From 18c97eacd0857a25c21acd74a14f52e21cf0823b Mon Sep 17 00:00:00 2001 From: atagen Date: Sat, 21 Mar 2026 21:22:41 +1100 Subject: [PATCH] cli11, bsd manual --- bin/main.ml | 98 ++++++++++------ doc/nushell-integration.md | 5 +- doc/runtime-completions.md | 22 +++- inshellah.opam | 1 + lib/manpage.ml | 209 +++++++++++++++++++++++++++++----- lib/nushell.ml | 89 +++++++-------- lib/parser.ml | 227 ++++++++++++++++++++++++++++++++++++- 7 files changed, 534 insertions(+), 117 deletions(-) diff --git a/bin/main.ml b/bin/main.ml index c1772db..4bda8b5 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -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,7 +160,7 @@ 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 @@ -172,7 +173,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 +228,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 +295,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 +361,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 +378,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 = diff --git a/doc/nushell-integration.md b/doc/nushell-integration.md index 7c6580d..cf75a23 100644 --- a/doc/nushell-integration.md +++ b/doc/nushell-integration.md @@ -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 ``` diff --git a/doc/runtime-completions.md b/doc/runtime-completions.md index 25373f9..a771ea0 100644 --- a/doc/runtime-completions.md +++ b/doc/runtime-completions.md @@ -31,7 +31,7 @@ def inshellah-complete [spans: list] { } 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 --`: 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 +``` diff --git a/inshellah.opam b/inshellah.opam index c3cc819..8d020f1 100644 --- a/inshellah.opam +++ b/inshellah.opam @@ -14,6 +14,7 @@ depends: [ "dune" {>= "3.20"} "angstrom" "angstrom-unix" + "camlzip" "ppx_inline_test" {with-test} "odoc" {with-doc} ] diff --git a/lib/manpage.ml b/lib/manpage.ml index 036a09a..7e7c5c6 100644 --- a/lib/manpage.ml +++ b/lib/manpage.ml @@ -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,171 @@ 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_lines lines = + let classified = List.map classify_line lines in + let entries = ref [] in + let positionals = ref [] in + let rec skip_to_el = function + | [] -> [] + | Macro ("El", _) :: rest -> rest + | _ :: rest -> skip_to_el rest + in + let collect_desc rest = + let rec go acc = function + | [] -> (acc, []) + | (Macro ("It", _) | Macro ("El", _) + | Macro ("Sh", _) | Macro ("Ss", _)) :: _ as rest -> (acc, rest) + | line :: rest -> + go (match mdoc_text_of line with Some s -> s :: acc | None -> acc) rest + in + let parts, rest = go [] rest in + (String.concat " " (List.rev parts) |> String.trim, rest) + in + let parse_mdoc_it args = + let words = String.split_on_char ' ' args + |> List.filter (fun w -> w <> "" && w <> "Ns") in + match words with + | "Fl" :: c :: _ when String.length c = 1 && is_alphanumeric c.[0] -> + let param = match words with + | _ :: _ :: "Ar" :: p :: _ -> Some (Mandatory p) + | _ -> None + in + Some { switch = Short c.[0]; param; desc = "" } + | "Fl" :: name :: _ when String.length name > 1 && name.[0] = '-' -> + let long = String.sub name 1 (String.length name - 1) in + let param = match words with + | _ :: _ :: "Ar" :: p :: _ -> Some (Mandatory p) + | _ -> None + in + Some { switch = Long long; param; desc = "" } + | _ -> None + in + let rec parse_option_list = function + | [] -> [] + | Macro ("El", _) :: rest -> rest + | Macro ("It", args) :: rest -> + let desc, rest = collect_desc rest in + (match parse_mdoc_it args with + | Some e -> entries := { e with desc } :: !entries + | None -> ()); + parse_option_list rest + | _ :: rest -> parse_option_list rest + in + let rec scan = function + | [] -> () + | 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 begin + let desc, rest = collect_desc rest in + (match parse_mdoc_it it_args with + | Some e -> entries := { e with desc } :: !entries + | None -> ()); + scan (parse_option_list rest) + end else + scan (skip_to_el rest) + | Macro ("Bl", _) :: rest -> scan (skip_to_el rest) + | Macro ("Sh", args) :: rest + when String.uppercase_ascii (String.trim args) = "SYNOPSIS" -> + scan (parse_synopsis rest) + | _ :: rest -> scan rest + and parse_synopsis = function + | [] -> [] + | Macro ("Sh", _) :: _ as rest -> rest + | Macro ("Ar", args) :: rest -> + let words = String.split_on_char ' ' args + |> List.filter (fun w -> w <> "") in + (match words with + | name :: _ when String.length name >= 2 -> + let variadic = List.mem "..." words in + positionals := { pos_name = String.lowercase_ascii name; + optional = false; variadic } :: !positionals + | _ -> ()); + parse_synopsis rest + | Macro ("Op", args) :: rest -> + let words = String.split_on_char ' ' args + |> List.filter (fun w -> w <> "") in + (match words with + | "Ar" :: name :: _ when String.length name >= 2 -> + let variadic = List.mem "..." words in + positionals := { pos_name = String.lowercase_ascii name; + optional = true; variadic } :: !positionals + | _ -> ()); + parse_synopsis rest + | _ :: rest -> parse_synopsis rest + in + scan classified; + let seen = Hashtbl.create 8 in + let positionals = List.rev !positionals |> List.filter (fun p -> + if Hashtbl.mem seen p.pos_name then false + else (Hashtbl.replace seen p.pos_name true; true)) 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 diff --git a/lib/nushell.ml b/lib/nushell.ml index 517090f..688a879 100644 --- a/lib/nushell.ml +++ b/lib/nushell.ml @@ -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,26 @@ 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 extern_of cmd_name result = + let entries = dedup_entries result.entries in + let cmd = escape_nu cmd_name in + let pos_lines = List.map (fun p -> format_positional p ^ "\n") result.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 +143,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 = [] } diff --git a/lib/parser.ml b/lib/parser.ml index 9aedaa2..ffa91ec 100644 --- a/lib/parser.ml +++ b/lib/parser.ml @@ -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 --- *) @@ -257,12 +258,225 @@ let help_parser = 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 }) + { 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; + let seen = Hashtbl.create 8 in + List.rev !results |> List.filter (fun p -> + if Hashtbl.mem seen p.pos_name then false + else (Hashtbl.replace seen p.pos_name true; true)) + +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 +500,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