1 /** 2 * Command framework for IRC bots. 3 * 4 * Groups of commands are bundled as _command sets, 5 * which are defined by classes deriving from $(MREF CommandSet). 6 * 7 * All _command sets implement the $(MREF ICommandSet) interface, 8 * which presents basic operations for _command sets. 9 * 10 * Commands are represented by the $(MREF Command) struct. 11 * 12 * See_Also: 13 * $(MREF CommandSet) 14 */ 15 module diggler.command; 16 17 import std.array; 18 import std.exception; 19 import std.traits; 20 21 import diggler.attribute; 22 import diggler.bot; 23 import diggler.context; 24 import diggler.util; 25 26 import irc.client; 27 28 /// Can be thrown by command implementations to signal a problem 29 /// with the command arguments. 30 class CommandArgumentException : Exception 31 { 32 import irc.util : ExceptionConstructor; 33 mixin ExceptionConstructor!(); 34 } 35 36 /// Represents a single command. 37 struct Command 38 { 39 string name; 40 string[] aliases; 41 string usage; 42 43 static struct ParameterInfo 44 { 45 string name; 46 string typeName; 47 string defaultArgument; // null when this parameter has no default argument 48 string displayName; 49 50 static immutable angleBrackets = "<>"; 51 static immutable squareBrackets = "[]"; 52 53 this(string name, string typeName, bool optional, string defaultArgument, bool variadic) 54 { 55 this.name = name; 56 this.typeName = typeName; 57 this.defaultArgument = defaultArgument; 58 59 auto brackets = optional? squareBrackets : angleBrackets; 60 61 auto app = appender!string(); 62 63 app ~= brackets[0]; 64 app ~= name; 65 66 if(variadic) 67 app ~= "..."; 68 69 auto isString = typeName == "string"; 70 71 if(defaultArgument) 72 { 73 app ~= " = "; 74 75 if(isString) 76 app ~= '\"'; 77 78 app ~= defaultArgument; 79 80 if(isString) 81 app ~= '\"'; 82 } 83 84 if(!isString) 85 { 86 app ~= " ("; 87 app ~= typeName; 88 app ~= ")"; 89 } 90 91 app ~= brackets[1]; 92 93 this.displayName = app.data; 94 } 95 } 96 97 ParameterInfo[] parameterInfo; 98 bool adminOnly = false; 99 bool channelOnly = false; 100 bool variadic = false; 101 102 package: 103 void delegate(string strArgs) handler; 104 105 static Command create(alias handler, T)(T dg) if(is(T == delegate)) 106 { 107 alias RetType = ReturnType!T; 108 static immutable primaryName = __traits(identifier, handler); 109 110 static assert(is(RetType == void), 111 format("return type of command handler `%s` must be `void`, not `%s`", 112 fullyQualifiedName!handler, 113 RetType.stringof)); 114 115 alias Args = FillableParameterTypeTuple!T; 116 alias defaultArgs = ParameterDefaultValueTuple!handler; 117 enum isVariadic = variadicFunctionStyle!handler == Variadic.typesafe; 118 119 void handleCommand(string strArgs) // TODO: code size reduction potential here 120 { 121 import std.algorithm : findSplitBefore; 122 import std.uni : isWhite; // TODO 123 import std..string : strip, stripLeft; 124 125 strArgs = strArgs.strip(); 126 127 Args args; 128 129 enum firstDefaultArg = computeFirstDefaultArg!defaultArgs; 130 enum hasDefaultArgs = firstDefaultArg != -1; 131 132 static immutable expectedMoreArgsMsg = hasDefaultArgs || isVariadic? 133 `got %s argument(s), expected at least %s for command "%s"` : 134 `got %s argument(s), expected %s for command "%s"`; 135 136 foreach(i, ref arg; args) 137 { 138 alias Arg = typeof(arg); 139 enum isLastArgument = i == args.length - 1; 140 141 static if(is(Arg == string[])) 142 { 143 static assert(isLastArgument, fullyQualifiedName!handler ~ `: string[] parameter can only appear at the end of the parameter list`); 144 static assert(isVariadic, fullyQualifiedName!handler ~ `: string[] parameter must be variadic`); 145 } 146 else 147 static assert(isValidCommandParameterType!Arg, 148 format("parameter #%s of command handler `%s` is of unsupported type `%s`", 149 i + 1, 150 fullyQualifiedName!handler, 151 Arg.stringof)); 152 153 alias defaultArg = defaultArgs[i]; 154 155 static if(is(defaultArg == void)) 156 { 157 static if(!(isLastArgument && isVariadic)) // Variadic arguments can be empty 158 enforceEx!CommandArgumentException(!strArgs.empty, 159 format(expectedMoreArgsMsg, 160 i, 161 hasDefaultArgs? firstDefaultArg : args.length - isVariadic, 162 primaryName)); 163 } 164 else 165 { 166 if(strArgs.empty) 167 { 168 arg = defaultArg; 169 } 170 } 171 172 if(!strArgs.empty) 173 { 174 auto result = strArgs.findSplitBefore(" "); 175 176 auto tail = result[1].stripLeft(); 177 178 static if(isLastArgument && 179 (is(Arg : const(char)[]) || is(Arg : const(char[])[]))) 180 { 181 auto rawArg = strArgs; 182 strArgs = null; 183 } 184 else 185 { 186 auto rawArg = result[0]; 187 strArgs = tail; 188 } 189 190 arg = parseCommandArgument!Arg(rawArg, primaryName, i + 1); 191 } 192 } 193 194 enforceEx!CommandArgumentException(strArgs.empty, 195 format(`too many arguments to command "%s", expected %s`, 196 primaryName, 197 args.length)); 198 199 dg(args); 200 } 201 202 Command cmd; 203 cmd.name = primaryName; 204 205 static if(isVariadic) 206 cmd.variadic = true; 207 208 static if(hasAttribute!(handler, .aliases)) 209 cmd.aliases = getAttribute!(handler, .aliases).value; 210 211 static if(hasAttribute!(handler, .usage)) 212 cmd.usage = getAttribute!(handler, .usage).value; 213 214 static if(hasAttribute!(handler, .admin)) 215 cmd.adminOnly = true; 216 217 static if(hasAttribute!(handler, .channelOnly)) 218 cmd.channelOnly = true; 219 220 foreach(i, paramName; ParameterIdentifierTuple!handler) 221 { 222 import std.conv : to; 223 224 alias defaultArg = defaultArgs[i]; 225 static if(is(defaultArg == void)) 226 { 227 bool optional = false; 228 string defaultValue = null; 229 } 230 else 231 { 232 bool optional = true; 233 static if(isSomeString!(typeof(defaultArg))) 234 { 235 string defaultValue = defaultArg.ptr? to!string(defaultArg) : null; 236 } 237 else 238 string defaultValue = to!string(defaultArg); 239 } 240 241 bool vararg = isVariadic && i == Args.length - 1; 242 cmd.parameterInfo ~= ParameterInfo(paramName, commandParameterTypeName!(Args[i]), optional, defaultValue, vararg); 243 } 244 245 cmd.handler = &handleCommand; 246 247 return cmd; 248 } 249 } 250 251 // TODO: Simplify? 252 private template computeFirstDefaultArgImpl(int count, args...) 253 { 254 static if(args.length > 0 && is(args[0] == void)) 255 { 256 enum computeFirstDefaultArgImpl = computeFirstDefaultArgImpl!(count, args[1 .. $]) + 1; 257 } 258 else 259 enum computeFirstDefaultArgImpl = count; 260 } 261 262 private template computeFirstDefaultArg(args...) 263 { 264 private enum result = computeFirstDefaultArgImpl!(0, args); 265 enum computeFirstDefaultArg = result == args.length? -1 : result; 266 } 267 268 private template commandParameterTypeName(T) 269 { 270 static if(is(T : const(char)[]) || is(T== string[])) 271 enum commandParameterTypeName = "string"; 272 else static if(isIntegral!T) 273 { 274 static if(isSigned!T) 275 enum commandParameterTypeName = "integer"; 276 else 277 enum commandParameterTypeName = "positive integer"; 278 } 279 else static if(isFloatingPoint!T) 280 enum commandParameterTypeName = "number"; 281 else static if(is(T == dchar)) 282 enum commandParameterTypeName = "character"; 283 else 284 static assert(false); 285 } 286 287 private template isValidCommandParameterType(T) 288 { 289 enum isValidCommandParameterType = 290 (!is(T == char[]) && is(T : const(char)[])) || 291 isIntegral!T || 292 isFloatingPoint!T || 293 is(T == dchar); 294 } 295 296 private T parseCommandArgument(T)(string strArg, string cmdName, size_t argNum) 297 { 298 import std.conv : ConvException, parse; 299 300 auto makeError(Exception cause) 301 { 302 auto msg = format("expected " ~ commandParameterTypeName!T ~ " for argument #%s of command \"%s\", not `%s`", 303 argNum, 304 cmdName, 305 strArg); 306 307 return new CommandArgumentException(msg, __FILE__, __LINE__, cause); 308 } 309 310 auto parsedArg = strArg; 311 T arg; 312 try arg = parsedArg.parse!T(); 313 catch(ConvException e) 314 { 315 throw makeError(e); 316 } 317 318 enforce(parsedArg.empty, makeError(null)); 319 320 return arg; 321 } 322 323 private T parseCommandArgument(T : const(char)[])(string strArg, string cmdName, size_t argNum) 324 { 325 return strArg; 326 } 327 328 private T parseCommandArgument(T : const(char[])[])(string strArg, string cmdName, size_t argNum) 329 { 330 import std.array : split; 331 return strArg.split(); 332 } 333 334 /** 335 * Basic interface of all command sets. 336 */ 337 interface ICommandSet 338 { 339 /// Human-readable categorical name for the 340 /// commands in the set. 341 /// See_Also: $(DPREF attribute, _category) 342 string category() @property @safe pure nothrow; 343 344 /** 345 * Context for the currently executing command. 346 * See_Also: 347 * $(DPREF _context, Context) 348 */ 349 ref Context context() @property @safe pure nothrow; 350 351 /** 352 * Add the command cmd to the command set. 353 */ 354 void add(ref Command cmd); 355 356 /** 357 * Lookup a command in this command set by one of its names. 358 */ 359 Command* getCommand(in char[] cmdName); 360 361 /// Sorted list of the primary names of all commands in the 362 /// command set. 363 string[] commandNames() @property @safe; 364 } 365 366 /** 367 * Base class for command sets. 368 * 369 * Commands are implemented by adding public, non-static methods 370 * to the derived class. Non-public or static methods of the derived 371 * class are ignored, as well as methods with the 372 * $(DPREF attribute, ignore) attribute. 373 * 374 * The name of the method becomes the primary name 375 * through which the command is invoked in chat. Other names may be added 376 * by tagging the method with the $(D @aliases) ($(DPREF attribute, aliases)) 377 * command attribute. When the command is invoked through one of its 378 * names, the method is called. 379 * 380 * Commands are invoked by sending a message to a channel the bot 381 * is a member of, where the message starts with the bot's command prefix 382 * followed by the name of the command to invoke. Whitespace-separated words 383 * following the command name are parsed as arguments to the command. 384 * 385 * The arguments to the chat command map one-to-one to the parameters 386 * of the method. The method's allowed parameter types are: 387 * const or immutable UTF-8 strings, integers and floating point numbers. 388 * 389 * If the method's last parameter type is a string, then it is passed all the 390 * text passed in chat after the previous arguments, including whitespace. 391 * 392 * If the method's last parameter is an array of strings, then the method 393 * must also be marked typesafe-variadic; the array is filled with all 394 * whitespace-separated arguments passed after arguments to preceding 395 * parameters. If no such arguments are passed, the array is empty. 396 * 397 * Parameters may have default arguments. If a command invocation does not 398 * pass an argument to a parameter with a default argument, the default 399 * argument is used instead. 400 * 401 * If an argument is not passed to a parameter without a default argument, 402 * or a non-integer is passed to an integer parameter or a non-number is 403 * passed to a floating point parameter, then the bot replies with 404 * an error message and the command method is not called. 405 * 406 * See $(DPMODULE attribute) for a list of attributes that can be attached 407 * to command methods to alter the behaviour of the command. 408 * 409 * This type subtypes a context object ($(DPREF context, Context)) that 410 * provides contextual operations and information for 411 * use by command method implementations. 412 * 413 * Params: 414 * T = type with command implementation methods. Must be the derived class 415 */ 416 abstract class CommandSet(T) : ICommandSet 417 { 418 private: 419 Command[string] _commands; 420 string[] _commandNames; // TODO: Not a static, immutable property because of template bugs (2.063) 421 Context _context; 422 423 final void registerCommands(T cmdSet) 424 { 425 foreach(memberName; __traits(derivedMembers, T)) 426 { 427 static if( 428 memberName != "__ctor" && 429 memberName != "__dtor" && 430 __traits(compiles, __traits(getMember, T, memberName)) && // ahem... 431 __traits(getProtection, __traits(getMember, T, memberName)) == "public" && 432 isSomeFunction!(mixin("T." ~ memberName)) && 433 !__traits(isStaticFunction, mixin("T." ~ memberName)) && 434 !hasAttribute!(mixin("T." ~ memberName), ignore)) 435 { 436 auto dg = &mixin("cmdSet." ~ memberName); 437 auto cmd = Command.create!(mixin("T." ~ memberName))(dg); 438 add(cmd); 439 } 440 } 441 } 442 443 public: 444 this() 445 { 446 auto cmdSet = enforce(cast(T)this); 447 registerCommands(cmdSet); 448 } 449 450 // See CommandContext 451 //alias context this; 452 453 override: // Implement ICommandSet 454 ref Context context() 455 { 456 return _context; 457 } 458 459 string category() 460 { 461 static if(hasAttribute!(T, .category)) 462 return getAttribute!(T, .category).value; 463 else 464 return null; 465 } 466 467 void add(ref Command cmd) 468 { 469 import std.range : assumeSorted, chain; 470 import irc.util : values; 471 472 foreach(name; values(cmd.name).chain(cmd.aliases)) 473 _commands[name] = cmd; 474 475 auto pos = _commandNames.assumeSorted().lowerBound(cmd.name).length; 476 _commandNames.insertInPlace(pos, cmd.name); 477 } 478 479 Command* getCommand(in char[] cmdName) 480 { 481 return cmdName in _commands; 482 } 483 484 string[] commandNames() 485 { 486 return _commandNames; 487 } 488 } 489 490 /** 491 * Temporary workaround for compiler bugs as of DMD front-end version 2.063. 492 * This mixin template must be mixed into deriviate classes of $(MREF CommandSet). 493 */ 494 // TODO: Temporary, figure out various bugs 495 mixin template CommandContext() 496 { 497 alias context this; 498 }