filter and sort completions

This commit is contained in:
atagen 2026-03-23 16:42:19 +11:00
parent 144d72b223
commit f05a6b9f8f

View file

@ -560,26 +560,38 @@ let completion_json value desc =
Printf.sprintf "{\"value\":\"%s\",\"description\":\"%s\"}"
(escape_json value) (escape_json desc)
let flag_completions prefix entries =
List.filter_map (fun (e : entry) ->
let desc = match e.param with
| Some (Mandatory p) -> if e.desc <> "" then e.desc ^ " <" ^ p ^ ">" else "<" ^ p ^ ">"
| Some (Optional p) -> if e.desc <> "" then e.desc ^ " [" ^ p ^ "]" else "[" ^ p ^ "]"
| None -> e.desc in
match e.switch with
| Long l ->
let flag = "--" ^ l in
if String.starts_with ~prefix flag then Some (completion_json flag desc) else None
| Short c ->
let flag = Printf.sprintf "-%c" c in
if String.starts_with ~prefix flag then Some (completion_json flag desc) else None
| Both (c, l) ->
let long = "--" ^ l in
let short = Printf.sprintf "-%c" c in
if String.starts_with ~prefix long then Some (completion_json long desc)
else if String.starts_with ~prefix short then Some (completion_json short desc)
else None
) entries
(* Fuzzy matching: returns a score > 0 if needle is a subsequence of haystack.
Higher scores = better match. Scoring:
- Exact match: 1000
- Prefix match: 900 + length bonus
- Subsequence with word-boundary alignment: bonus per boundary hit
- Plain subsequence: base score from match density *)
let fuzzy_score needle haystack =
let nlen = String.length needle and hlen = String.length haystack in
if nlen = 0 then 1
else if nlen > hlen then 0
else if needle = haystack then 1000
else
let needle = String.lowercase_ascii needle
and haystack_lc = String.lowercase_ascii haystack in
if String.starts_with ~prefix:needle haystack_lc then
900 + (nlen * 100 / hlen)
else
let is_boundary hi =
hi = 0 || haystack.[hi - 1] = '-' || haystack.[hi - 1] = '_'
|| (haystack.[hi - 1] >= 'a' && haystack.[hi - 1] <= 'z'
&& haystack.[hi] >= 'A' && haystack.[hi] <= 'Z') in
(* Walk haystack matching needle chars as a subsequence *)
let ni, score, _, _ =
String.fold_left (fun (ni, score, hi, prev_match) c ->
if ni >= nlen then (ni, score, hi + 1, prev_match)
else if c = needle.[ni] then
let bonus = (if is_boundary hi then 50 else 10)
+ (if prev_match = hi - 1 then 20 else 0) in
(ni + 1, score + bonus, hi + 1, hi)
else (ni, score, hi + 1, prev_match)
) (0, 0, 0, -1) haystack_lc in
if ni = nlen then score else 0
let cmd_complete spans user_dir system_dirs =
match spans with
@ -599,18 +611,19 @@ let cmd_complete spans user_dir system_dirs =
| Some r -> Some (try_name, r, List.length prefix)
| None -> None) in
let all_tokens = cmd_name :: rest in
let partial_tokens = match rest with
| _ :: _ -> cmd_name :: List.rev (List.tl (List.rev rest))
| _ -> [cmd_name] in
let last_token = match rest with
| [] -> "" | _ -> List.nth rest (List.length rest - 1) in
(* Try full token list first (last token is a complete subcommand),
then fall back to treating the last token as partial *)
let try_both () =
match find_result all_tokens with
| Some _ as r -> (r, "")
| None -> (find_result partial_tokens, last_token) in
let found, partial = try_both () in
(* Find the longest matching command prefix; the first unmatched
token (if any) becomes the fuzzy partial. *)
let resolve tokens =
match find_result tokens with
| Some (name, r, depth) ->
let partial = if depth < List.length tokens then
List.nth tokens depth
else "" in
(Some (name, r, depth), partial)
| None -> (None, last_token) in
let found, partial = resolve all_tokens in
(* If not found at all, try on-the-fly resolution for the base command *)
let result, partial = match found with
| Some _ -> (found, partial)
@ -618,26 +631,34 @@ let cmd_complete spans user_dir system_dirs =
(match find_in_path cmd_name with
| Some path ->
(match resolve_and_cache ~dir:user_dir cmd_name path with
| Some _pairs -> try_both ()
| Some _pairs -> resolve all_tokens
| None -> (None, partial))
| None -> (None, partial)) in
let candidates = match result with
| None -> []
| Some (_matched_name, r, _depth) ->
if String.starts_with ~prefix:"-" partial then
flag_completions partial r.entries
else
let subs = match r.subcommands with
| _ :: _ -> r.subcommands
| [] -> subcommands_of dirs _matched_name in
let sub_candidates = List.filter_map (fun (sc : subcommand) ->
if partial = "" || String.starts_with ~prefix:partial sc.name then
Some (completion_json sc.name sc.desc)
else None
) subs in
if partial = "" || sub_candidates = [] then
sub_candidates @ flag_completions partial r.entries
else sub_candidates in
let subs = match r.subcommands with
| _ :: _ -> r.subcommands
| [] -> subcommands_of dirs _matched_name in
let sub_candidates = List.filter_map (fun (sc : subcommand) ->
let s = fuzzy_score partial sc.name in
if s > 0 then Some (s, completion_json sc.name sc.desc) else None
) subs in
let flag_candidates = List.filter_map (fun (e : entry) ->
let desc = match e.param with
| Some (Mandatory p) -> if e.desc <> "" then e.desc ^ " <" ^ p ^ ">" else "<" ^ p ^ ">"
| Some (Optional p) -> if e.desc <> "" then e.desc ^ " [" ^ p ^ "]" else "[" ^ p ^ "]"
| None -> e.desc in
let flag = match e.switch with
| Long l -> "--" ^ l
| Short c -> Printf.sprintf "-%c" c
| Both (_, l) -> "--" ^ l in
let s = fuzzy_score partial flag in
if s > 0 then Some (s, completion_json flag desc) else None
) r.entries in
let scored = sub_candidates @ flag_candidates in
List.sort (fun (a, _) (b, _) -> compare b a) scored
|> List.map snd in
Printf.printf "[%s]\n" (String.concat "," candidates)
let cmd_query cmd dirs =