diff --git a/bin/main.ml b/bin/main.ml index 5fd294e..c1772db 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -2,8 +2,6 @@ open Inshellah.Parser open Inshellah.Manpage open Inshellah.Nushell -module SSet = Set.Make(String) - let usage () = Printf.eprintf {|inshellah - generate nushell completions @@ -14,10 +12,7 @@ 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 [--iterative] CMD [ARGS...] - Run CMD ARGS --help, parse and emit extern. - Recursively resolves subcommands unless - --iterative is given. + inshellah help CMD [ARGS...] Run CMD ARGS --help, parse and emit extern inshellah parse-help CMD Read --help text from stdin, emit extern inshellah demo Run built-in demo @@ -99,17 +94,21 @@ let is_executable path = st.st_kind = Unix.S_REG && st.st_perm land 0o111 <> 0 with Unix.Unix_error _ -> false -let is_script path = +let is_shell_script path = try let real = Unix.realpath path in let ic = open_in_bin real 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 + 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 close_in ic; - has_shebang + 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"] with _ -> false let elf_scan path needles = @@ -160,12 +159,11 @@ let elf_scan path needles = found let skip_name name = - String.length name = 0 || name = "-" || name.[0] = '.' + String.length name = 0 || 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 @@ -174,7 +172,7 @@ let classify_binary bindir name = else let path = Filename.concat bindir name in if not (is_executable path) then Skip - else if is_script path then Try_help + else if is_shell_script path then Try_help else let scan = elf_scan path ["-h"; "completion"] in if not (Hashtbl.mem scan "-h") then Skip @@ -229,50 +227,17 @@ 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 = - let iterative, cmd_args = match args with - | "--iterative" :: rest -> (true, rest) - | _ -> (false, args) - in - match cmd_args with + match args with | [] -> Printf.eprintf "error: help requires a command name\n"; exit 1 | cmd :: rest -> - 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 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 cmd_parse_help cmd = let buf = Buffer.create 4096 in @@ -296,22 +261,23 @@ let process_manpage file = with _ -> None let manpaged_commands mandir = - List.fold_left (fun acc section -> + let cmds = Hashtbl.create 128 in + List.iter (fun section -> let subdir = Filename.concat mandir (Printf.sprintf "man%d" section) in if Sys.file_exists subdir && Sys.is_directory subdir then - Array.fold_left (fun acc f -> SSet.add (cmd_name_of_manpage f) acc) - acc (Sys.readdir subdir) - else acc - ) SSet.empty command_sections + Array.iter (fun f -> Hashtbl.replace cmds (cmd_name_of_manpage f) true) + (Sys.readdir subdir) + ) command_sections; + cmds let cmd_generate bindir mandir outdir = - let done_cmds = ref SSet.empty in + let done_cmds = Hashtbl.create 256 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 SSet.mem name manpaged then (name, Skip) + if Hashtbl.mem manpaged name then (name, Skip) else (name, classify_binary bindir name) ) bins in let pending = ref [] in @@ -362,7 +328,7 @@ let cmd_generate bindir mandir outdir = with _ -> exit 1) end else begin pending := pid :: !pending; - done_cmds := SSet.add name !done_cmds + Hashtbl.replace done_cmds name true end ) classified; while !pending <> [] do @@ -379,9 +345,8 @@ let cmd_generate bindir mandir outdir = | None -> () | Some (cmd, result) -> let base = List.hd (String.split_on_char ' ' cmd) in - if SSet.mem cmd !done_cmds then () + if Hashtbl.mem done_cmds base 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 cf75a23..7c6580d 100644 --- a/doc/nushell-integration.md +++ b/doc/nushell-integration.md @@ -15,12 +15,9 @@ Generate completions for a single command: # From a manpage inshellah manpage /usr/share/man/man1/git.1.gz > git.nu -# From --help output (recursively resolves subcommands) +# From --help output 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 a771ea0..25373f9 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" "--iterative" ...($candidate) | complete) + let result = (run-external "inshellah" "help" ...($candidate) | complete) if $result.exit_code == 0 and ($result.stdout | str length) > 10 { $result.stdout | save -f $cache_file $cmd_spans = $candidate @@ -64,24 +64,17 @@ 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 --iterative docker compose up` +3. On cache miss, runs `inshellah help docker compose up` 4. Caches the result; all subsequent completions are instant -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. +First tab-press latency is ~100-200ms. Depth is capped at 5 levels. ## Cache management ```sh rm -rf ~/.cache/inshellah/ # Clear all ls ~/.cache/inshellah/ # List cached -inshellah help --iterative docker run > ~/.cache/inshellah/docker-run.nu # Regenerate one +inshellah help docker run > ~/.cache/inshellah/docker-run.nu # Regenerate one ``` ## When to use this vs build-time generation @@ -92,10 +85,3 @@ 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 8d020f1..c3cc819 100644 --- a/inshellah.opam +++ b/inshellah.opam @@ -14,7 +14,6 @@ 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 552c246..036a09a 100644 --- a/lib/manpage.ml +++ b/lib/manpage.ml @@ -435,25 +435,33 @@ let count_macro name lines = (* Auto-detect and try strategies, return the one with most entries *) let extract_entries lines = - 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 + 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 in - List.fold_left (fun (_, best) (name, entries) -> - if List.length entries >= List.length best then (name, entries) - else (name, best) - ) ("none", []) candidates |> snd + 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 (* --- SYNOPSIS command name extraction --- *) @@ -506,174 +514,12 @@ 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 = - 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 options_section = extract_options_section lines in + let entries = extract_entries options_section in + { entries; subcommands = [] } 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 d976806..517090f 100644 --- a/lib/nushell.ml +++ b/lib/nushell.ml @@ -1,9 +1,5 @@ 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"; @@ -44,10 +40,13 @@ let nushell_builtins = [ "zip"; ] -let builtin_set = lazy (SSet.of_list nushell_builtins) +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 is_nushell_builtin cmd = - SSet.mem cmd (Lazy.force builtin_set) + Hashtbl.mem (Lazy.force builtin_set) cmd let dedup_entries entries = let key_of entry = @@ -61,24 +60,30 @@ let dedup_entries entries = let d = min 5 (String.length entry.desc / 10) in sw + p + d in - let best = List.fold_left (fun acc e -> + let best = Hashtbl.create 64 in + List.iter (fun e -> let k = key_of e in - 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 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 e.switch with - | Both (c, _) -> CSet.add c acc - | _ -> acc - ) best CSet.empty in - List.fold_left (fun (seen, acc) e -> + | Both (c, _) -> Hashtbl.replace covered_shorts c true + | _ -> () + ) best; + let seen = Hashtbl.create 64 in + List.filter_map (fun e -> let k = key_of e in - 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 + 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 let nushell_type_of_param = function | "FILE" | "file" | "PATH" | "path" | "DIR" | "dir" | "DIRECTORY" @@ -115,40 +120,22 @@ let format_flag entry = let pad_len = max 1 (40 - String.length flag) in flag ^ String.make pad_len ' ' ^ "# " ^ entry.desc -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 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 write_extern buf 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) + 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 generate_extern = extern_of +let generate_extern cmd_name result = + let buf = Buffer.create 1024 in + write_extern buf cmd_name result; + Buffer.contents buf let module_name_of cmd_name = let s = String.map (function @@ -157,7 +144,11 @@ let module_name_of cmd_name = let generate_module cmd_name result = let m = module_name_of cmd_name in - Printf.sprintf "module %s {\n%s}\n\nuse %s *\n" m (extern_of cmd_name result) m + 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 let generate_extern_from_entries cmd_name entries = - generate_extern cmd_name { entries; subcommands = []; positionals = [] } + generate_extern cmd_name { entries; subcommands = [] } diff --git a/lib/parser.ml b/lib/parser.ml index efe3087..9aedaa2 100644 --- a/lib/parser.ml +++ b/lib/parser.ml @@ -58,8 +58,7 @@ 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 positional = { pos_name : string; optional : bool; variadic : bool } -type help_result = { entries : entry list; subcommands : subcommand list; positionals : positional list } +type help_result = { entries : entry list; subcommands : subcommand list } (* --- Low-level combinators --- *) @@ -257,236 +256,13 @@ 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 - |> 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 subcommands = List.filter_map (function `Subcommand sc -> Some sc | _ -> None) items in + { entries; subcommands }) let parse_help txt = let clean = strip_ansi txt in match Angstrom.parse_string ~consume:Consume.Prefix help_parser clean with - | 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 } + | Ok result -> Ok result | Error msg -> Error msg (* --- Pretty printers --- *) @@ -510,11 +286,6 @@ 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_positional r.positionals + List.iter print_subcommand r.subcommands