diff --git a/bin/main.ml b/bin/main.ml index 4ef15c7..2e5e0d5 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -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 =