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));
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(ResultException ex)
78                 {
79                     writefln("%s: %s", this._appName.ansi.fg(Ansi4BitColour.red), ex.msg);
80                     debug writeln("[debug-only] JCLI has displayed this exception in full for your convenience.");
81                     debug writeln(ex);
82                     return ex.errorCode;
83                 }
84                 catch(Exception ex)
85                 {
86                     writefln("%s: %s", this._appName.ansi.fg(Ansi4BitColour.red), ex.msg);
87                     debug writeln("[debug-only] JCLI has displayed this exception in full for your convenience.");
88                     debug writeln(ex);
89                     return -1;
90                 }
91             }
92         }
93 
94         if(this.hasHelpArgument(parser))
95         {
96             writeln(command.fullMatchChain[$-1].userData.onHelp());
97             return 0;
98         }
99         else if(args.length && args[$-1] == "--__jcli:complete")
100         {
101             args = args[0..$-1];
102 
103             if(command.valueProvider)
104                 writeln(command.valueProvider(args));
105             else
106                 writeln("Command does not contain a value provider.");
107             return 0;
108         }
109 
110         try return command.fullMatchChain[$-1].userData.onExecute(parser);
111         catch(ResultException ex)
112         {
113             writefln("%s: %s", this._appName.ansi.fg(Ansi4BitColour.red), ex.msg);
114             debug writeln("[debug-only] JCLI has displayed this exception in full for your convenience.");
115             debug writeln(ex);
116             return ex.errorCode;
117         }
118         catch(Exception ex)
119         {
120             writefln("%s: %s", this._appName.ansi.fg(Ansi4BitColour.red), ex.msg);
121             debug writeln("[debug-only] JCLI has displayed this exception in full for your convenience.");
122             debug writeln(ex);
123             return -1;
124         }
125     }
126 
127     ResolveResult!CommandInfo resolveCommand(ref ArgParser parser, out string[] args)
128     {
129         typeof(return) lastPartial;
130 
131         string[] command;
132         scope(exit)
133             args = parser.map!(r => r.fullSlice).array;
134 
135         while(true)
136         {
137             if(parser.empty)
138                 return lastPartial;
139             if(parser.front.kind == ArgParser.Result.Kind.argument)
140                 return lastPartial;
141 
142             command ~= parser.front.fullSlice;
143             auto result = this._resolver.resolve(command);
144 
145             if(result.kind == result.Kind.partial)
146                 lastPartial = result;
147             else
148             {
149                 parser.popFront();
150                 return result;
151             }
152 
153             parser.popFront();
154         }
155     }
156 
157     private bool hasHelpArgument(ArgParser parser)
158     {
159         return parser
160                 .filter!(r => r.kind == r.Kind.argument)
161                 .any!(r => r.nameSlice == "h" || r.nameSlice == "help");
162     }
163 
164     private void findCommands(alias Module)()
165     {
166         static foreach(member; __traits(allMembers, Module))
167         {{
168             alias Symbol = __traits(getMember, Module, member);
169             static if(hasUDA!(Symbol, Command) || hasUDA!(Symbol, CommandDefault))
170                 this.getCommand!Symbol;
171         }}
172     }
173 
174     private void getCommand(alias CommandT)()
175     {
176         CommandInfo info;
177 
178         info.onHelp = getOnHelp!CommandT();
179         info.onExecute = getOnExecute!CommandT();
180 
181         static if(hasUDA!(CommandT, Command))
182         {
183             info.pattern = getUDAs!(CommandT, Command)[0].pattern;
184             info.description = getUDAs!(CommandT, Command)[0].description;
185             foreach(pattern; info.pattern.patterns)
186                 this._resolver.add(pattern.splitter(' ').array, info, &(AutoComplete!CommandT()).complete);
187             this._uniqueCommands ~= info;
188         }
189         else
190             this._default = info;
191     }
192 
193     private CommandExecute getOnExecute(alias CommandT)()
194     {
195         return (ArgParser parser) 
196         {
197             auto comParser = CommandParser!(CommandT, ArgBinderInstance)();
198             auto result = comParser.parse(parser);
199             result.enforceOk();
200 
201             auto com = result.value;
202             static if(is(typeof(com.onExecute()) == int))
203                 return com.onExecute();
204             else
205             {
206                 com.onExecute();
207                 return 0;
208             }
209         };
210     }
211 
212     private CommandHelp getOnHelp(alias CommandT)()
213     {
214         return ()
215         {
216             return CommandHelpText!CommandT().generate();
217         };
218     }
219 }
220 
221 version(unittest)
222 @Command("assert even|ae|a e", "Asserts that the given number is even.")
223 private struct AssertEvenCommand
224 {
225     @ArgPositional("number", "The number to assert.")
226     int number;
227 
228     @ArgNamed("reverse|r", "If specified, then assert that the number is ODD instead.")
229     Nullable!bool reverse;
230 
231     int onExecute()
232     {
233         auto passedAssert = (this.reverse.get(false))
234                             ? this.number % 2 == 1
235                             : this.number % 2 == 0;
236 
237         return (passedAssert) ? 0 : 128;
238     }
239 }
240 
241 version(unittest)
242 @CommandDefault("echo")
243 private struct EchoCommand
244 {
245     @ArgOverflow
246     string[] overflow;
247 
248     int onExecute()
249     {
250         foreach(value; overflow)
251             writeln(value);
252         return 69;
253     }
254 }
255 
256 unittest
257 {
258     auto cli = new CommandLineInterface!(jcli.cli);
259     auto p = ArgParser(["a"]);
260     string[] a;
261     auto r = cli.resolveCommand(p, a);
262     assert(r.kind == r.Kind.partial);
263     assert(r.fullMatchChain.length == 1);
264     assert(r.fullMatchChain[0].fullMatchString == "a");
265     assert(r.partialMatches.length == 2);
266     assert(r.partialMatches[0].fullMatchString == "assert");
267     assert(r.partialMatches[1].fullMatchString == "ae");
268 
269     foreach(args; [["ae", "2"], ["assert", "even", "2"], ["a", "e", "2"]])
270     {
271         p = ArgParser(args);
272         r = cli.resolveCommand(p, a);
273         assert(r.kind == r.Kind.full);
274         assert(r.fullMatchChain.length == args.length-1);
275         assert(r.fullMatchChain.map!(fm => fm.fullMatchString).equal(args[0..$-1]));
276         assert(p.front.fullSlice == "2", p.to!string);
277         assert(r.fullMatchChain[$-1].userData.onExecute(p) == 0);
278     }
279 
280     foreach(args; [["ae", "1", "--reverse"], ["a", "e", "-r", "1"]])
281     {
282         p = ArgParser(args);
283         r = cli.resolveCommand(p, a);
284         assert(r.kind == r.Kind.full);
285         assert(r.fullMatchChain[$-1].userData.onExecute(p) == 0);
286     }
287 
288     assert(cli.parseAndExecute(["assert", "even", "2"], false) == 0);
289     assert(cli.parseAndExecute(["assert", "even", "1", "-r"], false) == 0);
290     assert(cli.parseAndExecute(["assert", "even", "2", "-r"], false) == 128);
291     assert(cli.parseAndExecute(["assert", "even", "1"], false) == 128);
292 
293     // Commented out to stop it from writing output.
294     // assert(cli.parseAndExecute(["assrt", "evn", "20"], false) == 69);
295 }