1 /// Functionality for defining and resolving command "sentences". 2 module jaster.cli.resolver; 3 4 import std.range; 5 import jaster.cli.parser; 6 7 /// The type of a `CommandNode`. 8 enum CommandNodeType 9 { 10 /// Failsafe 11 ERROR, 12 13 /// Used for the root `CommandNode`. 14 root, 15 16 /// Used for `CommandNodes` that don't contain a command, but instead contain child `CommandNodes`. 17 /// 18 /// e.g. For "build all libraries", "build" and "all" would be `partialWords`. 19 partialWord, 20 21 /// Used for `CommandNodes` that contain a command. 22 /// 23 /// e.g. For "build all libraries", "libraries" would be a `finalWord`. 24 finalWord 25 } 26 27 /++ 28 + The result of a command resolution attempt. 29 + 30 + Params: 31 + UserDataT = See `CommandResolver`'s documentation. 32 + ++/ 33 struct CommandResolveResult(UserDataT) 34 { 35 /// Whether the resolution was successful (true) or not (false). 36 bool success; 37 38 /// The resolved `CommandNode`. This value is undefined when `success` is `false`. 39 CommandNode!UserDataT value; 40 } 41 42 /++ 43 + Contains a single "word" within a command "sentence", see `CommandResolver`'s documentation for more. 44 + 45 + Params: 46 + UserDataT = See `CommandResolver`'s documentation. 47 + ++/ 48 @safe 49 struct CommandNode(UserDataT) 50 { 51 /// The word this node contains. 52 string word; 53 54 /// What type of node this is. 55 CommandNodeType type; 56 57 /// The children of this node. 58 CommandNode!UserDataT[] children; 59 60 /// User-provided data for this node. Note that partial words don't contain any user data. 61 UserDataT userData; 62 63 /// A string of the entire sentence up to (and including) this word, please note that $(B currently only final words) have this field set. 64 string sentence; 65 66 /// See_Also: `CommandResolver.resolve` 67 CommandResolveResult!UserDataT byCommandSentence(RangeOfStrings)(RangeOfStrings range) 68 { 69 auto current = this; 70 for(; !range.empty; range.popFront()) 71 { 72 auto commandWord = range.front; 73 auto currentBeforeChange = current; 74 75 foreach(child; current.children) 76 { 77 if(child.word == commandWord) 78 { 79 current = child; 80 break; 81 } 82 } 83 84 // Above loop failed. 85 if(currentBeforeChange.word == current.word) 86 { 87 current = this; // Makes result.success become false. 88 break; 89 } 90 } 91 92 typeof(return) result; 93 result.value = current; 94 result.success = range.empty && current.word != this.word; 95 return result; 96 } 97 98 /++ 99 + Retrieves all child `CommandNodes` that are of type `CommandNodeType.finalWord`. 100 + 101 + Notes: 102 + While similar to `CommandResolver.finalWords`, this function has one major difference. 103 + 104 + It is less efficient, since `CommandResolver.finalWords` builds and caches its value whenever a sentence is defined, while 105 + this function (currently) has to recreate its value each time. 106 + 107 + Furthermore, as with `CommandResolver.finalWords`, the returned array of nodes are simply copies of the actual nodes used 108 + and returned by `CommandResolver.resolve`. So don't expect any changes to be reflected anywhere. 109 + 110 + Technically the same could be done here, but I'm lazy, so for now you get extra GC garbage. 111 + 112 + Returns: 113 + All child final words. 114 + ++/ 115 CommandNode!UserDataT[] finalWords() 116 { 117 if(this.type != CommandNodeType.partialWord && this.type != CommandNodeType.root) 118 return null; 119 120 typeof(return) nodes; 121 122 void addFinalNodes(CommandNode!UserDataT repetitionNode) 123 { 124 foreach(childNode; repetitionNode.children) 125 { 126 if(childNode.type == CommandNodeType.finalWord) 127 nodes ~= childNode; 128 else if(childNode.type == CommandNodeType.partialWord) 129 addFinalNodes(childNode); 130 else 131 assert(false, "Malformed tree."); 132 } 133 } 134 135 addFinalNodes(this); 136 return nodes; 137 } 138 } 139 140 /++ 141 + A helper class where you can define command "sentences", and then resolve (either partially or fully) commands 142 + from "sentences" provided by the user. 143 + 144 + Params: 145 + UserDataT = User-provided data for each command (`CommandNodes` of type `CommandNodeType.finalWord`). 146 + 147 + Description: 148 + In essence, this class is just an abstraction around a basic tree structure (`CommandNode`), to make it easy to 149 + both define and search the tree. 150 + 151 + First of all, JCLI supports commands having multiple "words" within them, such as "build all libs"; "remote get-url", etc. 152 + This entire collection of "words" is referred to as a "sentence". 153 + 154 + The tree for the resolver consists of words pointing to any following words (`CommandNodeType.partialWord`), ultimately ending each 155 + branch with the final command word (`CommandNodeType.finalWord`). 156 + 157 + For example, if we had the following commands "build libs"; "build apps", and "test libs", the tree would look like the following. 158 + 159 + Legend = `[word] - partial word` and `<word> - final word`. 160 + 161 +``` 162 + root 163 + / \ 164 + [test] [build] 165 + | | \ 166 + <libs> <libs> <apps> 167 +``` 168 + 169 + Because this class only handles resolving commands, and nothing more than that, the application can attach whatever data it wants (`UserDataT`) 170 + so it can later perform its own processing (description; arg info; execution delegates, etc.) 171 + 172 + I'd like to point out however, $(B only final words) are given user data as partial words aren't supposed to represent commands. 173 + 174 + Finally, given the above point, if you tried to define "build release" and "build" at the same time, you'd fail an assert as "build" cannot be 175 + a partial word and a final word at the same time. This does kind of suck in some cases, but there are workarounds e.g. defining "build", then passing "release"/"debug" 176 + as arguments. 177 + 178 + Usage: 179 + Build up your tree by using `CommandResolver.define`. 180 + 181 + Resolve commands via `CommandResolver.resolve` or `CommandResolver.resolveAndAdvance`. 182 + ++/ 183 @safe 184 final class CommandResolver(UserDataT) 185 { 186 /// The `CommandNode` instatiation for this resolver. 187 alias NodeT = CommandNode!UserDataT; 188 189 private 190 { 191 CommandNode!UserDataT _rootNode; 192 string[] _sentences; 193 NodeT[] _finalWords; 194 } 195 196 this() 197 { 198 this._rootNode.type = CommandNodeType.root; 199 } 200 201 /++ 202 + Defines a command sentence. 203 + 204 + Description: 205 + A "sentence" consists of multiple "words". A "word" is a string of characters, each seperated by any amount of spaces. 206 + 207 + For instance, `"build all libs"` contains the words `["build", "all", "libs"]`. 208 + 209 + The last word within a sentence is known as the final word (`CommandNodeType.finalWord`), which is what defines the 210 + actual command associated with this sentence. The final word is the only word that has the `userDataForFinalNode` associated with it. 211 + 212 + The rest of the words are known as partial words (`CommandNodeType.partialWord`) as they are only a partial part of a full sentence. 213 + (I hate all of this as well, don't worry). 214 + 215 + So for example, if you wanted to define the command "build all libs" with some custom user data containing, for example, a description and 216 + an execute function, you could do something such as. 217 + 218 + `myResolver.define("build all libs", MyUserData("Builds all Libraries", &buildAllLibsCommand))` 219 + 220 + You can then later use `CommandResolver.resolve` or `CommandResolver.resolveAndAdvance`, using a user-provided string, to try and resolve 221 + to the final command. 222 + 223 + Params: 224 + commandSentence = The sentence to define. 225 + userDataForFinalNode = The `UserDataT` to attach to the `CommandNode` for the sentence's final word. 226 + ++/ 227 void define(string commandSentence, UserDataT userDataForFinalNode) 228 { 229 import std.algorithm : splitter, filter, any, countUntil; 230 import std.format : format; // For errors. 231 import std.range : walkLength; 232 import std.uni : isWhite; 233 234 auto words = commandSentence.splitter!(a => a == ' ').filter!(w => w.length > 0); 235 assert(!words.any!(w => w.any!isWhite), "Words inside a command sentence cannot contain whitespace."); 236 237 const wordCount = words.walkLength; 238 scope currentNode = &this._rootNode; 239 size_t wordIndex = 0; 240 foreach(word; words) 241 { 242 const isLastWord = (wordIndex == wordCount - 1); 243 wordIndex++; 244 245 const existingNodeIndex = currentNode.children.countUntil!(c => c.word == word); 246 247 NodeT node; 248 node.word = word; 249 node.type = (isLastWord) ? CommandNodeType.finalWord : CommandNodeType.partialWord; 250 node.userData = (isLastWord) ? userDataForFinalNode : UserDataT.init; 251 node.sentence = (isLastWord) ? commandSentence : null; 252 253 if(isLastWord) 254 this._finalWords ~= node; 255 256 if(existingNodeIndex == -1) 257 { 258 currentNode.children ~= node; 259 currentNode = ¤tNode.children[$-1]; 260 continue; 261 } 262 263 currentNode = ¤tNode.children[existingNodeIndex]; 264 assert( 265 currentNode.type == CommandNodeType.partialWord, 266 "Cannot append word '%s' onto word '%s' as the latter word is not a partialWord, but instead a %s." 267 .format(word, currentNode.word, currentNode.type) 268 ); 269 } 270 271 this._sentences ~= commandSentence; 272 } 273 274 /++ 275 + Attempts to resolve a range of words/a sentence into a `CommandNode`. 276 + 277 + Notes: 278 + The overload taking a `string` will split the string by spaces, the same way `CommandResolver.define` works. 279 + 280 + Description: 281 + There are three potential outcomes of this function. 282 + 283 + 1. The words provided fully match a command sentence. The value of `returnValue.value.type` will be `CommandNodeType.finalWord`. 284 + 2. The words provided a partial match of a command sentence. The value of `returnValue.value.type` will be `CommandNodeType.partialWord`. 285 + 3. Neither of the above. The value of `returnValue.success` will be `false`. 286 + 287 + How you handle these outcomes, and which ones you handle, are entirely up to your application. 288 + 289 + Params: 290 + words = The words to resolve. 291 + 292 + Returns: 293 + A `CommandResolveResult`, specifying the result of the resolution. 294 + ++/ 295 CommandResolveResult!UserDataT resolve(RangeOfStrings)(RangeOfStrings words) 296 { 297 return this._rootNode.byCommandSentence(words); 298 } 299 300 /// ditto. 301 CommandResolveResult!UserDataT resolve(string sentence) pure 302 { 303 import std.algorithm : splitter, filter; 304 return this.resolve(sentence.splitter(' ').filter!(w => w.length > 0)); 305 } 306 307 /++ 308 + Peforms the same task as `CommandResolver.resolve`, except that it will also advance the given `parser` to the 309 + next unparsed argument. 310 + 311 + Description: 312 + For example, you've defined `"set verbose"` as a command, and you pass in an `ArgPullParser(["set", "verbose", "true"])`. 313 + 314 + This function will match with the `"set verbose"` sentence, and will advance the parser so that it will now be `ArgPullParser(["true"])`, ready 315 + for your application code to perform additional processing (e.g. arguments). 316 + 317 + Params: 318 + parser = The `ArgPullParser` to use and advance. 319 + 320 + Returns: 321 + Same thing as `CommandResolver.resolve`. 322 + ++/ 323 CommandResolveResult!UserDataT resolveAndAdvance(ref ArgPullParser parser) 324 { 325 import std.algorithm : map; 326 import std.range : take; 327 328 typeof(return) lastSuccessfulResult; 329 330 auto parserCopy = parser; 331 size_t amountToTake = 0; 332 while(true) 333 { 334 if(parser.empty || parser.front.type != ArgTokenType.Text) 335 return lastSuccessfulResult; 336 337 auto result = this.resolve(parserCopy.take(++amountToTake).map!(t => t.value)); 338 if(!result.success) 339 return lastSuccessfulResult; 340 341 lastSuccessfulResult = result; 342 parser.popFront(); 343 } 344 } 345 346 /// Returns: The root `CommandNode`, for whatever you need it for. 347 @property 348 NodeT root() 349 { 350 return this._rootNode; 351 } 352 353 /++ 354 + Notes: 355 + While the returned array is mutable, the nodes stored in this array are *not* the same nodes stored in the actual search tree. 356 + This means that any changes made to this array will not be reflected by the results of `resolve` and `resolveAndAdvance`. 357 + 358 + The reason this isn't marked `const` is because that'd mean that your user data would also be marked `const`, which, in D, 359 + can be *very* annoying and limiting. Doubly so since your intentions can't be determined due to the nature of user data. So behave with this. 360 + 361 + Returns: 362 + All of the final words currently defined. 363 + ++/ 364 @property 365 NodeT[] finalWords() 366 { 367 return this._finalWords; 368 } 369 } 370 /// 371 @("Main test for CommandResolver") 372 @safe 373 unittest 374 { 375 import std.algorithm : map, equal; 376 377 // Define UserData as a struct containing an execution method. Define a UserData which toggles a value. 378 static struct UserData 379 { 380 void delegate() @safe execute; 381 } 382 383 bool executeValue; 384 void toggleValue() @safe 385 { 386 executeValue = !executeValue; 387 } 388 389 auto userData = UserData(&toggleValue); 390 391 // Create the resolver and define three command paths: "toggle", "please toggle", and "please tog". 392 // Tree should look like: 393 // [root] 394 // / \ 395 // toggle please 396 // / \ 397 // toggle tog 398 auto resolver = new CommandResolver!UserData; 399 resolver.define("toggle", userData); 400 resolver.define("please toggle", userData); 401 resolver.define("please tog", userData); 402 403 // Resolve 'toggle' and call its execute function. 404 auto result = resolver.resolve("toggle"); 405 assert(result.success); 406 assert(result.value.word == "toggle"); 407 assert(result.value.sentence == "toggle"); 408 assert(result.value.type == CommandNodeType.finalWord); 409 assert(result.value.userData.execute !is null); 410 result.value.userData.execute(); 411 assert(executeValue == true); 412 413 // Resolve 'please' and confirm that it's only a partial match. 414 result = resolver.resolve("please"); 415 assert(result.success); 416 assert(result.value.word == "please"); 417 assert(result.value.sentence is null); 418 assert(result.value.type == CommandNodeType.partialWord); 419 assert(result.value.children.length == 2); 420 assert(result.value.userData == UserData.init); 421 422 // Resolve 'please toggle' and call its execute function. 423 result = resolver.resolve("please toggle"); 424 assert(result.success); 425 assert(result.value.word == "toggle"); 426 assert(result.value.sentence == "please toggle"); 427 assert(result.value.type == CommandNodeType.finalWord); 428 result.value.userData.execute(); 429 assert(executeValue == false); 430 431 // Resolve 'please tog' and call its execute function. (to test nodes with multiple children). 432 result = resolver.resolve("please tog"); 433 assert(result.success); 434 assert(result.value.word == "tog"); 435 assert(result.value.sentence == "please tog"); 436 assert(result.value.type == CommandNodeType.finalWord); 437 result.value.userData.execute(); 438 assert(executeValue == true); 439 440 // Resolve a few non-existing command sentences, and ensure that they were unsuccessful. 441 assert(!resolver.resolve(null).success); 442 assert(!resolver.resolve("toggle please").success); 443 assert(!resolver.resolve("He she we, wombo.").success); 444 445 // Test that final words are properly tracked. 446 assert(resolver.finalWords.map!(w => w.word).equal(["toggle", "toggle", "tog"])); 447 assert(resolver.root.finalWords.equal(resolver.finalWords)); 448 449 auto node = resolver.resolve("please").value; 450 assert(node.finalWords().map!(w => w.word).equal(["toggle", "tog"])); 451 } 452 453 @("Test CommandResolver.resolveAndAdvance") 454 @safe 455 unittest 456 { 457 // Resolution should stop once a non-Text argument is found "--c" in this case. 458 // Also the parser should be advanced, where .front is the argument that wasn't part of the resolved command. 459 auto resolver = new CommandResolver!int(); 460 auto parser = ArgPullParser(["a", "b", "--c", "-d", "e"]); 461 462 resolver.define("a b e", 0); 463 464 auto parserCopy = parser; 465 auto result = resolver.resolveAndAdvance(parserCopy); 466 assert(result.success); 467 assert(result.value.type == CommandNodeType.partialWord); 468 assert(result.value.word == "b"); 469 assert(parserCopy.front.value == "c", parserCopy.front.value); 470 } 471 472 @("Test CommandResolver.resolve possible edge case") 473 @safe 474 unittest 475 { 476 auto resolver = new CommandResolver!int(); 477 auto parser = ArgPullParser(["set", "value", "true"]); 478 479 resolver.define("set value", 0); 480 481 auto result = resolver.resolveAndAdvance(parser); 482 assert(result.success); 483 assert(result.value.type == CommandNodeType.finalWord); 484 assert(result.value.word == "value"); 485 assert(parser.front.value == "true"); 486 487 result = resolver.resolve("set verbose true"); 488 assert(!result.success); 489 }