1 /// Contains a type that can parse data into a command.
2 module jaster.cli.commandparser;
3 
4 import std.traits, std.algorithm, std.conv, std.format, std.typecons;
5 import jaster.cli.infogen, jaster.cli.binder, jaster.cli.result, jaster.cli.parser;
6 
7 /++
8  + A type that can parse an argument list into a command.
9  +
10  + Description:
11  +  One may wonder, "what's the difference between `CommandParser` and `CommandLineInterface`?".
12  +
13  +  The answer is simple: `CommandParser` $(B only) performs argument parsing and value binding for a single command,
14  +  whereas `CommandLineInterface` builds on top of `CommandParser` and several other components in order to support
15  +  multiple commands via a complete CLI interface.
16  +
17  +  So in short, if all you want from JCLI is its command modeling and parsing abilties without all the extra faff
18  +  provided by `CommandLineInterface`, and you're fine with performing execution by yourself, then you'll want to use
19  +  this type.
20  +
21  + Commands_:
22  +  Commands and arguments are defined in the same way as `CommandLineInterface` documents.
23  +
24  +  However, you don't need to define an `onExecute` function as this type has no concept of executing commands, only parsing them.
25  +
26  + Dependency_Injection:
27  +  This is a feature provided by `CommandLineInterface`, not `CommandParser`.
28  +
29  +  Command instances must be constructed outside of `CommandParser`, as it has no knowledge on how to do this, it only knows how to parse data into it.
30  +
31  + Params:
32  +  CommandT = The type of your command.
33  +  ArgBinderInstance = The `ArgBinder` to use when binding arguments to the user provided values.
34  + ++/
35 struct CommandParser(alias CommandT, alias ArgBinderInstance = ArgBinder!())
36 {
37     /// The `CommandInfo` for the command being parsed. Special note is that this is compile-time viewable.
38     enum Info = getCommandInfoFor!(CommandT, ArgBinderInstance);
39 
40     private static struct ArgRuntimeInfo(ArgInfoT)
41     {
42         ArgInfoT argInfo;
43         bool wasFound;
44 
45         bool isNullable()
46         {
47             return (this.argInfo.existence & CommandArgExistence.optional) > 0;
48         }
49     }
50     private auto argInfoOf(ArgInfoT)(ArgInfoT info) { return ArgRuntimeInfo!ArgInfoT(info); }
51 
52     /// Same as `parse` except it will automatically construct an `ArgPullParser` for you.
53     Result!void parse(string[] args, ref CommandT commandInstance)
54     {
55         auto parser = ArgPullParser(args);
56         return this.parse(parser, commandInstance);
57     }
58 
59     /++
60      + Parses the given arguments into your command instance.
61      +
62      + Description:
63      +  This performs the full value parsing as described in `CommandLineInterface`.
64      +
65      + Notes:
66      +  If the argument parsing fails, your command instance and parser $(B can be in a half-parsed state).
67      +
68      + Params:
69      +  parser = The parser containing the argument tokens.
70      +  commandInstance = The instance of your `CommandT` to populate.
71      +
72      + Returns:
73      +  A successful result (`Result.isSuccess`) if argument parsing and binding succeeded, otherwise a failure result
74      +  with an error (`Result.asFailure.error`) describing what happened. This error is user-friendly.
75      +
76      + See_Also:
77      +  `jaster.cli.core.CommandLineInterface` as it goes over everything in detail.
78      +
79      +  This project's README also goes into detail about how commands are parsed.
80      + ++/
81     Result!void parse(ref ArgPullParser parser, ref CommandT commandInstance)
82     {
83         auto namedArgs = this.getNamedArgs();
84         auto positionalArgs = this.getPositionalArgs();
85 
86         size_t positionalArgIndex = 0;
87         bool breakOuterLoop = false;
88         for(; !parser.empty && !breakOuterLoop; parser.popFront())
89         {
90             const token = parser.front();
91             final switch(token.type) with(ArgTokenType)
92             {
93                 case None: assert(false);
94                 case EOF: break;
95 
96                 // Positional Argument
97                 case Text:
98                     if(positionalArgIndex >= positionalArgs.length)
99                     {
100                         return typeof(return).failure(
101                             "too many arguments starting at '%s'".format(token.value)
102                         );
103                     }
104 
105                     auto actionResult = positionalArgs[positionalArgIndex].argInfo.actionFunc(token.value, commandInstance);
106                     positionalArgs[positionalArgIndex++].wasFound = true;
107 
108                     if(!actionResult.isSuccess)
109                     {
110                         return typeof(return).failure(
111                             "positional argument %s ('%s'): %s"
112                             .format(positionalArgIndex-1, positionalArgs[positionalArgIndex-1].argInfo.uda.name, actionResult.asFailure.error)
113                         );
114                     }
115                     break;
116 
117                 // Named Argument
118                 case LongHandArgument:
119                     if(token.value == "-" || token.value == "") // --- || --
120                     {
121                         breakOuterLoop = true;                        
122                         static if(!Info.rawListArg.isNull)
123                             mixin("commandInstance.%s = parser.unparsedArgs;".format(Info.rawListArg.get.identifier));
124                         break;
125                     }
126                     goto case;
127                 case ShortHandArgument:
128                     const argIndex = namedArgs.countUntil!"a.argInfo.uda.pattern.matchSpaceless(b)"(token.value);
129                     if(argIndex < 0)
130                         return typeof(return).failure("unknown argument '%s'".format(token.value));
131 
132                     if(namedArgs[argIndex].wasFound && (namedArgs[argIndex].argInfo.existence & CommandArgExistence.multiple) == 0)
133                         return typeof(return).failure("multiple definitions of argument '%s'".format(token.value));
134 
135                     namedArgs[argIndex].wasFound = true;
136                     auto argParseResult = this.performParseScheme(parser, commandInstance, namedArgs[argIndex].argInfo);
137                     if(!argParseResult.isSuccess)
138                         return typeof(return).failure("named argument '%s': ".format(token.value)~argParseResult.asFailure.error);
139                     break;
140             }
141         }
142 
143         auto validateResult = this.validateArgs(namedArgs, positionalArgs);
144         return validateResult;
145     }
146 
147     private Result!void performParseScheme(ref ArgPullParser parser, ref CommandT commandInstance, NamedArgumentInfo!CommandT argInfo)
148     {
149         final switch(argInfo.parseScheme) with(CommandArgParseScheme)
150         {
151             case default_: return this.parseDefault(parser, commandInstance, argInfo);
152             case allowRepeatedName: return this.parseRepeatableName(parser, commandInstance, argInfo);
153             case bool_: return this.parseBool(parser, commandInstance, argInfo);
154         }
155     }
156 
157     private Result!void parseBool(ref ArgPullParser parser, ref CommandT commandInstance, NamedArgumentInfo!CommandT argInfo)
158     {
159         // Bools have special support:
160         //  If they are defined, they are assumed to be true, however:
161         //      If the next token is Text, and its value is one of a predefined list, then it is then sent to the ArgBinder instead of defaulting to true.
162 
163         auto parserCopy = parser;
164         parserCopy.popFront();
165 
166         if(parserCopy.empty
167         || parserCopy.front.type != ArgTokenType.Text
168         || !["true", "false"].canFind(parserCopy.front.value))
169             return argInfo.actionFunc("true", /*ref*/ commandInstance);
170 
171         auto result = argInfo.actionFunc(parserCopy.front.value, /*ref*/ commandInstance);
172         parser.popFront(); // Keep the main parser up to date.
173 
174         return result;
175     }
176 
177     private Result!void parseDefault(ref ArgPullParser parser, ref CommandT commandInstance, NamedArgumentInfo!CommandT argInfo)
178     {
179         parser.popFront();
180 
181         if(parser.front.type == ArgTokenType.EOF)
182             return typeof(return).failure("defined without a value.");
183         else if(parser.front.type != ArgTokenType.Text)
184             return typeof(return).failure("expected a value, not an argument name.");
185 
186         return argInfo.actionFunc(parser.front.value, /*ref*/ commandInstance);
187     }
188 
189     private Result!void parseRepeatableName(ref ArgPullParser parser, ref CommandT commandInstance, NamedArgumentInfo!CommandT argInfo)
190     {
191         auto parserCopy  = parser;
192         auto incrementBy = 1;
193         
194         // Support "-vvvvv" syntax.
195         parserCopy.popFront();
196         if(parser.front.type == ArgTokenType.ShortHandArgument 
197         && parserCopy.front.type == ArgTokenType.Text
198         && parserCopy.front.value.all!(c => c == parser.front.value[0]))
199         {
200             incrementBy += parserCopy.front.value.length;
201             parser.popFront(); // keep main parser up to date.
202         }
203 
204         // .actionFunc will perform an increment each call.
205         foreach(i; 0..incrementBy)
206             argInfo.actionFunc(null, /*ref*/ commandInstance);
207 
208         return Result!void.success();
209     }
210 
211     private ArgRuntimeInfo!(NamedArgumentInfo!CommandT)[] getNamedArgs()
212     {
213         typeof(return) toReturn;
214 
215         foreach(arg; Info.namedArgs)
216         {
217             arg.uda.pattern.assertNoWhitespace();
218             toReturn ~= this.argInfoOf(arg);
219         }
220 
221         // TODO: Forbid arguments that have the same pattern and/or subpatterns.
222 
223         return toReturn;
224     }
225 
226     private ArgRuntimeInfo!(PositionalArgumentInfo!CommandT)[] getPositionalArgs()
227     {
228         typeof(return) toReturn;
229 
230         foreach(arg; Info.positionalArgs)
231             toReturn ~= this.argInfoOf(arg);
232 
233         toReturn.sort!"a.argInfo.uda.position < b.argInfo.uda.position"();
234         foreach(i, arg; toReturn)
235         {
236             assert(
237                 arg.argInfo.uda.position == i, 
238                 "Expected positional argument %s to take up position %s, not %s."
239                 .format(toReturn[i].argInfo.uda.name, i, arg.argInfo.uda.position)
240             );
241         }
242 
243         // TODO: Make sure there are no optional args appearing before any mandatory ones.
244 
245         return toReturn;
246     }
247     
248     private Result!void validateArgs(
249         ArgRuntimeInfo!(NamedArgumentInfo!CommandT)[] namedArgs,
250         ArgRuntimeInfo!(PositionalArgumentInfo!CommandT)[] positionalArgs
251     )
252     {
253         import std.algorithm : filter, map;
254         import std.format    : format;
255         import std.exception : assumeUnique;
256 
257         char[] error;
258         error.reserve(512);
259 
260         // Check for missing args.
261         auto missingNamedArgs      = namedArgs.filter!(a => !a.isNullable && !a.wasFound);
262         auto missingPositionalArgs = positionalArgs.filter!(a => !a.isNullable && !a.wasFound);
263         if(!missingNamedArgs.empty)
264         {
265             foreach(arg; missingNamedArgs)
266             {
267                 const name = arg.argInfo.uda.pattern.defaultPattern;
268                 error ~= (name.length == 1) ? "-" : "--";
269                 error ~= name;
270                 error ~= ", ";
271             }
272         }
273         if(!missingPositionalArgs.empty)
274         {
275             foreach(arg; missingPositionalArgs)
276             {
277                 error ~= "<";
278                 error ~= arg.argInfo.uda.name;
279                 error ~= ">, ";
280             }
281         }
282 
283         if(error.length > 0)
284         {
285             error = error[0..$-2]; // Skip extra ", "
286             return Result!void.failure("missing required arguments " ~ error.assumeUnique);
287         }
288 
289         return Result!void.success();
290     }
291 }
292 
293 version(unittest)
294 {
295     // For the most part, these are just some choice selections of tests from core.d that were moved over.
296 
297     // NOTE: The only reason it can see and use private @Commands is because they're in the same module.
298     @Command("", "This is a test command")
299     private struct CommandTest
300     {
301         // These are added to test that they are safely ignored.
302         alias al = int;
303         enum e = 2;
304         struct S
305         {
306         }
307         void f () {}
308 
309         @CommandNamedArg("a|avar", "A variable")
310         int a;
311 
312         @CommandPositionalArg(0, "b")
313         Nullable!string b;
314 
315         @CommandNamedArg("c")
316         Nullable!bool c;
317     }
318     @("General test")
319     unittest
320     {
321         auto command = CommandParser!CommandTest();
322         auto instance = CommandTest();
323 
324         resultAssert(command.parse(["-a 20"], instance));
325         assert(instance.a == 20);
326         assert(instance.b.isNull);
327         assert(instance.c.isNull);
328 
329         instance = CommandTest.init;
330         resultAssert(command.parse(["20", "--avar 20"], instance));
331         assert(instance.a == 20);
332         assert(instance.b.get == "20");
333 
334         instance = CommandTest.init;
335         resultAssert(command.parse(["-a 20", "-c"], instance));
336         assert(instance.c.get);
337     }
338 
339     @Command("booltest", "Bool test")
340     private struct BoolTestCommand
341     {
342         @CommandNamedArg("a")
343         bool definedNoValue;
344 
345         @CommandNamedArg("b")
346         bool definedFalseValue;
347 
348         @CommandNamedArg("c")
349         bool definedTrueValue;
350 
351         @CommandNamedArg("d")
352         bool definedNoValueWithArg;
353 
354         @CommandPositionalArg(0)
355         string comesAfterD;
356     }
357     @("Test that booleans are handled properly")
358     unittest
359     {
360         auto command = CommandParser!BoolTestCommand();
361         auto instance = BoolTestCommand();
362 
363         resultAssert(command.parse(["-a", "-b=false", "-c", "true", "-d", "Lalafell"], instance));
364         assert(instance.definedNoValue);
365         assert(!instance.definedFalseValue);
366         assert(instance.definedTrueValue);
367         assert(instance.definedNoValueWithArg);
368         assert(instance.comesAfterD == "Lalafell");
369     }
370 
371     @Command("rawListTest", "Test raw lists")
372     private struct RawListTestCommand
373     {
374         @CommandNamedArg("a")
375         bool dummyThicc;
376 
377         @CommandRawListArg
378         string[] rawList;
379     }
380     @("Test that raw lists work")
381     unittest
382     {
383         CommandParser!RawListTestCommand command;
384         RawListTestCommand instance;
385 
386         resultAssert(command.parse(["-a", "--", "raw1", "raw2"], instance));
387         assert(instance.rawList == ["raw1", "raw2"], "%s".format(instance.rawList));
388     }
389 
390     @ArgValidator
391     private struct Expect(T)
392     {
393         T value;
394 
395         Result!void onValidate(T boundValue)
396         {
397             import std.format : format;
398 
399             return this.value == boundValue
400             ? Result!void.success()
401             : Result!void.failure("Expected value to equal '%s', not '%s'.".format(this.value, boundValue));
402         }
403     }
404 
405     @Command("validationTest", "Test validation")
406     private struct ValidationTestCommand
407     {
408         @CommandPositionalArg(0)
409         @Expect!string("lol")
410         string value;
411     }
412     @("Test ArgBinder validation integration")
413     unittest
414     {
415         CommandParser!ValidationTestCommand command;
416         ValidationTestCommand instance;
417 
418         resultAssert(command.parse(["lol"], instance));
419         assert(instance.value == "lol");
420         
421         assert(!command.parse(["nan"], instance).isSuccess);
422     }
423 
424     @Command("arg action count", "Test that the count arg action works")
425     private struct ArgActionCount
426     {
427         @CommandNamedArg("c")
428         @(CommandArgAction.count)
429         int c;
430     }
431     @("Test that CommandArgAction.count works.")
432     unittest
433     {
434         CommandParser!ArgActionCount command;
435 
436         void test(string[] args, int expectedCount)
437         {
438             ArgActionCount instance;
439             resultAssert(command.parse(args, instance));
440             assert(instance.c == expectedCount);
441         }
442 
443         ArgActionCount instance;
444 
445         test([], 0);
446         test(["-c"], 1);
447         test(["-c", "-c"], 2);
448         test(["-ccccc"], 5);
449         assert(!command.parse(["-ccv"], instance).isSuccess); // -ccv -> [name '-c', positional 'cv']. -1 because too many positional args.
450         test(["-c", "cccc"], 5); // Unfortunately this case also works because of limitations in ArgPullParser
451     }
452 }