Compare commits

...

2 commits

Author SHA1 Message Date
atagen
163e330716 adopt better fp patterns 2026-03-21 21:54:24 +11:00
atagen
18c97eacd0 cli11, bsd manual 2026-03-21 21:22:41 +11:00
7 changed files with 563 additions and 118 deletions

View file

@ -2,6 +2,8 @@ open Inshellah.Parser
open Inshellah.Manpage
open Inshellah.Nushell
module SSet = Set.Make(String)
let usage () =
Printf.eprintf
{|inshellah - generate nushell completions
@ -12,7 +14,10 @@ Usage:
One .nu file per command.
inshellah manpage FILE Parse a manpage and emit nushell extern
inshellah manpage-dir DIR Batch-process manpages under DIR
inshellah help CMD [ARGS...] Run CMD ARGS --help, parse and emit extern
inshellah help [--iterative] CMD [ARGS...]
Run CMD ARGS --help, parse and emit extern.
Recursively resolves subcommands unless
--iterative is given.
inshellah parse-help CMD Read --help text from stdin, emit extern
inshellah demo Run built-in demo
@ -94,21 +99,17 @@ let is_executable path =
st.st_kind = Unix.S_REG && st.st_perm land 0o111 <> 0
with Unix.Unix_error _ -> false
let is_shell_script path =
let is_script path =
try
let real = Unix.realpath path in
let ic = open_in_bin real in
let line = (try let b = Bytes.create 256 in
let n = input ic b 0 256 in
let s = Bytes.sub_string b 0 n in
(match String.index_opt s '\n' with Some i -> String.sub s 0 i | None -> s)
with End_of_file -> "") in
let has_shebang =
try let b = Bytes.create 2 in
really_input ic b 0 2;
Bytes.get b 0 = '#' && Bytes.get b 1 = '!'
with End_of_file -> false in
close_in ic;
String.length line >= 2 && line.[0] = '#' && line.[1] = '!'
&& let shebang = String.lowercase_ascii line in
List.exists (fun s -> contains_str shebang s)
["bash"; "/sh "; "/sh\n"; "zsh"; "fish"; "nushell"; "/nu "; "/nu\n";
"dash"; "ksh"; "csh"]
has_shebang
with _ -> false
let elf_scan path needles =
@ -159,11 +160,12 @@ let elf_scan path needles =
found
let skip_name name =
String.length name = 0 || name.[0] = '.'
String.length name = 0 || name = "-" || name.[0] = '.'
|| String.starts_with ~prefix:"lib" name
|| String.ends_with ~suffix:"-daemon" name
|| String.ends_with ~suffix:"-wrapped" name
|| String.ends_with ~suffix:".so" name
|| not (String.exists (fun c -> (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) name)
type bin_class = Skip | Try_help | Try_native_and_help
@ -172,7 +174,7 @@ let classify_binary bindir name =
else
let path = Filename.concat bindir name in
if not (is_executable path) then Skip
else if is_shell_script path then Try_help
else if is_script path then Try_help
else
let scan = elf_scan path ["-h"; "completion"] in
if not (Hashtbl.mem scan "-h") then Skip
@ -227,17 +229,50 @@ let cmd_manpage_dir dir =
) (Sys.readdir subdir)
) command_sections
let help_resolve cmd rest name =
let resolve_one go rest name depth =
match run_cmd (cmd :: rest @ ["--help"]) 10_000 with
| None -> None
| Some text ->
(match parse_help text with
| Error _ -> None
| Ok r when depth >= 5 -> Some (generate_extern name r)
| Ok r ->
let main = generate_extern name { r with subcommands = [] } in
let subs = List.map (fun (sc : subcommand) ->
let sub_name = name ^ " " ^ sc.name in
match go (rest @ [sc.name]) sub_name (depth + 1) with
| Some s -> "\n" ^ s
| None ->
Printf.sprintf "\nexport extern \"%s\" [ # %s\n]\n"
(escape_nu sub_name) (escape_nu sc.desc)
) r.subcommands in
Some (String.concat "" (main :: subs)))
in
let fix = ref (fun _ _ _ -> None) in
fix := (fun rest name depth -> resolve_one !fix rest name depth);
!fix rest name 0
let cmd_help args =
match args with
let iterative, cmd_args = match args with
| "--iterative" :: rest -> (true, rest)
| _ -> (false, args)
in
match cmd_args with
| [] -> Printf.eprintf "error: help requires a command name\n"; exit 1
| cmd :: rest ->
let name = Filename.basename cmd in
(match run_cmd (cmd :: rest @ ["--help"]) 10_000 with
| None -> Printf.eprintf "no output from %s --help\n" name; exit 1
| Some text ->
(match parse_help text with
| Ok r -> print_string (generate_extern name r)
| Error msg -> Printf.eprintf "parse error for %s: %s\n" name msg; exit 1))
let name = String.concat " " (Filename.basename cmd :: rest) in
if iterative then
(match run_cmd (cmd :: rest @ ["--help"]) 10_000 with
| None -> Printf.eprintf "no output from %s --help\n" name; exit 1
| Some text ->
(match parse_help text with
| Ok r -> print_string (generate_extern name r)
| Error msg -> Printf.eprintf "parse error for %s: %s\n" name msg; exit 1))
else
(match help_resolve cmd rest name with
| None -> Printf.eprintf "no output from %s --help\n" name; exit 1
| Some output -> print_string output)
let cmd_parse_help cmd =
let buf = Buffer.create 4096 in
@ -261,23 +296,22 @@ let process_manpage file =
with _ -> None
let manpaged_commands mandir =
let cmds = Hashtbl.create 128 in
List.iter (fun section ->
List.fold_left (fun acc section ->
let subdir = Filename.concat mandir (Printf.sprintf "man%d" section) in
if Sys.file_exists subdir && Sys.is_directory subdir then
Array.iter (fun f -> Hashtbl.replace cmds (cmd_name_of_manpage f) true)
(Sys.readdir subdir)
) command_sections;
cmds
Array.fold_left (fun acc f -> SSet.add (cmd_name_of_manpage f) acc)
acc (Sys.readdir subdir)
else acc
) SSet.empty command_sections
let cmd_generate bindir mandir outdir =
let done_cmds = Hashtbl.create 256 in
let done_cmds = ref SSet.empty in
let bins = Sys.readdir bindir in
Array.sort String.compare bins;
let manpaged = manpaged_commands mandir in
let max_jobs = num_cores () in
let classified = Array.map (fun name ->
if Hashtbl.mem manpaged name then (name, Skip)
if SSet.mem name manpaged then (name, Skip)
else (name, classify_binary bindir name)
) bins in
let pending = ref [] in
@ -328,7 +362,7 @@ let cmd_generate bindir mandir outdir =
with _ -> exit 1)
end else begin
pending := pid :: !pending;
Hashtbl.replace done_cmds name true
done_cmds := SSet.add name !done_cmds
end
) classified;
while !pending <> [] do
@ -345,8 +379,9 @@ let cmd_generate bindir mandir outdir =
| None -> ()
| Some (cmd, result) ->
let base = List.hd (String.split_on_char ' ' cmd) in
if Hashtbl.mem done_cmds base then ()
if SSet.mem cmd !done_cmds then ()
else begin
done_cmds := SSet.add cmd !done_cmds;
let outpath = Filename.concat outdir (filename_of_cmd base ^ ".nu") in
if Sys.file_exists outpath then begin
let existing =

