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 // Pretty sure this is like O(n^n), but if you ever have an "n" higher than 5, you have different issues. 331 auto parserCopy = parser; 332 size_t amountToTake = 0; 333 while(true) 334 { 335 if(parser.empty || parser.front.type != ArgTokenType.Text) 336 return lastSuccessfulResult; 337 338 auto result = this.resolve(parserCopy.take(++amountToTake).map!(t => t.value)); 339 if(!result.success) 340 return lastSuccessfulResult; 341 342 lastSuccessfulResult = result; 343 parser.popFront(); 344 } 345 } 346 347 /// Returns: The root `CommandNode`, for whatever you need it for. 348 @property 349 NodeT root() 350 { 351 return this._rootNode; 352 } 353 354 /++ 355 + Notes: 356 + While the returned array is mutable, the nodes stored in this array are *not* the same nodes stored in the actual search tree. 357 + This means that any changes made to this array will not be reflected by the results of `resolve` and `resolveAndAdvance`. 358 + 359 + The reason this isn't marked `const` is because that'd mean that your user data would also be marked `const`, which, in D, 360 + 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. 361 + 362 + Returns: 363 + All of the final words currently defined. 364 + ++/ 365 @property 366 NodeT[] finalWords() 367 { 368 return this._finalWords; 369 } 370 } 371 /// 372 @("Main test for CommandResolver") 373 @safe 374 unittest 375 { 376 import std.algorithm : map, equal; 377 378 // Define UserData as a struct containing an execution method. Define a UserData which toggles a value. 379 static struct UserData 380 { 381 void delegate() @safe execute; 382 } 383 384 bool executeValue; 385 void toggleValue() @safe 386 { 387 executeValue = !executeValue; 388 } 389 390 auto userData = UserData(&toggleValue); 391 392 // Create the resolver and define three command paths: "toggle", "please toggle", and "please tog". 393 // Tree should look like: 394 // [root] 395 // / \ 396 // toggle please 397 // / \ 398 // toggle tog 399 auto resolver = new CommandResolver!UserData; 400 resolver.define("toggle", userData); 401 resolver.define("please toggle", userData); 402 resolver.define("please tog", userData); 403 404 // Resolve 'toggle' and call its execute function. 405 auto result = resolver.resolve("toggle"); 406 assert(result.success); 407 assert(result.value.word == "toggle"); 408 assert(result.value.sentence == "toggle"); 409 assert(result.value.type == CommandNodeType.finalWord); 410 assert(result.value.userData.execute !is null); 411 result.value.userData.execute(); 412 assert(executeValue == true); 413 414 // Resolve 'please' and confirm that it's only a partial match. 415 result = resolver.resolve("please"); 416 assert(result.success); 417 assert(result.value.word == "please"); 418 assert(result.value.sentence is null); 419 assert(result.value.type == CommandNodeType.partialWord); 420 assert(result.value.children.length == 2); 421 assert(result.value.userData == UserData.init); 422 423 // Resolve 'please toggle' and call its execute function. 424 result = resolver.resolve("please toggle"); 425 assert(result.success); 426 assert(result.value.word == "toggle"); 427 assert(result.value.sentence == "please toggle"); 428 assert(result.value.type == CommandNodeType.finalWord); 429 result.value.userData.execute(); 430 assert(executeValue == false); 431 432 // Resolve 'please tog' and call its execute function. (to test nodes with multiple children). 433 result = resolver.resolve("please tog"); 434 assert(result.success); 435 assert(result.value.word == "tog"); 436 assert(result.value.sentence == "please tog"); 437 assert(result.value.type == CommandNodeType.finalWord); 438 result.value.userData.execute(); 439 assert(executeValue == true); 440 441 // Resolve a few non-existing command sentences, and ensure that they were unsuccessful. 442 assert(!resolver.resolve(null).success); 443 assert(!resolver.resolve("toggle please").success); 444 assert(!resolver.resolve("He she we, wombo.").success); 445 446 // Test that final words are properly tracked. 447 assert(resolver.finalWords.map!(w => w.word).equal(["toggle", "toggle", "tog"])); 448 assert(resolver.root.finalWords.equal(resolver.finalWords)); 449 450 auto node = resolver.resolve("please").value; 451 assert(node.finalWords().map!(w => w.word).equal(["toggle", "tog"])); 452 } 453 454 @("Test CommandResolver.resolveAndAdvance") 455 @safe 456 unittest 457 { 458 // Resolution should stop once a non-Text argument is found "--c" in this case. 459 // Also the parser should be advanced, where .front is the argument that wasn't part of the resolved command. 460 auto resolver = new CommandResolver!int(); 461 auto parser = ArgPullParser(["a", "b", "--c", "-d", "e"]); 462 463 resolver.define("a b e", 0); 464 465 auto parserCopy = parser; 466 auto result = resolver.resolveAndAdvance(parserCopy); 467 assert(result.success); 468 assert(result.value.type == CommandNodeType.partialWord); 469 assert(result.value.word == "b"); 470 assert(parserCopy.front.value == "c", parserCopy.front.value); 471 } 472 473 @("Test CommandResolver.resolve possible edge case") 474 @safe 475 unittest 476 { 477 auto resolver = new CommandResolver!int(); 478 auto parser = ArgPullParser(["set", "value", "true"]); 479 480 resolver.define("set value", 0); 481 482 auto result = resolver.resolveAndAdvance(parser); 483 assert(result.success); 484 assert(result.value.type == CommandNodeType.finalWord); 485 assert(result.value.word == "value"); 486 assert(parser.front.value == "true"); 487 488 result = resolver.resolve("set verbose true"); 489 assert(!result.success); 490 }