1 module jcli.cli;
2 
3 import jcli, std;
4 
5 final class CommandLineInterface(Modules...)
6 {
7     alias ArgBinderInstance = ArgBinder!Modules;
8 
9     private alias CommandExecute = int delegate(ArgParser);
10     private alias CommandHelp    = string delegate();
11 
12     private struct CommandInfo
13     {
14         CommandExecute onExecute;
15         CommandHelp onHelp;
16         Pattern pattern;
17         string description;
18     }
19 
20     private
21     {
22         Resolver!CommandInfo _resolver;
23         CommandInfo[] _uniqueCommands;
24         CommandInfo _default;
25         string _appName;
26     }
27 
28     this()
29     {
30         this._resolver = new typeof(_resolver)();
31         static foreach(mod; Modules)
32             this.findCommands!mod;
33         this._appName = thisExePath().baseName;
34     }
35 
36     int parseAndExecute(string[] args, bool ignoreFirstArg = true)
37     {
38         return this.parseAndExecute(ArgParser(ignoreFirstArg ? args[1..$] : args));
39     }
40 
41     int parseAndExecute(ArgParser parser)
42     {
43         auto parserCopy = parser;
44         if(parser.empty)
45             parser = ArgParser(["-h"]);
46 
47         string[] args;
48         auto command = this.resolveCommand(parser, args);
49         if(command.kind == command.Kind.partial || command == typeof(command).init)
50         {
51             if(this._default == CommandInfo.init)
52             {
53                 HelpText help = HelpText.make(180);
54                 
55                 if(parserCopy.empty || parserCopy == ArgParser(["-h"]))
56                     help.addHeader("Available commands:");
57                 else
58                 {
59                     help.addLineWithPrefix(this._appName~": ", "Unknown command", AnsiStyleSet.init.fg(Ansi4BitColour.red).nullable);
60                     help.addLine(null);
61                     help.addHeader("Did you mean:");
62                 }
63                 foreach(comm; this._uniqueCommands)
64                     help.addArgument(comm.pattern.patterns.front, [HelpTextDescription(0, comm.description)]);
65                 writeln(help.finish());
66                 return -1;
67             }
68             else
69             {
70                 if(this.hasHelpArgument(parser) && !parserCopy.empty)
71                 {
72                     writeln(this._default.onHelp());
73                     return 0;
74                 }
75 
76                 try return this._default.onExecute(parserCopy);
77                 catch(Exception ex)
78                 {
79                     writefln("%s: %s", this._appName.ansi.fg(Ansi4BitColour.red), ex.msg);
80                     debug writeln(ex);
81                     return -1;
82                 }
83             }
84         }
85 
86         if(this.hasHelpArgument(parser))
87         {
88             writeln(command.fullMatchChain[$-1].userData.onHelp());
89             return 0;
90         }
91         else if(args.length && args[$-1] == "--__jcli:complete")
92         {
93             args = args[0..$-1];
94 
95             if(command.valueProvider)
96                 writeln(command.valueProvider(args));
97             else
98                 writeln("Command does not contain a value provider.");
99             return 0;
100         }
101 
102         try return command.fullMatchChain[$-1].userData.onExecute(parser);
103         catch(Exception ex)
104         {
105             writefln("%s: %s", this._appName.ansi.fg(Ansi4BitColour.red), ex.msg);
106             debug writeln("[debug-only] JCLI has displayed this exception in full for your convenience.");
107             debug writeln(ex);
108             return -1;
109         }
110     }
111 
112     ResolveResult!CommandInfo resolveCommand(ref ArgParser parser, out string[] args)
113     {
114         typeof(return) lastPartial;
115 
116         string[] command;
117         scope(exit)
118             args = parser.map!(r => r.fullSlice).array;
119 
120         while(true)
121         {
122             if(parser.empty)
123                 return lastPartial;
124             if(parser.front.kind == ArgParser.Result.Kind.argument)
125                 return lastPartial;
126 
127             command ~= parser.front.fullSlice;
128             auto result = this._resolver.resolve(command);
129 
130             if(result.kind == result.Kind.partial)
131                 lastPartial = result;
132             else
133             {
134                 parser.popFront();
135                 return result;
136             }
137 
138             parser.popFront();
139         }
140     }
141 
142     private bool hasHelpArgument(ArgParser parser)
143     {
144         return parser
145                 .filter!(r => r.kind == r.Kind.argument)
146                 .any!(r => r.nameSlice == "h" || r.nameSlice == "help");
147     }
148 
149     private void findCommands(alias Module)()
150     {
151         static foreach(member; __traits(allMembers, Module))
152         {{
153             alias Symbol = __traits(getMember, Module, member);
154             static if(hasUDA!(Symbol, Command) || hasUDA!(Symbol, CommandDefault))
155                 this.getCommand!Symbol;
156         }}
157     }
158 
159     private void getCommand(alias CommandT)()
160     {
161         CommandInfo info;
162 
163         info.onHelp = getOnHelp!CommandT();
164         info.onExecute = getOnExecute!CommandT();
165 
166         static if(hasUDA!(CommandT, Command))
167         {
168             info.pattern = getUDAs!(CommandT, Command)[0].pattern;
169             info.description = getUDAs!(CommandT, Command)[0].description;
170             foreach(pattern; info.pattern.patterns)
171                 this._resolver.add(pattern.splitter(' ').array, info, &(AutoComplete!CommandT()).complete);
172             this._uniqueCommands ~= info;
173         }
174         else
175             this._default = info;
176     }
177 
178     private CommandExecute getOnExecute(alias CommandT)()
179     {
180         return (ArgParser parser) 
181         {
182             auto comParser = CommandParser!(CommandT, ArgBinderInstance)();
183             auto result = comParser.parse(parser);
184             enforce(result.isOk, result.error);
185 
186             auto com = result.value;
187             static if(is(typeof(com.onExecute()) == int))
188                 return com.onExecute();
189             else
190             {
191                 com.onExecute();
192                 return 0;
193             }
194         };
195     }
196 
197     private CommandHelp getOnHelp(alias CommandT)()
198     {
199         return ()
200         {
201             return CommandHelpText!CommandT().generate();
202         };
203     }
204 }
205 
206 version(unittest)
207 @Command("assert even|ae|a e", "Asserts that the given number is even.")
208 private struct AssertEvenCommand
209 {
210     @ArgPositional("number", "The number to assert.")
211     int number;
212 
213     @ArgNamed("reverse|r", "If specified, then assert that the number is ODD instead.")
214     Nullable!bool reverse;
215 
216     int onExecute()
217     {
218         auto passedAssert = (this.reverse.get(false))
219                             ? this.number % 2 == 1
220                             : this.number % 2 == 0;
221 
222         return (passedAssert) ? 0 : 128;
223     }
224 }
225 
226 version(unittest)
227 @CommandDefault("echo")
228 private struct EchoCommand
229 {
230     @ArgOverflow
231     string[] overflow;
232 
233     int onExecute()
234     {
235         foreach(value; overflow)
236             writeln(value);
237         return 69;
238     }
239 }
240 
241 unittest
242 {
243     auto cli = new CommandLineInterface!(jcli.cli);
244     auto p = ArgParser(["a"]);
245     auto r = cli.resolveCommand(p);
246     assert(r.kind == r.Kind.partial);
247     assert(r.fullMatchChain.length == 1);
248     assert(r.fullMatchChain[0].fullMatchString == "a");
249     assert(r.partialMatches.length == 2);
250     assert(r.partialMatches[0].fullMatchString == "assert");
251     assert(r.partialMatches[1].fullMatchString == "ae");
252 
253     foreach(args; [["ae", "2"], ["assert", "even", "2"], ["a", "e", "2"]])
254     {
255         p = ArgParser(args);
256         r = cli.resolveCommand(p);
257         assert(r.kind == r.Kind.full);
258         assert(r.fullMatchChain.length == args.length-1);
259         assert(r.fullMatchChain.map!(fm => fm.fullMatchString).equal(args[0..$-1]));
260         assert(p.front.fullSlice == "2", p.to!string);
261         assert(r.fullMatchChain[$-1].userData.onExecute(p) == 0);
262     }
263 
264     foreach(args; [["ae", "1", "--reverse"], ["a", "e", "-r", "1"]])
265     {
266         p = ArgParser(args);
267         r = cli.resolveCommand(p);
268         assert(r.kind == r.Kind.full);
269         assert(r.fullMatchChain[$-1].userData.onExecute(p) == 0);
270     }
271 
272     assert(cli.parseAndExecute(["assert", "even", "2"], false) == 0);
273     assert(cli.parseAndExecute(["assert", "even", "1", "-r"], false) == 0);
274     assert(cli.parseAndExecute(["assert", "even", "2", "-r"], false) == 128);
275     assert(cli.parseAndExecute(["assert", "even", "1"], false) == 128);
276 
277     // Commented out to stop it from writing output.
278     // assert(cli.parseAndExecute(["assrt", "evn", "20"], false) == 69);
279 }