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 }