View file

@ -15,9 +15,12 @@ Generate completions for a single command:
# From a manpage
inshellah manpage /usr/share/man/man1/git.1.gz > git.nu
# From --help output
# From --help output (recursively resolves subcommands)
inshellah help rg > rg.nu
# Without recursive resolution (for on-demand / runtime caching)
inshellah help --iterative rg > rg.nu
# Pipe --help text directly
curl --help | inshellah parse-help curl > curl.nu
```

View file

@ -31,7 +31,7 @@ def inshellah-complete [spans: list<string>] {
}
try {
let result = (run-external "inshellah" "help" ...($candidate) | complete)
let result = (run-external "inshellah" "help" "--iterative" ...($candidate) | complete)
if $result.exit_code == 0 and ($result.stdout | str length) > 10 {
$result.stdout | save -f $cache_file
$cmd_spans = $candidate
@ -64,17 +64,24 @@ When you type `docker compose up --<TAB>`:
1. Nushell calls the completer with `spans = ["docker", "compose", "up", "--"]`
2. The completer tries progressively deeper prefixes as cache keys
3. On cache miss, runs `inshellah help docker compose up`
3. On cache miss, runs `inshellah help --iterative docker compose up`
4. Caches the result; all subsequent completions are instant
First tab-press latency is ~100-200ms. Depth is capped at 5 levels.
The `--iterative` flag tells inshellah to resolve only the immediate level
without recursing into subcommands. This keeps each cache miss fast
(~100-200ms) and lets the completer progressively resolve deeper levels
on demand. Without `--iterative`, `inshellah help` recursively resolves
all subcommands, which is better for upfront generation but too slow for
interactive tab-completion.
Depth is capped at 5 levels.
## Cache management
```sh
rm -rf ~/.cache/inshellah/ # Clear all
ls ~/.cache/inshellah/ # List cached
inshellah help docker run > ~/.cache/inshellah/docker-run.nu # Regenerate one
inshellah help --iterative docker run > ~/.cache/inshellah/docker-run.nu # Regenerate one
```
## When to use this vs build-time generation
@ -85,3 +92,10 @@ at build time. Runtime caching is useful for:
- Commands installed outside the system profile (cargo, pip, npm, go)
- Subcommand completions at arbitrary depth
- Systems without the NixOS module
For one-off upfront generation (not runtime caching), use `inshellah help`
without `--iterative` to recursively resolve all subcommands at once:
```sh
inshellah help docker > docker.nu # Resolves all subcommands recursively
```

View file

@ -14,6 +14,7 @@ depends: [
"dune" {>= "3.20"}
"angstrom"
"angstrom-unix"
"camlzip"
"ppx_inline_test" {with-test}
"odoc" {with-doc}
]

View file

@ -435,33 +435,25 @@ let count_macro name lines =
(* Auto-detect and try strategies, return the one with most entries *)
let extract_entries lines =
let results = ref [] in
(* Try TP if .TP macros present *)
if count_macro "TP" lines > 0 then
results := ("TP", strategy_tp lines) :: !results;
(* Try IP if .IP macros present *)
if count_macro "IP" lines > 0 then
results := ("IP", strategy_ip lines) :: !results;
(* Try PP+RS if both present *)
if count_macro "PP" lines > 0 && count_macro "RS" lines > 0 then
results := ("PP+RS", strategy_pp_rs lines) :: !results;
(* Try nix3 style if UR macros present *)
if count_macro "UR" lines > 0 && count_macro "IP" lines > 0 then
results := ("nix", strategy_nix lines) :: !results;
(* Always try deroff as fallback *)
results := ("deroff", strategy_deroff_lines lines) :: !results;
(* Prefer specialized strategies over deroff fallback *)
let specialized =
List.filter (fun (name, entries) -> name <> "deroff" && entries <> []) !results
let tp = count_macro "TP" lines
and ip = count_macro "IP" lines
and pp = count_macro "PP" lines
and rs = count_macro "RS" lines
and ur = count_macro "UR" lines in
let specialized = List.filter_map Fun.id [
(if tp > 0 then Some ("TP", strategy_tp lines) else None);
(if ip > 0 then Some ("IP", strategy_ip lines) else None);
(if pp > 0 && rs > 0 then Some ("PP+RS", strategy_pp_rs lines) else None);
(if ur > 0 && ip > 0 then Some ("nix", strategy_nix lines) else None);
] in
let candidates = match List.filter (fun (_, e) -> e <> []) specialized with
| [] -> [("deroff", strategy_deroff_lines lines)]
| filtered -> filtered
in
let candidates = if specialized <> [] then specialized else !results in
let best =
List.fold_left (fun (best_name, best_entries) (name, entries) ->
if List.length entries >= List.length best_entries then (name, entries)
else (best_name, best_entries)
) ("none", []) candidates
in
snd best
List.fold_left (fun (_, best) (name, entries) ->
if List.length entries >= List.length best then (name, entries)
else (name, best)
) ("none", []) candidates |> snd
(* --- SYNOPSIS command name extraction --- *)
@ -514,12 +506,174 @@ let extract_synopsis_command contents =
let lines = String.split_on_char '\n' contents in
extract_synopsis_command_lines lines
(* --- SYNOPSIS positional extraction --- *)
let extract_synopsis_positionals_lines lines =
let classified = List.map classify_line lines in
let is_synopsis name =
String.uppercase_ascii (String.trim name) = "SYNOPSIS"
in
let rec find = function
| [] -> []
| Macro ("SH", args) :: rest when is_synopsis args -> collect rest []
| _ :: rest -> find rest
and collect lines acc =
match lines with
| [] -> finish acc
| Macro ("SH", _) :: _ -> finish acc
| Macro ("SS", _) :: _ -> finish acc
| Macro ("br", _) :: _ -> finish acc
| Text s :: rest ->
let s = strip_groff_escapes s |> String.trim in
collect rest (if String.length s > 0 then s :: acc else acc)
| Macro (("B" | "BI" | "BR" | "I" | "IR" | "IB" | "RB" | "RI"), args) :: rest ->
let s = strip_inline_macro_args args |> strip_groff_escapes |> String.trim in
collect rest (if String.length s > 0 then s :: acc else acc)
| _ :: rest -> collect rest acc
and finish acc =
let parts = List.rev acc in
let full = String.concat " " parts |> String.trim in
if String.length full = 0 then []
else
let cmd_end = skip_command_prefix full in
let args = String.sub full cmd_end (String.length full - cmd_end) in
parse_usage_args args
in
find classified
(* --- mdoc (BSD) format support --- *)
let is_mdoc lines =
List.exists (fun l ->
match classify_line l with Macro ("Sh", _) -> true | _ -> false
) lines
let mdoc_text_of line =
match line with
| Text s -> Some (strip_groff_escapes s)
| Macro (m, args) ->
(match m with
| "Pp" | "Bl" | "El" | "Sh" | "Ss" | "Os" | "Dd" | "Dt"
| "Oo" | "Oc" | "Op" -> None
| _ ->
let s = strip_groff_escapes args |> String.trim in
if s = "" then None else Some s)
| _ -> None
let parse_mdoc_it args =
let words = String.split_on_char ' ' args
|> List.filter (fun w -> w <> "" && w <> "Ns") in
let param = match words with
| _ :: _ :: "Ar" :: p :: _ -> Some (Mandatory p)
| _ -> None
in
match words with
| "Fl" :: c :: _ when String.length c = 1 && is_alphanumeric c.[0] ->
Some { switch = Short c.[0]; param; desc = "" }
| "Fl" :: name :: _ when String.length name > 1 && name.[0] = '-' ->
Some { switch = Long (String.sub name 1 (String.length name - 1)); param; desc = "" }
| _ -> None
let positional_of_mdoc_line optional args =
let words = String.split_on_char ' ' args
|> List.filter (fun w -> w <> "") in
match words with
| name :: _ when String.length name >= 2 ->
Some { pos_name = String.lowercase_ascii name;
optional; variadic = List.mem "..." words }
| _ -> None
let parse_mdoc_lines lines =
let classified = List.map classify_line lines in
let rec skip_to_el = function
| [] -> []
| Macro ("El", _) :: rest -> rest
| _ :: rest -> skip_to_el rest
in
let rec collect_desc acc = function
| [] -> (acc, [])
| (Macro ("It", _) | Macro ("El", _)
| Macro ("Sh", _) | Macro ("Ss", _)) :: _ as rest -> (acc, rest)
| line :: rest ->
collect_desc (match mdoc_text_of line with Some s -> s :: acc | None -> acc) rest
in
let desc_of rest =
let parts, rest = collect_desc [] rest in
(String.concat " " (List.rev parts) |> String.trim, rest)
in
let parse_it args rest entries =
let desc, rest = desc_of rest in
let entries = match parse_mdoc_it args with
| Some e -> { e with desc } :: entries
| None -> entries
in
(entries, rest)
in
let rec parse_option_list entries = function
| [] -> (entries, [])
| Macro ("El", _) :: rest -> (entries, rest)
| Macro ("It", args) :: rest ->
let entries, rest = parse_it args rest entries in
parse_option_list entries rest
| _ :: rest -> parse_option_list entries rest
in
let rec scan entries positionals = function
| [] -> (entries, positionals)
| Macro ("Bl", _) :: Macro ("It", it_args) :: rest ->
let words = String.split_on_char ' ' it_args
|> List.filter (fun w -> w <> "") in
if (match words with "Fl" :: _ -> true | _ -> false) then
let entries, rest = parse_it it_args rest entries in
let entries, rest = parse_option_list entries rest in
scan entries positionals rest
else
scan entries positionals (skip_to_el rest)
| Macro ("Bl", _) :: rest -> scan entries positionals (skip_to_el rest)
| Macro ("Sh", args) :: rest
when String.uppercase_ascii (String.trim args) = "SYNOPSIS" ->
let positionals, rest = parse_synopsis positionals rest in
scan entries positionals rest
| _ :: rest -> scan entries positionals rest
and parse_synopsis positionals = function
| [] -> (positionals, [])
| Macro ("Sh", _) :: _ as rest -> (positionals, rest)
| Macro ("Ar", args) :: rest ->
let positionals = match positional_of_mdoc_line false args with
| Some p -> p :: positionals | None -> positionals in
parse_synopsis positionals rest
| Macro ("Op", args) :: rest ->
let words = String.split_on_char ' ' args
|> List.filter (fun w -> w <> "") in
let positionals = match words with
| "Ar" :: _ ->
(match positional_of_mdoc_line true args with
| Some p -> p :: positionals | None -> positionals)
| _ -> positionals in
parse_synopsis positionals rest
| _ :: rest -> parse_synopsis positionals rest
in
let entries, positionals = scan [] [] classified in
let positionals =
List.rev positionals
|> List.fold_left (fun (seen, acc) p ->
if List.mem p.pos_name seen then (seen, acc)
else (p.pos_name :: seen, p :: acc)
) ([], [])
|> snd |> List.rev
in
{ entries = List.rev entries; subcommands = []; positionals }
(* --- Top-level API --- *)
let parse_manpage_lines lines =
let options_section = extract_options_section lines in
let entries = extract_entries options_section in
{ entries; subcommands = [] }
if is_mdoc lines then
parse_mdoc_lines lines
else begin
let options_section = extract_options_section lines in
let entries = extract_entries options_section in
let positionals = extract_synopsis_positionals_lines lines in
{ entries; subcommands = []; positionals }
end
let parse_manpage_string contents =
let lines = String.split_on_char '\n' contents in

View file

@ -1,5 +1,9 @@
open Parser
module SSet = Set.Make(String)
module SMap = Map.Make(String)
module CSet = Set.Make(Char)
(* Nushell built-in commands and keywords *)
let nushell_builtins = [
"alias"; "all"; "ansi"; "any"; "append"; "ast"; "attr";
@ -40,13 +44,10 @@ let nushell_builtins = [
"zip";
]
let builtin_set = lazy (
let tbl = Hashtbl.create (List.length nushell_builtins) in
List.iter (fun s -> Hashtbl.replace tbl s true) nushell_builtins;
tbl)
let builtin_set = lazy (SSet.of_list nushell_builtins)
let is_nushell_builtin cmd =
Hashtbl.mem (Lazy.force builtin_set) cmd
SSet.mem cmd (Lazy.force builtin_set)
let dedup_entries entries =
let key_of entry =
@ -60,30 +61,24 @@ let dedup_entries entries =
let d = min 5 (String.length entry.desc / 10) in
sw + p + d
in
let best = Hashtbl.create 64 in
List.iter (fun e ->
let best = List.fold_left (fun acc e ->
let k = key_of e in
match Hashtbl.find_opt best k with
| Some prev when score prev >= score e -> ()
| _ -> Hashtbl.replace best k e
) entries;
let covered_shorts = Hashtbl.create 16 in
Hashtbl.iter (fun _ e ->
match SMap.find_opt k acc with
| Some prev when score prev >= score e -> acc
| _ -> SMap.add k e acc
) SMap.empty entries in
let covered = SMap.fold (fun _ e acc ->
match e.switch with
| Both (c, _) -> Hashtbl.replace covered_shorts c true
| _ -> ()
) best;
let seen = Hashtbl.create 64 in
List.filter_map (fun e ->
| Both (c, _) -> CSet.add c acc
| _ -> acc
) best CSet.empty in
List.fold_left (fun (seen, acc) e ->
let k = key_of e in
if Hashtbl.mem seen k then None
else
match e.switch with
| Short c when Hashtbl.mem covered_shorts c -> None
| _ ->
Hashtbl.add seen k true;
Hashtbl.find_opt best k
) entries
if SSet.mem k seen then (seen, acc)
else match e.switch with
| Short c when CSet.mem c covered -> (seen, acc)
| _ -> (SSet.add k seen, SMap.find k best :: acc)
) (SSet.empty, []) entries |> snd |> List.rev
let nushell_type_of_param = function
| "FILE" | "file" | "PATH" | "path" | "DIR" | "dir" | "DIRECTORY"
@ -120,22 +115,40 @@ let format_flag entry =
let pad_len = max 1 (40 - String.length flag) in
flag ^ String.make pad_len ' ' ^ "# " ^ entry.desc
let write_extern buf cmd_name result =
let entries = dedup_entries result.entries in
Printf.bprintf buf "export extern \"%s\" [\n" (escape_nu cmd_name);
List.iter (fun e ->
Buffer.add_string buf (format_flag e); Buffer.add_char buf '\n'
) entries;
Buffer.add_string buf "]\n";
List.iter (fun (sc : subcommand) ->
Printf.bprintf buf "\nexport extern \"%s %s\" [ # %s\n]\n"
(escape_nu cmd_name) (escape_nu sc.name) (escape_nu sc.desc)
) result.subcommands
let format_positional p =
let name = String.map (function '-' -> '_' | c -> c) p.pos_name in
let prefix = if p.variadic then "..." else "" in
let suffix = if p.optional && not p.variadic then "?" else "" in
let typ = nushell_type_of_param (String.uppercase_ascii p.pos_name) in
Printf.sprintf " %s%s%s: %s" prefix name suffix typ
let generate_extern cmd_name result =
let buf = Buffer.create 1024 in
write_extern buf cmd_name result;
Buffer.contents buf
let fixup_positionals positionals =
(* Nushell rules: no required after optional, only one rest param *)
List.fold_left (fun (saw_opt, saw_rest, acc) p ->
if p.variadic then
if saw_rest then (saw_opt, saw_rest, acc)
else (true, true, p :: acc)
else if saw_opt then
(true, saw_rest, { p with optional = true } :: acc)
else
(p.optional, saw_rest, p :: acc)
) (false, false, []) positionals
|> fun (_, _, acc) -> List.rev acc
let extern_of cmd_name result =
let entries = dedup_entries result.entries in
let cmd = escape_nu cmd_name in
let positionals = fixup_positionals result.positionals in
let pos_lines = List.map (fun p -> format_positional p ^ "\n") positionals in
let flags = List.map (fun e -> format_flag e ^ "\n") entries in
let main = Printf.sprintf "export extern \"%s\" [\n%s%s]\n" cmd (String.concat "" pos_lines) (String.concat "" flags) in
let subs = List.map (fun (sc : subcommand) ->
Printf.sprintf "\nexport extern \"%s %s\" [ # %s\n]\n"
cmd (escape_nu sc.name) (escape_nu sc.desc)
) result.subcommands in
String.concat "" (main :: subs)
let generate_extern = extern_of
let module_name_of cmd_name =
let s = String.map (function
@ -144,11 +157,7 @@ let module_name_of cmd_name =
let generate_module cmd_name result =
let m = module_name_of cmd_name in
let buf = Buffer.create 1024 in
Printf.bprintf buf "module %s {\n" m;
write_extern buf cmd_name result;
Printf.bprintf buf "}\n\nuse %s *\n" m;
Buffer.contents buf
Printf.sprintf "module %s {\n%s}\n\nuse %s *\n" m (extern_of cmd_name result) m
let generate_extern_from_entries cmd_name entries =
generate_extern cmd_name { entries; subcommands = [] }
generate_extern cmd_name { entries; subcommands = []; positionals = [] }

View file

@ -58,7 +58,8 @@ type switch = Short of char | Long of string | Both of char * string
type param = Mandatory of string | Optional of string
type entry = { switch : switch; param : param option; desc : string }
type subcommand = { name : string; desc : string }
type help_result = { entries : entry list; subcommands : subcommand list }
type positional = { pos_name : string; optional : bool; variadic : bool }
type help_result = { entries : entry list; subcommands : subcommand list; positionals : positional list }
(* --- Low-level combinators --- *)
@ -256,13 +257,236 @@ let help_parser =
in
many (choice [ try_entry; try_subcommand; try_skip ]) >>| fun items ->
let entries = List.filter_map (function `Entry e -> Some e | _ -> None) items in
let subcommands = List.filter_map (function `Subcommand sc -> Some sc | _ -> None) items in
{ entries; subcommands })
let subcommands =
List.filter_map (function `Subcommand sc -> Some sc | _ -> None) items
|> List.fold_left (fun acc sc ->
match List.assoc_opt sc.name acc with
| Some prev when String.length prev.desc >= String.length sc.desc -> acc
| _ -> (sc.name, sc) :: List.remove_assoc sc.name acc
) []
|> List.rev_map snd
in
{ entries; subcommands; positionals = [] })
let skip_command_prefix s =
let len = String.length s in
let i = ref 0 in
let skip_ws () = while !i < len && (s.[!i] = ' ' || s.[!i] = '\t') do incr i done in
let is_word_char = function
| 'a'..'z' | 'A'..'Z' | '0'..'9' | '-' | '_' | '/' | '.' -> true
| _ -> false
in
let rec loop () =
skip_ws ();
if !i >= len then ()
else if s.[!i] = '[' || s.[!i] = '<' || s.[!i] = '(' || s.[!i] = '{' || s.[!i] = '-' then ()
else if is_word_char s.[!i] then begin
let start = !i in
while !i < len && is_word_char s.[!i] do incr i done;
let word = String.sub s start (!i - start) in
let has_lower = ref false in
String.iter (fun c -> if c >= 'a' && c <= 'z' then has_lower := true) word;
if not !has_lower then
i := start
else
loop ()
end
in
loop ();
!i
let parse_usage_args s =
let len = String.length s in
let i = ref 0 in
let results = ref [] in
let skip_ws () =
while !i < len && (s.[!i] = ' ' || s.[!i] = '\t') do incr i done in
let is_pos_char c =
(c >= 'A' && c <= 'Z') || c = '_' || c = '-' || (c >= '0' && c <= '9') in
let read_dots () =
skip_ws ();
if !i + 2 < len && s.[!i] = '.' && s.[!i+1] = '.' && s.[!i+2] = '.' then
(i := !i + 3; true)
else if !i + 2 < len && s.[!i] = '\xe2' && s.[!i+1] = '\x80' && s.[!i+2] = '\xa6' then
(i := !i + 3; true) (* UTF-8 ellipsis … *)
else false
in
let is_skip name =
let u = String.uppercase_ascii name in
u = "OPTIONS" || u = "OPTION" || u = "FLAGS" || u = "FLAG"
in
let is_clean_name name =
String.length name >= 2
&& String.for_all (fun c ->
(c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
|| (c >= '0' && c <= '9') || c = '_' || c = '-') name
in
let is_letter c = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') in
let skip_braces () =
(* Skip {A|c|d|...} alternative blocks *)
if !i < len && s.[!i] = '{' then begin
let depth = ref 1 in
incr i;
while !i < len && !depth > 0 do
if s.[!i] = '{' then incr depth
else if s.[!i] = '}' then decr depth;
incr i
done;
ignore (read_dots ());
true
end else false
in
while !i < len do
skip_ws ();
if !i >= len then ()
else if skip_braces () then ()
else match s.[!i] with
| '[' ->
incr i;
let start = !i in
let depth = ref 1 in
while !i < len && !depth > 0 do
if s.[!i] = '[' then incr depth
else if s.[!i] = ']' then decr depth;
incr i
done;
let bracket_end = !i - 1 in
let inner = String.sub s start (max 0 (bracket_end - start)) |> String.trim in
let inner, has_inner_dots =
if String.ends_with ~suffix:"..." inner then
(String.sub inner 0 (String.length inner - 3) |> String.trim, true)
else (inner, false)
in
let variadic = has_inner_dots || read_dots () in
if String.length inner > 0
&& inner.[0] <> '-'
&& (is_letter inner.[0] || inner.[0] = '<') then begin
let name =
if inner.[0] = '<' then
let e = try String.index inner '>' with Not_found -> String.length inner in
String.sub inner 1 (e - 1)
else inner
in
if is_clean_name name && not (is_skip name) then
results := { pos_name = String.lowercase_ascii name;
optional = true; variadic } :: !results
end
| '<' ->
incr i;
let start = !i in
while !i < len && s.[!i] <> '>' do incr i done;
let name = String.sub s start (!i - start) in
if !i < len then incr i;
let variadic = read_dots () in
if is_clean_name name && not (is_skip name) then
results := { pos_name = String.lowercase_ascii name;
optional = false; variadic } :: !results
| '-' ->
while !i < len && s.[!i] <> ' ' && s.[!i] <> '\t' && s.[!i] <> ']' do incr i done
| c when c >= 'A' && c <= 'Z' ->
let start = !i in
while !i < len && is_pos_char s.[!i] do incr i done;
let name = String.sub s start (!i - start) in
let variadic = read_dots () in
if String.length name >= 2
&& String.for_all (fun c ->
(c >= 'A' && c <= 'Z') || c = '_' || c = '-' || (c >= '0' && c <= '9')
) name
&& not (is_skip name) then
results := { pos_name = String.lowercase_ascii name;
optional = false; variadic } :: !results
| _ ->
incr i
done;
List.rev !results
|> List.fold_left (fun (seen, acc) p ->
if List.mem p.pos_name seen then (seen, acc)
else (p.pos_name :: seen, p :: acc)
) ([], [])
|> snd |> List.rev
let extract_usage_positionals text =
let lines = String.split_on_char '\n' text in
let lines_arr = Array.of_list lines in
let len = Array.length lines_arr in
let find_usage_line () =
let rec go i =
if i >= len then None
else
let t = String.trim lines_arr.(i) in
let tlen = String.length t in
if tlen >= 6 then
let prefix = String.lowercase_ascii (String.sub t 0 6) in
if prefix = "usage:" then begin
let after = String.sub t 6 (tlen - 6) |> String.trim in
if String.length after > 0 then Some after
else if i + 1 < len then
(* Clap style: USAGE:\n cmd [OPTIONS] PATTERN *)
let next = String.trim lines_arr.(i + 1) in
if String.length next > 0 then Some next else None
else None
end else go (i + 1)
else go (i + 1)
in
go 0
in
match find_usage_line () with
| None -> []
| Some usage ->
let cmd_end = skip_command_prefix usage in
let args = String.sub usage cmd_end (String.length usage - cmd_end) in
parse_usage_args args
let extract_cli11_positionals text =
let lines = String.split_on_char '\n' text in
let rec find_section = function
| [] -> []
| line :: rest ->
let t = String.trim line in
if t = "POSITIONALS:" || t = "Positionals:" then
parse_lines rest []
else
find_section rest
and parse_lines lines acc =
match lines with
| [] -> List.rev acc
| line :: rest ->
let len = String.length line in
if len = 0 || (line.[0] <> ' ' && line.[0] <> '\t') then
List.rev acc
else
let t = String.trim line in
if String.length t = 0 then List.rev acc
else match parse_one t with
| Some p -> parse_lines rest (p :: acc)
| None -> parse_lines rest acc
and parse_one s =
let len = String.length s in
let i = ref 0 in
let is_name_char c =
(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|| (c >= '0' && c <= '9') || c = '_' || c = '-' in
while !i < len && is_name_char s.[!i] do incr i done;
if !i < 2 then None
else
let name = String.sub s 0 !i in
while !i < len && (s.[!i] = ' ' || s.[!i] = '\t') do incr i done;
(* skip type word: TEXT, INT, FLOAT, ENUM, BOOLEAN, etc. *)
while !i < len && s.[!i] >= 'A' && s.[!i] <= 'Z' do incr i done;
while !i < len && (s.[!i] = ' ' || s.[!i] = '\t') do incr i done;
let variadic = !i + 2 < len && s.[!i] = '.' && s.[!i+1] = '.' && s.[!i+2] = '.' in
Some { pos_name = String.lowercase_ascii name; optional = false; variadic }
in
find_section lines
let parse_help txt =
let clean = strip_ansi txt in
match Angstrom.parse_string ~consume:Consume.Prefix help_parser clean with
| Ok result -> Ok result
| Ok result ->
let cli11 = extract_cli11_positionals clean in
let usage = extract_usage_positionals clean in
let positionals = if cli11 <> [] then cli11 else usage in
Ok { result with positionals }
| Error msg -> Error msg
(* --- Pretty printers --- *)
@ -286,6 +510,11 @@ let print_subcommand sc =
Printf.printf "\n\t** SUBCOMMAND **\n\tName: %s\n\tDescription: %s\n"
sc.name sc.desc
let print_positional p =
Printf.printf "\n\t** POSITIONAL **\n\tName: %s\n\tOptional: %b\n\tVariadic: %b\n"
p.pos_name p.optional p.variadic
let print_help_result r =
List.iter print_entry r.entries;
List.iter print_subcommand r.subcommands
List.iter print_subcommand r.subcommands;
List.iter print_positional r.positionals