1 /++
2 $(H2 The Help Type for Cmdline)
3 
4 This modlue mainly has `Help` Type.
5 We can change it to control the behaviors 
6 of the cmd-line program's help command and help option
7 
8 Authors: 笑愚(xiaoyu)
9 +/
10 module cmdline.help;
11 
12 import std.algorithm;
13 import std.array;
14 import std.string : stripRight;
15 import std.regex;
16 import std.format;
17 import std.typecons;
18 import std.uni;
19 import std.range;
20 
21 import cmdline.pattern;
22 import cmdline.option;
23 import cmdline.argument;
24 import cmdline.command;
25 
26 class Help {
27     /// the help print's max coloum width
28     int helpWidth = 80;
29     /// whether sort the sub commands on help print
30     bool sortSubCommands = false;
31     /// whether sort the options on help print
32     bool sortOptions = false;
33     /// whether show global options, which is not recommended to turn on it
34     bool showGlobalOptions = false;
35 
36     /// get the list of not hidden sub commands
37     inout(Command)[] visibleCommands(inout(Command) cmd) const {
38         Command cmd_tmp = cast(Command) cmd;
39         Command[] visible_cmds = cmd_tmp._commands.filter!(c => !c._hidden).array;
40         auto help_cmd = cmd_tmp._getHelpCommand();
41         auto version_cmd = cmd_tmp._versionCommand;
42         if (help_cmd && !help_cmd._hidden) {
43             visible_cmds ~= help_cmd;
44         }
45         if (version_cmd && !version_cmd._hidden) {
46             visible_cmds ~= version_cmd;
47         }
48         if (this.sortSubCommands) {
49             return cast(inout(Command[])) visible_cmds.sort!((a, b) => a.name < b.name).array;
50         }
51         return cast(inout(Command[])) visible_cmds;
52     }
53 
54     /// get the list of not hidden options
55     inout(Option)[] visibleOptions(inout(Command) command) const {
56         auto cmd = cast(Command) command;
57         Option[] visible_opts = (cmd._options).filter!(opt => !opt.hidden).array;
58         visible_opts ~= cmd._abandons.filter!(opt => !opt.hidden).array;
59         auto help_opt = cmd._getHelpOption();
60         auto version_opt = cmd._versionOption;
61         auto config_opt = cmd._configOption;
62         if (help_opt && !help_opt.hidden)
63             visible_opts ~= help_opt;
64         if (version_opt && !version_opt.hidden)
65             visible_opts ~= version_opt;
66         if (config_opt && !config_opt.hidden)
67             visible_opts ~= config_opt;
68         if (this.sortOptions) {
69             return cast(inout(Option[])) visible_opts.sort!((a, b) => a.name < b.name).array;
70         }
71         return cast(inout(Option[])) visible_opts;
72     }
73 
74     /// get the list of not hidden negate options
75     inout(NegateOption)[] visibleNegateOptions(inout(Command) command) const {
76         auto cmd = cast(Command) command;
77         auto visible_opts = (cmd._negates).filter!(opt => !opt.hidden).array;
78         if (this.sortOptions) {
79             return cast(inout(NegateOption[])) visible_opts.sort!((a, b) => a.name < b.name).array;
80         }
81         return cast(inout(NegateOption[])) visible_opts;
82     }
83 
84     /// get the list of not hidden global options
85     inout(Option)[] visibleGlobalOptions(inout(Command) command) const {
86         if (!this.showGlobalOptions)
87             return [];
88         auto cmds_global = command._getCommandAndAncestors();
89         auto ancestor_cmd = (cast(Command[]) cmds_global)[1 .. $];
90         Option[] visible_opts = [];
91         foreach (cmd; ancestor_cmd) {
92             visible_opts ~= (cmd._options).filter!(opt => !opt.hidden).array;
93             visible_opts ~= cmd._abandons.filter!(opt => !opt.hidden).array;
94         }
95         if (this.sortOptions) {
96             return cast(inout(Option[])) visible_opts.sort!((a, b) => a.name < b.name).array;
97         }
98         return cast(inout(Option[])) visible_opts;
99     }
100 
101     /// get the list of not hidden global negate options 
102     inout(NegateOption)[] visibleGlobalNegateOptions(inout(Command) command) const {
103         if (!this.showGlobalOptions)
104             return [];
105         auto cmds_global = command._getCommandAndAncestors();
106         auto ancestor_cmd = (cast(Command[]) cmds_global)[1 .. $];
107         NegateOption[] visible_opts = [];
108         foreach (cmd; ancestor_cmd)
109             visible_opts ~= (cmd._negates).filter!(opt => !opt.hidden).array;
110         if (this.sortOptions) {
111             return cast(inout(NegateOption[])) visible_opts.sort!((a, b) => a.name < b.name).array;
112         }
113         return cast(inout(NegateOption[])) visible_opts;
114     }
115 
116     /// get the list of arguments
117     inout(Argument)[] visibleArguments(inout(Command) command) const {
118         if (command._argsDescription) {
119             Command cmd = cast(Command) command;
120             cmd._arguments.each!((arg) {
121                 if (arg.description == "") {
122                     auto tmp = arg.name in cmd._argsDescription;
123                     if (tmp)
124                         arg.description = *tmp;
125                 }
126             });
127         }
128         return command._arguments;
129     }
130 
131 package:
132     string subCommandTerm(in Command cmd) const {
133         auto args_str = "";
134         if (cmd._arguments.length)
135             args_str = cmd._arguments.map!(arg => arg.readableArgName).join(" ");
136         return (
137             cmd._name ~
138                 (cmd._aliasNames.empty ? "" : "|" ~ cmd._aliasNames[0]) ~
139                 (cmd._options.empty ? "" : " [options]") ~
140                 (args_str == "" ? args_str : " " ~ args_str) ~
141                 (cmd._execHandler ? " >> " ~ cmd._usage : "")
142         );
143     }
144 
145     string optionTerm(in Option opt) const {
146         return opt.flags;
147     }
148 
149     string optionTerm(in NegateOption opt) const {
150         return opt.flags;
151     }
152 
153     string argumentTerm(in Argument arg) const {
154         return arg.name;
155     }
156 
157     int longestSubcommandTermLength(in Command cmd) const {
158         return reduce!((int mn, command) {
159             return max(mn, cast(int) subCommandTerm(command).length);
160         })(0, visibleCommands(cmd));
161     }
162 
163     int longestOptionTermLength(in Command cmd) const {
164         int opt_len = reduce!((int mn, opt) {
165             return max(mn, cast(int) optionTerm(opt).length);
166         })(0, visibleOptions(cmd));
167 
168         int nopt_len = reduce!((int mn, opt) {
169             return max(mn, cast(int) optionTerm(opt).length);
170         })(0, visibleNegateOptions(cmd));
171 
172         return max(opt_len, nopt_len);
173     }
174 
175     int longestGlobalOptionTermLength(in Command cmd) const {
176         int opt_len = reduce!((int mn, opt) {
177             return max(mn, cast(int) optionTerm(opt).length);
178         })(0, visibleGlobalOptions(cmd));
179 
180         int nopt_len = reduce!((int mn, opt) {
181             return max(mn, cast(int) optionTerm(opt).length);
182         })(0, visibleGlobalNegateOptions(cmd));
183 
184         return max(opt_len, nopt_len);
185     }
186 
187     int longestArgumentTermLength(in Command cmd) const {
188         return reduce!((int mn, arg) {
189             return max(mn, cast(int) argumentTerm(arg).length);
190         })(0, visibleArguments(cast(Command) cmd));
191     }
192 
193     /// get the usage of command
194     public string commandUsage(in Command cmd) const {
195         string cmd_name = cmd._name;
196         if (!cmd._aliasNames.empty)
197             cmd_name = cmd_name ~ "|" ~ cmd._aliasNames[0];
198         string ancestor_name = "";
199         auto tmp = cmd._getCommandAndAncestors();
200         Command[] ancestor = (cast(Command[]) tmp)[1 .. $];
201         ancestor.each!((c) { ancestor_name = c.name ~ " " ~ ancestor_name; });
202         return ancestor_name ~ cmd_name ~ " " ~ cmd.usage;
203     }
204 
205     string commandDesc(in Command cmd) const {
206         return cmd.description;
207     }
208 
209     string optionDesc(in Option opt) const {
210         string[] info = [opt.description];
211         auto type_str = opt.typeStr();
212         auto choices_str = opt.choicesStr();
213         auto default_str = opt.defaultValStr();
214         auto preset_str = opt.presetStr();
215         auto env_str = opt.envValStr();
216         auto imply_str = opt.implyOptStr();
217         auto conflict_str = opt.conflictOptStr();
218         auto rangeof_str = opt.rangeOfStr();
219         info ~= type_str;
220         info ~= default_str;
221         info ~= env_str;
222         info ~= preset_str;
223         info ~= rangeof_str;
224         info ~= choices_str;
225         info ~= imply_str;
226         info ~= conflict_str;
227         return info.filter!(str => !str.empty).join('\n');
228     }
229 
230     string optionDesc(in NegateOption opt) const {
231         return opt.description;
232     }
233 
234     string argumentDesc(in Argument arg) const {
235         string[] info = [arg.description];
236         auto type_str = arg.typeStr;
237         auto default_str = arg.defaultValStr;
238         auto rangeof_str = arg.rangeOfStr;
239         auto choices_str = arg.choicesStr;
240         info ~= type_str;
241         info ~= default_str;
242         info ~= rangeof_str;
243         info ~= choices_str;
244         return info.filter!(str => !str.empty).join('\n');
245     }
246 
247     string subCommandDesc(in Command cmd) const {
248         return cmd.description;
249     }
250 
251     int paddWidth(in Command cmd) const {
252         auto lg_opl = longestOptionTermLength(cmd);
253         auto lg_gopl = longestGlobalOptionTermLength(cmd);
254         auto lg_scl = longestSubcommandTermLength(cmd);
255         auto lg_arl = longestArgumentTermLength(cmd);
256         return max(
257             lg_opl,
258             lg_gopl,
259             lg_scl,
260             lg_arl
261         );
262     }
263 
264     string formatHelp(in Command cmd) const {
265         auto term_width = paddWidth(cmd);
266         auto item_indent_width = 2;
267         auto item_sp_width = 2;
268 
269         auto format_item = (string term, string desc) {
270             if (desc != "") {
271                 string padded_term_str = term;
272                 if (term_width + item_sp_width > term.length) {
273                     auto pad_num = term_width + item_sp_width - term.length;
274                     string space = ' '.repeat(pad_num).array;
275                     padded_term_str = term ~ space;
276                 }
277                 auto full_text = padded_term_str ~ desc;
278                 return this.wrap(
279                     full_text,
280                     this.helpWidth - item_indent_width,
281                     term_width + item_sp_width);
282             }
283             return term;
284         };
285 
286         auto format_list = (string[] textArr) {
287             return textArr.join("\n").replaceAll(regex(`^`, "m"), "  ");
288         };
289 
290         string[] output = ["Usage: " ~ this.commandUsage(cmd), ""];
291 
292         string cmd_desc = this.commandDesc(cmd);
293         if (cmd_desc.length > 0) {
294             output ~= [this.wrap(cmd_desc, this.helpWidth, 0), ""];
295         }
296 
297         if (!cmd._defaultCommandName.empty) {
298             string str = format!"default sub command is `%s`"(cmd._defaultCommandName);
299             output ~= [this.wrap(str, this.helpWidth, 0), ""];
300         }
301 
302         string[] arg_list = visibleArguments(cmd).map!(
303             arg => format_item(argumentTerm(arg), argumentDesc(arg))).array;
304         if (arg_list.length) {
305             output ~= ["Arguments:", format_list(arg_list), ""];
306         }
307 
308         string[] opt_list = visibleOptions(cmd).map!(
309             opt => format_item(optionTerm(opt), optionDesc(opt))).array;
310         string[] negate_list = visibleNegateOptions(cmd).map!(
311             opt => format_item(optionTerm(opt), optionDesc(opt))).array;
312         opt_list ~= negate_list;
313         if (opt_list.length) {
314             output ~= ["Options:", format_list(opt_list), ""];
315         }
316 
317         if (this.showGlobalOptions) {
318             string[] global_list = visibleGlobalOptions(cmd).map!(
319                 opt => format_item(optionTerm(opt), optionDesc(opt))).array;
320             string[] nglobal_list = visibleGlobalNegateOptions(cmd).map!(
321                 opt => format_item(optionTerm(opt), optionDesc(opt))).array;
322             global_list ~= nglobal_list;
323             if (global_list.length) {
324                 output ~= ["Global Options:", format_list(global_list), ""];
325             }
326         }
327 
328         string[] cmd_list = visibleCommands(cmd).map!(
329             c => format_item(subCommandTerm(c), subCommandDesc(c))).array;
330         if (cmd_list.length) {
331             output ~= ["Commands:", format_list(cmd_list), ""];
332         }
333 
334         return output.join("\n");
335     }
336 
337     string wrap(string str, int width, int indent) const {
338         if (!matchFirst(str, PTN_MANUALINDENT).empty)
339             return str;
340         auto text = str[indent .. $].replaceAll(regex(`\s+(?=\n|$)`), "");
341         auto leading_str = str[0 .. indent];
342         int col_width = width - indent;
343         string indent_str = repeat(' ', indent).array;
344         string ex_indent_str = "  ";
345         string[] col_texts = text.split('\n').filter!(s => s.length).array;
346         auto get_front = () => col_texts.length ? col_texts.front : "";
347         string[] tmp;
348         string cur_txt = get_front();
349         if (cur_txt.length)
350             col_texts.popFront;
351         int cur_max_width = col_width;
352         bool is_ex_ing = cur_txt.length > cur_max_width;
353         if (is_ex_ing) {
354             col_texts.insertInPlace(0, cur_txt[cur_max_width .. $]);
355             cur_txt = cur_txt[0 .. cur_max_width];
356         }
357         tmp ~= cur_txt;
358         while ((cur_txt = get_front()).length) {
359             col_texts.popFront;
360             bool flag = is_ex_ing;
361             cur_max_width = is_ex_ing ? col_width - 2 : col_width;
362             is_ex_ing = cur_txt.length > cur_max_width;
363             if (is_ex_ing) {
364                 col_texts.insertInPlace(0, cur_txt[cur_max_width .. $]);
365                 cur_txt = cur_txt[0 .. cur_max_width];
366             }
367             tmp ~= flag ? ex_indent_str ~ indent_str ~ cur_txt : indent_str ~ cur_txt;
368         }
369         return leading_str ~ tmp.join("\r\n");
370     }
371 }
372 
373 private:
374 
375 enum int maxDistance = 3;
376 
377 int _editDistance(string a, string b) {
378     assert(max(a.length, b.length) < 20);
379     int xy = cast(int) a.length - cast(int) b.length;
380     if (xy < -maxDistance || xy > maxDistance) {
381         return cast(int) max(a.length, b.length);
382     }
383     int[20][20] dp = (int[20][20]).init;
384     for (int i = 0; i <= a.length; i++)
385         dp[i][0] = i;
386     for (int i = 0; i <= b.length; i++)
387         dp[0][i] = i;
388     int cost = 0;
389     for (int i = 1; i <= a.length; i++) {
390         for (int j = 1; j <= b.length; j++) {
391             if (a[i - 1] == b[j - 1])
392                 cost = 0;
393             else
394                 cost = 1;
395             dp[i][j] = min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
396         }
397     }
398     return dp[a.length][b.length];
399 }
400 
401 package:
402 
403 string suggestSimilar(string word, in string[] _candidates) {
404     if (!_candidates || _candidates.length == 0)
405         return "";
406     auto searching_opts = word[0 .. 2] == "--";
407     string[] candidates;
408     if (searching_opts) {
409         word = word[2 .. $].idup;
410         candidates = _candidates.map!(candidate => candidate[2 .. $].idup).array;
411     }
412     else {
413         candidates = _candidates.dup;
414     }
415     string[] similar = [];
416     int best_dist = maxDistance;
417     double min_similarity = 0.4;
418     candidates.each!((string candidate) {
419         if (candidates.length <= 1)
420             return No.each;
421         double dist = _editDistance(word, candidate);
422         double len = cast(double) max(word.length, candidate.length);
423         double similarity = (len - dist) / len;
424         if (similarity > min_similarity) {
425             if (dist < best_dist) {
426                 best_dist = cast(int) dist;
427                 similar ~= candidate;
428             }
429             else if (dist == best_dist) {
430                 similar ~= candidate;
431             }
432         }
433         return Yes.each;
434     });
435     similar.sort!((a, b) => a.toLower < b.toLower);
436     if (searching_opts) {
437         similar.each!((ref str) { str = "--" ~ str; });
438     }
439     if (similar.length > 1)
440         return format("\n(Did you mean one of %s?)", similar.join(", "));
441     if (similar.length == 1)
442         return format("\n(Did you mean %s?)", similar[0]);
443     return "";
444 }
445 
446 unittest {
447     import std.stdio;
448 
449     writeln("===:", suggestSimilar("--son", [
450         "--sone", "--sans", "--sou", "--don"
451     ]));
452     assert(_editDistance("kitten", "sitting") == 3);
453     assert(_editDistance("rosettacode", "raisethysword") == 8);
454     assert(_editDistance("", "") == 0);
455     assert(_editDistance("kitten", "") == 6);
456     assert(_editDistance("", "sitting") == 7);
457     assert(_editDistance("kitten", "kitten") == 0);
458     assert(_editDistance("meow", "woof") == 3);
459     assert(_editDistance("woof", "meow") == 3);
460 }