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 }