diff --git a/bin/main.ml b/bin/main.ml index fffd493..fb12c6d 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -1041,7 +1041,10 @@ let find_real_command is_command args = * this ensures file completions appear with full nushell UX. when the user * IS typing a flag (partial starts with "-"), we return our flag candidates. *) let cmd_complete spans user_dir system_dirs mandirs = - let dirs = user_dir :: system_dirs in + (* system dirs are searched first — they're built at index time from + * manpages and are authoritative. user dir is an on-the-fly cache + * that should only be used as fallback for commands not in any system dir. *) + let dirs = system_dirs @ [user_dir] in (* if the command line starts with a privilege-escalation wrapper, scan past * it to find the real command. we identify the command by checking the store * and $PATH — this avoids needing per-command option tables which are fragile diff --git a/flake.nix b/flake.nix index b09bd98..36b6c86 100644 --- a/flake.nix +++ b/flake.nix @@ -84,13 +84,17 @@ $entries } "systemctl" => { - if ($spans | length) < 2 { null } else { + if ($spans | length) < 3 { null } else { let kw = $spans | last - ^systemctl list-units --all --no-pager --plain --full $"($kw)*" - | detect columns - | drop 7 - | headers - | each { |r| {value: $r.UNIT, description: ($r.DESCRIPTION | default "")} } + let scope = if ("--user" in $spans) { [--user] } else { [] } + ^systemctl ...$scope list-units --all --no-pager --plain --full --no-legend $"($kw)*" + | lines + | each { |l| + let cols = $l | split column -r '\s\s+' unit load active sub desc + if ($cols | length) > 0 { + {value: $cols.0.unit, description: ($cols.0.desc | default "")} + } + } | compact } } _ => { null } diff --git a/lib/manpage.ml b/lib/manpage.ml index 0fa0a71..5415fac 100644 --- a/lib/manpage.ml +++ b/lib/manpage.ml @@ -655,6 +655,15 @@ let extract_name_description contents = * we hit something that looks like an argument (starts with [, <, -, etc.). *) let extract_synopsis_command_lines lines = + (* replace italic text (\fI...\fR) with angle-bracketed placeholders + * before classification strips the font info. italic in groff indicates + * a parameter/placeholder (e.g. \fIoperation\fR), not a command word. + * the angle brackets cause extract_cmd to stop at these tokens since + * '<' is in its stop set. without this, "nix-env \fIoperation\fR" + * would be parsed as command "nix-env operation" instead of "nix-env". *) + let lines = List.map (fun line -> + Str.global_replace (Str.regexp {|\\fI\([^\\]*\)\\f[RP]|}) {|<\1>|} line + ) lines in let classified = List.map classify_line lines in let is_synopsis name = String.uppercase_ascii (String.trim name) = "SYNOPSIS" diff --git a/lib/store.ml b/lib/store.ml index f8d15bf..2466c81 100644 --- a/lib/store.ml +++ b/lib/store.ml @@ -387,18 +387,190 @@ let find_file dirs command = else None ) dirs -(* look up a command and deserialize its help_result from JSON. - * only searches for .json files (not .nu, since those can't be deserialized - * back into help_result). returns None if not found or parse fails. *) +(* parse a nushell .nu file to extract a help_result for a specific command. + * .nu files contain `export extern "cmd" [ ... ]` blocks with flag definitions. + * this parser extracts flags, positionals, subcommands, and descriptions + * from the nushell extern syntax so the completer can use native completions. + * + * nushell extern parameter syntax: + * --flag(-s): type # description → Both(s, "flag") with param + * --flag: type # description → Long "flag" with param + * --flag # description → Long "flag" no param + * -s # description → Short 's' + * name: type # description → positional + * name?: type → optional positional + * ...name: type → variadic positional + *) +let parse_nu_completions target_cmd contents = + let lines = String.split_on_char '\n' contents in + (* extract the description comment preceding an export extern block *) + let current_desc = ref "" in + (* collect all extern blocks: (cmd_name, entries, positionals, description) *) + let blocks = ref [] in + let in_block = ref false in + let block_cmd = ref "" in + let block_entries = ref [] in + let block_positionals = ref [] in + let block_desc = ref "" in + let finish_block () = + if !in_block then begin + blocks := (!block_cmd, List.rev !block_entries, + List.rev !block_positionals, !block_desc) :: !blocks; + in_block := false + end in + List.iter (fun line -> + let trimmed = String.trim line in + if not !in_block then begin + (* look for description comments and export extern lines *) + if String.length trimmed > 2 && trimmed.[0] = '#' && trimmed.[1] = ' ' then + current_desc := String.trim (String.sub trimmed 2 (String.length trimmed - 2)) + else if String.length trimmed > 15 + && (try ignore (Str.search_forward + (Str.regexp_string "export extern") trimmed 0); true + with Not_found -> false) then begin + (* extract command name from: export extern "cmd name" [ or export extern cmd [ *) + let re_quoted = Str.regexp {|export extern "\([^"]*\)"|} in + let re_bare = Str.regexp {|export extern \([a-zA-Z0-9_-]+\)|} in + let cmd_opt = + if try ignore (Str.search_forward re_quoted trimmed 0); true + with Not_found -> false + then Some (Str.matched_group 1 trimmed) + else if try ignore (Str.search_forward re_bare trimmed 0); true + with Not_found -> false + then Some (Str.matched_group 1 trimmed) + else None in + if cmd_opt <> None then begin + let cmd = match cmd_opt with Some c -> c | None -> "" in + in_block := true; + block_cmd := cmd; + block_entries := []; + block_positionals := []; + block_desc := !current_desc; + current_desc := "" + end + end else + current_desc := "" + end else begin + (* inside an extern block — parse flag/positional lines *) + if String.length trimmed > 0 && trimmed.[0] = ']' then + finish_block () + else begin + (* extract description from # comment *) + let param_part, desc = + match String.split_on_char '#' trimmed with + | before :: rest -> + (String.trim before, + String.trim (String.concat "#" rest)) + | _ -> (trimmed, "") + in + if String.length param_part > 1 then begin + if param_part.[0] = '-' && param_part.[1] = '-' then begin + (* long flag: --flag(-s): type or --flag: type or --flag *) + let re_both = Str.regexp {|--\([a-zA-Z0-9-]+\)(-\([a-zA-Z0-9]\))\(: *\([a-zA-Z]+\)\)?|} in + let re_long = Str.regexp {|--\([a-zA-Z0-9-]+\)\(: *\([a-zA-Z]+\)\)?|} in + if try ignore (Str.search_forward re_both param_part 0); true + with Not_found -> false then begin + let long = Str.matched_group 1 param_part in + let short = (Str.matched_group 2 param_part).[0] in + let param = try Some (Mandatory (Str.matched_group 4 param_part)) + with Not_found | Invalid_argument _ -> None in + block_entries := { switch = Both (short, long); param; desc } :: !block_entries + end else if try ignore (Str.search_forward re_long param_part 0); true + with Not_found -> false then begin + let long = Str.matched_group 1 param_part in + let param = try Some (Mandatory (Str.matched_group 3 param_part)) + with Not_found | Invalid_argument _ -> None in + block_entries := { switch = Long long; param; desc } :: !block_entries + end + end else if param_part.[0] = '-' then begin + (* short flag: -s *) + if String.length param_part >= 2 then + let c = param_part.[1] in + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') then + block_entries := { switch = Short c; param = None; desc } :: !block_entries + end else begin + (* positional: name: type or name?: type or ...name: type *) + let variadic = String.starts_with ~prefix:"..." param_part in + let part = if variadic then String.sub param_part 3 (String.length param_part - 3) + else param_part in + let optional = try let q = String.index part '?' in q > 0 + with Not_found -> false in + let name = match String.index_opt part ':' with + | Some i -> String.trim (String.sub part 0 i) + | None -> match String.index_opt part '?' with + | Some i -> String.trim (String.sub part 0 i) + | None -> String.trim part in + let name = String.map (function '-' -> '_' | c -> c) name in + if String.length name > 0 && name.[0] <> '-' then + block_positionals := { pos_name = name; optional = optional || variadic; + variadic } :: !block_positionals + end + end + end + end + ) lines; + finish_block (); + let blocks = List.rev !blocks in + (* find the block matching the target command *) + let target = target_cmd in + match List.find_opt (fun (cmd, _, _, _) -> cmd = target) blocks with + | Some (_, entries, positionals, description) -> + (* collect subcommands from other blocks that are children of this command *) + let prefix = target ^ " " in + let subcommands = List.filter_map (fun (cmd, _, _, desc) -> + if String.starts_with ~prefix cmd then + let sub_name = String.sub cmd (String.length prefix) + (String.length cmd - String.length prefix) in + (* only immediate subcommands (no further spaces) *) + if not (String.contains sub_name ' ') && String.length sub_name > 0 + then Some { name = sub_name; desc } + else None + else None + ) blocks in + { entries; subcommands; positionals; description } + | None -> + (* target not found — return empty result *) + { entries = []; subcommands = []; positionals = []; description = "" } + +(* look up a command and deserialize its help_result. + * searches for .json files first, then falls back to .nu files + * (parsing the nushell extern syntax to extract completion data). + * for subcommands like "rbw get", also checks the parent's .nu file + * (e.g. rbw.nu) since clap-generated .nu files contain all extern + * blocks in a single file. *) let lookup dirs command = let base_name = filename_of_command command in + (* also try the root command's .nu file for subcommand lookups. + * "rbw get" -> try rbw.nu and look for the "rbw get" extern block. *) + let parent_base = match String.index_opt command ' ' with + | Some i -> Some (filename_of_command (String.sub command 0 i)) + | None -> None in List.find_map (fun directory -> - let path = Filename.concat directory (base_name ^ ".json") in - match read_file path with + let json_path = Filename.concat directory (base_name ^ ".json") in + match read_file json_path with | Some data -> (try Some (help_result_of_json (parse_json data)) with _ -> None) - | None -> None + | None -> + let nu_path = Filename.concat directory (base_name ^ ".nu") in + (match read_file nu_path with + | Some data -> + (try Some (parse_nu_completions command data) + with _ -> None) + | None -> + (* try parent's .nu file for subcommand blocks *) + match parent_base with + | Some pb -> + let parent_nu = Filename.concat directory (pb ^ ".nu") in + (match read_file parent_nu with + | Some data -> + (try + let r = parse_nu_completions command data in + if r.entries <> [] || r.subcommands <> [] || r.positionals <> [] + then Some r else None + with _ -> None) + | None -> None) + | None -> None) ) dirs (* look up a command's raw data (JSON or .nu source) without parsing. diff --git a/test/test_inshellah.ml b/test/test_inshellah.ml index bb36889..8f7b25e 100644 --- a/test/test_inshellah.ml +++ b/test/test_inshellah.ml @@ -500,6 +500,52 @@ Options: * here we just verify the parser extracts it correctly. *) check "has entries too" (List.length r.entries >= 2) +let test_nu_file_parsing () = + Printf.printf "\n== .nu file parsing ==\n"; + let nu_source = {|module completions { + + # Unofficial CLI tool + export extern mytool [ + --help(-h) # Print help + --version(-V) # Print version + ] + + # List all items + export extern "mytool list" [ + --raw # Output as JSON + --format(-f): string # Output format + --help(-h) # Print help + name?: string # Filter by name + ] + +} + +use completions * +|} in + let r = Inshellah.Store.parse_nu_completions "mytool" nu_source in + check "has entries" (List.length r.entries = 2); + check "has subcommands" (List.length r.subcommands >= 1); + let list_sc = List.find_opt (fun (sc : subcommand) -> sc.name = "list") r.subcommands in + check "has list subcommand" (list_sc <> None); + check "description" (r.description = "Unofficial CLI tool"); + (* test subcommand lookup *) + let r2 = Inshellah.Store.parse_nu_completions "mytool list" nu_source in + check "list has entries" (List.length r2.entries = 3); + let has_format = List.exists (fun (e : entry) -> + e.switch = Both ('f', "format")) r2.entries in + check "list has --format(-f)" has_format; + check "list has positional" (List.length r2.positionals >= 1) + +let test_italic_synopsis () = + Printf.printf "\n== Italic in SYNOPSIS ==\n"; + let groff = {|.SH Synopsis +.LP +\f(CRnix-env\fR \fIoperation\fR [\fIoptions\fR] [\fIarguments…\fR] +.SH Description +|} in + let cmd = extract_synopsis_command groff in + check "no phantom operation" (cmd = Some "nix-env") + let test_font_boundary_spacing () = Printf.printf "\n== Font boundary spacing ==\n"; (* \fB--max-results\fR\fIcount\fR should become "--max-results count" *) @@ -556,5 +602,9 @@ let () = test_commands_section_subcommands (); test_self_listing_detection (); + Printf.printf "\nRunning .nu and synopsis tests...\n"; + test_nu_file_parsing (); + test_italic_synopsis (); + Printf.printf "\n=== Results: %d passed, %d failed ===\n" !passes !failures; if !failures > 0 then exit 1