From 1d0d3465c17a9d39f3cded691f04e1fba3159f2c Mon Sep 17 00:00:00 2001 From: atagen Date: Mon, 23 Mar 2026 02:17:42 +1100 Subject: [PATCH] SAVE GAME --- bin/main.ml | 236 ++++++++++++++++++++++++++++++++- dune-project | 1 + flake.nix | 2 + lib/dune | 2 +- lib/parser.ml | 53 +++++++- lib/store.ml | 344 +++++++++++++++++++++++++++++++++++++++++++++++++ nix/module.nix | 13 +- 7 files changed, 641 insertions(+), 10 deletions(-) create mode 100644 lib/store.ml diff --git a/bin/main.ml b/bin/main.ml index ce1a179..9ac0556 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -1,14 +1,21 @@ open Inshellah.Parser open Inshellah.Manpage open Inshellah.Nushell +open Inshellah.Store module SSet = Set.Make(String) let usage () = Printf.eprintf - {|inshellah - generate nushell completions + {|inshellah - nushell completions engine Usage: + inshellah index PREFIX... [--db PATH] + Index completions into a SQLite database. + PREFIX is a directory containing bin/ and share/man/. + Default db: $XDG_CACHE_HOME/inshellah/completions.db + inshellah dump [--db PATH] + Show stats and commands in the database. inshellah generate BINDIR MANDIR -o OUTDIR Full generation: native completions, manpages, and --help fallback. One .nu file per command. @@ -250,6 +257,8 @@ let cmd_manpage_dir dir = ) (Sys.readdir subdir) ) command_sections +let max_resolve_results = 500 + let help_resolve ?(timeout=10_000) cmd rest name = let max_jobs = num_cores () in let queue = Queue.create () in @@ -269,7 +278,7 @@ let help_resolve ?(timeout=10_000) cmd rest name = (escape_nu p_name) (escape_nu p_desc) :: !results | Some (code, subs) -> results := code :: !results; - if p_depth < 5 then + if p_depth < 5 && List.length !results < max_resolve_results then List.iter (fun (sc_name, sc_desc) -> Queue.push (p_rest @ [sc_name], p_name ^ " " ^ sc_name, @@ -311,6 +320,17 @@ let help_resolve ?(timeout=10_000) cmd rest name = | Error _ -> None | Ok r when r.entries = [] && r.subcommands = [] && r.positionals = [] -> None | Ok r -> + (* If the subcommand we just queried appears in its own + subcommand list, the command is echoing the parent help + (e.g. nil ignores subcommands when --help is present). + Abandon this branch to avoid infinite recursion. *) + let self_listed = match q_rest with + | [] -> false + | _ -> + let leaf = List.nth q_rest (List.length q_rest - 1) in + List.exists (fun (sc : subcommand) -> sc.name = leaf) r.subcommands in + if self_listed then None + else let at_limit = q_depth >= 5 in let code = generate_extern q_name (if at_limit then r else { r with subcommands = [] }) in @@ -390,14 +410,14 @@ let manpaged_commands mandir = else acc ) SSet.empty command_sections -let cmd_generate bindir mandir outdir = +let cmd_generate bindir mandir outdir ignorelist = 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 SSet.mem name manpaged then (name, Skip) + if SSet.mem name manpaged || SSet.mem name ignorelist then (name, Skip) else (name, classify_binary bindir name) ) bins in let pending = ref [] in @@ -487,6 +507,176 @@ let cmd_generate bindir mandir outdir = end ) command_sections + +(* Sequential help resolver for use inside forked children. + No forking — just iterates through subcommands with run_cmd directly. *) +let help_resolve_seq ?(timeout=200) cmd rest name = + let queue = Queue.create () in + Queue.push (rest, name, 0) queue; + let results = ref [] in + while not (Queue.is_empty queue) do + let (q_rest, q_name, q_depth) = Queue.pop queue in + let text = match run_cmd (cmd :: q_rest @ ["--help"]) timeout with + | Some _ as r -> r + | None -> run_cmd (cmd :: q_rest @ ["-h"]) timeout in + match text with + | None -> () + | Some text -> + (match parse_help text with + | Error _ -> () + | Ok r when r.entries = [] && r.subcommands = [] && r.positionals = [] -> () + | Ok r -> + let self_listed = match q_rest with + | [] -> false + | _ -> + let leaf = List.nth q_rest (List.length q_rest - 1) in + List.exists (fun (sc : subcommand) -> sc.name = leaf) r.subcommands in + if not self_listed then begin + let at_limit = q_depth >= 5 || List.length !results >= max_resolve_results in + results := (q_name, { r with subcommands = [] }) :: !results; + if not at_limit then + List.iter (fun (sc : subcommand) -> + Queue.push (q_rest @ [sc.name], q_name ^ " " ^ sc.name, q_depth + 1) queue + ) r.subcommands + end) + done; + List.rev !results + +(* Index: mirrors cmd_generate's fork-per-binary pattern. + Each child handles one binary completely (including subcommand resolution) + and marshals all results back in one shot. No nested forking — children + use help_resolve_seq which is purely sequential. *) +let cmd_index bindirs mandirs ignorelist db_path = + let db = init db_path in + begin_transaction db; + let done_cmds = ref SSet.empty in + let n_results = ref 0 in + let index_bindir bindir mandir = + if not (Sys.file_exists bindir && Sys.is_directory bindir) then + Printf.eprintf "skipping %s (not found)\n" bindir + else begin + let bins = Sys.readdir bindir in + Array.sort String.compare bins; + let manpaged = if Sys.file_exists mandir && Sys.is_directory mandir + then manpaged_commands mandir else SSet.empty in + let max_jobs = num_cores () in + let classified = Array.map (fun name -> + if SSet.mem name manpaged || SSet.mem name ignorelist then (name, Skip) + else (name, classify_binary bindir name) + ) bins in + let pending = ref [] in + let reap () = + pending := List.filter (fun (pid, rd, name) -> + match Unix.waitpid [Unix.WNOHANG] pid with + | (0, _) -> true + | _ -> + let ic = Unix.in_channel_of_descr rd in + (let[@warning "-8"] result : [`Native of string | `Parsed of (string * help_result) list | `None] = + try Marshal.from_channel ic with _ -> `None in + match result with + | `Native src -> + upsert_raw db ~source:"native" name src; + incr n_results + | `Parsed pairs -> + List.iter (fun (cmd_name, r) -> + if not (SSet.mem cmd_name !done_cmds) then begin + upsert db ~source:"help" cmd_name r; + done_cmds := SSet.add cmd_name !done_cmds; + incr n_results + end + ) pairs + | `None -> ()); + close_in ic; + done_cmds := SSet.add name !done_cmds; + false + | exception Unix.Unix_error (Unix.ECHILD, _, _) -> + (try Unix.close rd with _ -> ()); false + ) !pending in + let wait_slot () = + while List.length !pending >= max_jobs do + reap (); + if List.length !pending >= max_jobs then + (try ignore (Unix.wait ()) with Unix.Unix_error _ -> ()); + reap () + done in + Array.iter (fun (name, cls) -> + match cls with + | Skip -> () + | Try_help | Try_native_and_help -> + wait_slot (); + let (rd, wr) = Unix.pipe () in + let pid = Unix.fork () in + if pid = 0 then begin + Unix.close rd; + List.iter (fun (_, prd, _) -> + try Unix.close prd with _ -> ()) !pending; + let result = + try + let path = Filename.concat bindir name in + let native = match cls with + | Try_native_and_help -> + (match try_native_completion path with + | Some src -> Some src | None -> None) + | _ -> None in + match native with + | Some src -> `Native src + | None -> + let pairs = help_resolve_seq ~timeout:200 path [] name in + if pairs <> [] then `Parsed pairs else `None + with _ -> `None in + let oc = Unix.out_channel_of_descr wr in + Marshal.to_channel oc + (result : [`Native of string | `Parsed of (string * help_result) list | `None]) []; + close_out oc; + exit 0 + end else begin + Unix.close wr; + pending := (pid, rd, name) :: !pending + end + ) classified; + while !pending <> [] do + (try ignore (Unix.wait ()) with Unix.Unix_error _ -> ()); + reap () + done; + (* Phase 2: manpages *) + if Sys.file_exists mandir && Sys.is_directory mandir then + 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 begin + let files = Sys.readdir subdir in + Array.sort String.compare files; + Array.iter (fun file -> + match process_manpage (Filename.concat subdir file) with + | None -> () + | Some (cmd, result) -> + if not (SSet.mem cmd !done_cmds) then begin + upsert db ~source:"manpage" cmd result; + done_cmds := SSet.add cmd !done_cmds; + incr n_results + end + ) files + end + ) command_sections + end in + List.iter2 index_bindir bindirs mandirs; + commit db; + Printf.printf "indexed %d commands into %s\n" !n_results db_path; + close db + +let cmd_dump db_path = + let db = init db_path in + let (count, sources) = stats db in + Printf.printf "database: %s\n" db_path; + Printf.printf "commands: %d (from %d sources)\n" count sources; + let cmds = all_commands db in + List.iter (fun cmd -> + match lookup db cmd with + | None -> () + | Some (_data, source) -> + Printf.printf " %-40s [%s]\n" cmd source + ) cmds; + close db + let cmd_demo () = Printf.printf "# Generated by: inshellah demo\n\n"; match parse_help @@ -502,9 +692,45 @@ let cmd_demo () = | Ok r -> print_string (generate_extern "ls" r) | Error msg -> Printf.eprintf "parse error: %s\n" msg +let load_ignorelist path = + try + let ic = open_in path in + let lines = ref [] in + (try while true do + let line = String.trim (input_line ic) in + if String.length line > 0 && line.[0] <> '#' then + lines := line :: !lines + done with End_of_file -> ()); + close_in ic; + SSet.of_list !lines + with _ -> SSet.empty + +let parse_index_args args = + let rec go prefixes db ignore = function + | [] -> (List.rev prefixes, db, ignore) + | "--db" :: path :: rest -> go prefixes path ignore rest + | "--ignore" :: path :: rest -> go prefixes db (SSet.union ignore (load_ignorelist path)) rest + | dir :: rest -> go (dir :: prefixes) db ignore rest in + go [] (default_db_path ()) SSet.empty args + let () = match Array.to_list Sys.argv |> List.tl with - | ["generate"; bindir; mandir; "-o"; outdir] -> cmd_generate bindir mandir outdir + | ["generate"; bindir; mandir; "-o"; outdir] -> + cmd_generate bindir mandir outdir SSet.empty + | ["generate"; bindir; mandir; "-o"; outdir; "--ignore"; ignore_file] -> + cmd_generate bindir mandir outdir (load_ignorelist ignore_file) + | "index" :: rest -> + let (prefixes, db_path, ignorelist) = parse_index_args rest in + if prefixes = [] then (Printf.eprintf "error: index requires at least one prefix dir\n"; exit 1); + let bindirs = List.map (fun p -> Filename.concat p "bin") prefixes in + let mandirs = List.map (fun p -> Filename.concat p "share/man") prefixes in + cmd_index bindirs mandirs ignorelist db_path + | "dump" :: rest -> + let db_path = match rest with + | ["--db"; path] -> path + | [] -> default_db_path () + | _ -> Printf.eprintf "error: dump [--db PATH]\n"; exit 1 in + cmd_dump db_path | ["manpage"; file] -> cmd_manpage file | ["manpage-dir"; dir] -> cmd_manpage_dir dir | "help" :: rest -> cmd_help rest diff --git a/dune-project b/dune-project index 514770f..89cddbb 100644 --- a/dune-project +++ b/dune-project @@ -24,6 +24,7 @@ angstrom angstrom-unix camlzip + sqlite3 (ppx_inline_test :with-test)) (tags (shell completions nushell parser angstrom))) diff --git a/flake.nix b/flake.nix index 90a94f9..8636276 100644 --- a/flake.nix +++ b/flake.nix @@ -20,6 +20,7 @@ angstrom angstrom-unix camlzip + ocaml_sqlite3 ppx_inline_test ocaml-lsp ocamlformat @@ -43,6 +44,7 @@ angstrom angstrom-unix camlzip + ocaml_sqlite3 ]; meta.mainProgram = "inshellah"; diff --git a/lib/dune b/lib/dune index 38defe1..338f98a 100644 --- a/lib/dune +++ b/lib/dune @@ -1,3 +1,3 @@ (library (name inshellah) - (libraries angstrom angstrom-unix camlzip str unix)) + (libraries angstrom angstrom-unix camlzip sqlite3 str unix)) diff --git a/lib/parser.ml b/lib/parser.ml index 81bf201..3fcc807 100644 --- a/lib/parser.ml +++ b/lib/parser.ml @@ -234,7 +234,43 @@ let subcommand_entry = else char ' ' *> char ' ' *> inline_ws *> rest_of_line <* eol >>| fun desc -> - { name; desc = String.trim desc } + { name = String.lowercase_ascii name; + desc = let t = String.trim desc in + if String.length t >= 2 && t.[0] = '-' && t.[1] = ' ' then + String.trim (String.sub t 2 (String.length t - 2)) + else t } + +(* --- Section header detection --- *) + +(* Detect lines like "Arguments:", "POSITIONALS:", etc. that introduce + positional-argument sections (where name+desc lines are NOT subcommands) *) +let is_arg_section s = + let lc = String.lowercase_ascii (String.trim s) in + let base = if String.ends_with ~suffix:":" lc + then String.sub lc 0 (String.length lc - 1) |> String.trim + else lc in + base = "arguments" || base = "args" || base = "positionals" + || base = "positional arguments" + +(* A section header: left-aligned (or lightly indented) text ending with ':', + not starting with '-'. Must be consumed BEFORE subcommand_entry in choice. *) +let section_header = + available >>= fun avail -> + if avail = 0 then fail "eof" + else + peek_string (min avail 80) >>= fun preview -> + (* Extract just the first line from the preview *) + let first_line = match String.index_opt preview '\n' with + | Some i -> String.sub preview 0 i + | None -> preview in + let t = String.trim first_line in + let len = String.length t in + let indent = let i = ref 0 in + while !i < String.length first_line && (first_line.[!i] = ' ' || first_line.[!i] = '\t') do incr i done; + !i in + if len >= 2 && t.[len - 1] = ':' && t.[0] <> '-' && indent <= 4 then + rest_of_line <* eol_strict >>| fun line -> is_arg_section line + else fail "not a section header" (* --- Top-level parser --- *) @@ -247,6 +283,10 @@ let help_parser = let try_entry = entry >>| fun e -> `Entry e in + (* Detect section headers to track arg vs command sections *) + let try_section = + section_header >>| fun is_arg -> `Section is_arg + in (* Try to parse a subcommand *) let try_subcommand = subcommand_entry >>| fun sc -> `Subcommand sc @@ -255,10 +295,17 @@ let help_parser = let try_skip = skip_non_option_line >>| fun () -> `Skip in - many (choice [ try_entry; try_subcommand; try_skip ]) >>| fun items -> + many (choice [ try_entry; try_section; try_subcommand; try_skip ]) >>| fun items -> let entries = List.filter_map (function `Entry e -> Some e | _ -> None) items in + (* Only keep subcommands that didn't appear under an Arguments/Positionals section *) let subcommands = - List.filter_map (function `Subcommand sc -> Some sc | _ -> None) items + List.fold_left (fun (in_arg_sec, acc) item -> + match item with + | `Section is_arg -> (is_arg, acc) + | `Subcommand sc when not in_arg_sec -> (in_arg_sec, sc :: acc) + | _ -> (in_arg_sec, acc) + ) (false, []) items + |> snd |> List.rev |> 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 diff --git a/lib/store.ml b/lib/store.ml new file mode 100644 index 0000000..b340192 --- /dev/null +++ b/lib/store.ml @@ -0,0 +1,344 @@ +open Parser + +let default_db_path () = + let cache = try Sys.getenv "XDG_CACHE_HOME" + with Not_found -> Filename.concat (Sys.getenv "HOME") ".cache" in + Filename.concat cache "inshellah/completions.db" + +let ensure_parent path = + let dir = Filename.dirname path in + let rec mkdir_p d = + if Sys.file_exists d then () + else begin mkdir_p (Filename.dirname d); Unix.mkdir d 0o755 end in + mkdir_p dir + +let init db_path = + ensure_parent db_path; + let db = Sqlite3.db_open db_path in + let exec sql = + match Sqlite3.exec db sql with + | Sqlite3.Rc.OK -> () + | rc -> failwith (Printf.sprintf "sqlite: %s: %s" (Sqlite3.Rc.to_string rc) sql) in + exec "PRAGMA journal_mode=WAL"; + exec "PRAGMA synchronous=NORMAL"; + exec {|CREATE TABLE IF NOT EXISTS completions ( + command TEXT PRIMARY KEY, + data TEXT NOT NULL, + source TEXT, + updated_at INTEGER NOT NULL + )|}; + db + +let close db = ignore (Sqlite3.db_close db) + +(* --- JSON serialization of help_result --- *) + +let escape_json s = + let buf = Buffer.create (String.length s + 4) in + String.iter (fun c -> match c with + | '"' -> Buffer.add_string buf "\\\"" + | '\\' -> Buffer.add_string buf "\\\\" + | '\n' -> Buffer.add_string buf "\\n" + | '\t' -> Buffer.add_string buf "\\t" + | '\r' -> Buffer.add_string buf "\\r" + | c when Char.code c < 0x20 -> + Buffer.add_string buf (Printf.sprintf "\\u%04x" (Char.code c)) + | c -> Buffer.add_char buf c + ) s; + Buffer.contents buf + +let json_string s = Printf.sprintf "\"%s\"" (escape_json s) +let json_null = "null" + +let json_switch_of = function + | Short c -> Printf.sprintf "{\"type\":\"short\",\"char\":%s}" (json_string (String.make 1 c)) + | Long l -> Printf.sprintf "{\"type\":\"long\",\"name\":%s}" (json_string l) + | Both (c, l) -> + Printf.sprintf "{\"type\":\"both\",\"char\":%s,\"name\":%s}" + (json_string (String.make 1 c)) (json_string l) + +let json_param_of = function + | None -> json_null + | Some (Mandatory p) -> + Printf.sprintf "{\"kind\":\"mandatory\",\"name\":%s}" (json_string p) + | Some (Optional p) -> + Printf.sprintf "{\"kind\":\"optional\",\"name\":%s}" (json_string p) + +let json_entry_of e = + Printf.sprintf "{\"switch\":%s,\"param\":%s,\"desc\":%s}" + (json_switch_of e.switch) (json_param_of e.param) (json_string e.desc) + +let json_subcommand_of sc = + Printf.sprintf "{\"name\":%s,\"desc\":%s}" (json_string sc.name) (json_string sc.desc) + +let json_positional_of p = + Printf.sprintf "{\"name\":%s,\"optional\":%b,\"variadic\":%b}" + (json_string p.pos_name) p.optional p.variadic + +let json_list f items = + "[" ^ String.concat "," (List.map f items) ^ "]" + +let json_of_help_result r = + Printf.sprintf "{\"entries\":%s,\"subcommands\":%s,\"positionals\":%s}" + (json_list json_entry_of r.entries) + (json_list json_subcommand_of r.subcommands) + (json_list json_positional_of r.positionals) + +(* --- JSON deserialization --- *) + +(* Minimal JSON parser — just enough for our own output *) + +type json = + | Jnull + | Jbool of bool + | Jstring of string + | Jarray of json list + | Jobject of (string * json) list + +let json_get key = function + | Jobject pairs -> (try List.assoc key pairs with Not_found -> Jnull) + | _ -> Jnull + +let json_to_string = function Jstring s -> s | _ -> "" +let json_to_bool = function Jbool b -> b | _ -> false +let json_to_list = function Jarray l -> l | _ -> [] + +exception Json_error of string + +let parse_json s = + let len = String.length s in + let pos = ref 0 in + let peek () = if !pos < len then s.[!pos] else '\x00' in + let advance () = incr pos in + let skip_ws () = + while !pos < len && (s.[!pos] = ' ' || s.[!pos] = '\t' + || s.[!pos] = '\n' || s.[!pos] = '\r') do + advance () + done in + let expect c = + skip_ws (); + if peek () <> c then + raise (Json_error (Printf.sprintf "expected '%c' at %d" c !pos)); + advance () in + let rec parse_value () = + skip_ws (); + match peek () with + | '"' -> Jstring (parse_string ()) + | '{' -> parse_object () + | '[' -> parse_array () + | 'n' -> advance (); advance (); advance (); advance (); Jnull + | 't' -> advance (); advance (); advance (); advance (); Jbool true + | 'f' -> + advance (); advance (); advance (); advance (); advance (); Jbool false + | c -> raise (Json_error (Printf.sprintf "unexpected '%c' at %d" c !pos)) + and parse_string () = + expect '"'; + let buf = Buffer.create 32 in + while peek () <> '"' do + if peek () = '\\' then begin + advance (); + (match peek () with + | '"' -> Buffer.add_char buf '"' + | '\\' -> Buffer.add_char buf '\\' + | 'n' -> Buffer.add_char buf '\n' + | 't' -> Buffer.add_char buf '\t' + | 'r' -> Buffer.add_char buf '\r' + | 'u' -> + advance (); + let hex = String.sub s !pos 4 in + pos := !pos + 3; + let code = int_of_string ("0x" ^ hex) in + if code < 128 then Buffer.add_char buf (Char.chr code) + else begin + (* UTF-8 encode *) + if code < 0x800 then begin + Buffer.add_char buf (Char.chr (0xc0 lor (code lsr 6))); + Buffer.add_char buf (Char.chr (0x80 lor (code land 0x3f))) + end else begin + Buffer.add_char buf (Char.chr (0xe0 lor (code lsr 12))); + Buffer.add_char buf (Char.chr (0x80 lor ((code lsr 6) land 0x3f))); + Buffer.add_char buf (Char.chr (0x80 lor (code land 0x3f))) + end + end + | c -> Buffer.add_char buf c); + advance () + end else begin + Buffer.add_char buf (peek ()); + advance () + end + done; + advance (); (* closing quote *) + Buffer.contents buf + and parse_object () = + expect '{'; + skip_ws (); + if peek () = '}' then (advance (); Jobject []) + else begin + let pairs = ref [] in + let cont = ref true in + while !cont do + skip_ws (); + let key = parse_string () in + expect ':'; + let value = parse_value () in + pairs := (key, value) :: !pairs; + skip_ws (); + if peek () = ',' then advance () + else cont := false + done; + expect '}'; + Jobject (List.rev !pairs) + end + and parse_array () = + expect '['; + skip_ws (); + if peek () = ']' then (advance (); Jarray []) + else begin + let items = ref [] in + let cont = ref true in + while !cont do + let v = parse_value () in + items := v :: !items; + skip_ws (); + if peek () = ',' then advance () + else cont := false + done; + expect ']'; + Jarray (List.rev !items) + end + in + parse_value () + +let switch_of_json j = + match json_to_string (json_get "type" j) with + | "short" -> + let c = json_to_string (json_get "char" j) in + Short (if String.length c > 0 then c.[0] else '?') + | "long" -> Long (json_to_string (json_get "name" j)) + | "both" -> + let c = json_to_string (json_get "char" j) in + Both ((if String.length c > 0 then c.[0] else '?'), + json_to_string (json_get "name" j)) + | _ -> Long "?" + +let param_of_json = function + | Jnull -> None + | j -> + let name = json_to_string (json_get "name" j) in + (match json_to_string (json_get "kind" j) with + | "mandatory" -> Some (Mandatory name) + | "optional" -> Some (Optional name) + | _ -> None) + +let entry_of_json j = + { switch = switch_of_json (json_get "switch" j); + param = param_of_json (json_get "param" j); + desc = json_to_string (json_get "desc" j) } + +let subcommand_of_json j = + { name = json_to_string (json_get "name" j); + desc = json_to_string (json_get "desc" j) } + +let positional_of_json j = + { pos_name = json_to_string (json_get "name" j); + optional = json_to_bool (json_get "optional" j); + variadic = json_to_bool (json_get "variadic" j) } + +let help_result_of_json j = + { entries = List.map entry_of_json (json_to_list (json_get "entries" j)); + subcommands = List.map subcommand_of_json (json_to_list (json_get "subcommands" j)); + positionals = List.map positional_of_json (json_to_list (json_get "positionals" j)) } + +(* --- Database operations --- *) + +let upsert db ?(source="help") command result = + let json = json_of_help_result result in + let now = int_of_float (Unix.gettimeofday ()) in + let stmt = Sqlite3.prepare db + "INSERT INTO completions (command, data, source, updated_at) VALUES (?, ?, ?, ?) + ON CONFLICT(command) DO UPDATE SET data=excluded.data, source=excluded.source, updated_at=excluded.updated_at" in + ignore (Sqlite3.bind stmt 1 (Sqlite3.Data.TEXT command)); + ignore (Sqlite3.bind stmt 2 (Sqlite3.Data.TEXT json)); + ignore (Sqlite3.bind stmt 3 (Sqlite3.Data.TEXT source)); + ignore (Sqlite3.bind stmt 4 (Sqlite3.Data.INT (Int64.of_int now))); + (match Sqlite3.step stmt with + | Sqlite3.Rc.DONE -> () + | rc -> failwith (Printf.sprintf "upsert %s: %s" command (Sqlite3.Rc.to_string rc))); + ignore (Sqlite3.finalize stmt) + +let upsert_raw db ?(source="native") command data = + let now = int_of_float (Unix.gettimeofday ()) in + let stmt = Sqlite3.prepare db + "INSERT INTO completions (command, data, source, updated_at) VALUES (?, ?, ?, ?) + ON CONFLICT(command) DO UPDATE SET data=excluded.data, source=excluded.source, updated_at=excluded.updated_at" in + ignore (Sqlite3.bind stmt 1 (Sqlite3.Data.TEXT command)); + ignore (Sqlite3.bind stmt 2 (Sqlite3.Data.TEXT data)); + ignore (Sqlite3.bind stmt 3 (Sqlite3.Data.TEXT source)); + ignore (Sqlite3.bind stmt 4 (Sqlite3.Data.INT (Int64.of_int now))); + (match Sqlite3.step stmt with + | Sqlite3.Rc.DONE -> () + | rc -> failwith (Printf.sprintf "upsert_raw %s: %s" command (Sqlite3.Rc.to_string rc))); + ignore (Sqlite3.finalize stmt) + +let lookup db command = + let stmt = Sqlite3.prepare db + "SELECT data, source FROM completions WHERE command = ?" in + ignore (Sqlite3.bind stmt 1 (Sqlite3.Data.TEXT command)); + let result = match Sqlite3.step stmt with + | Sqlite3.Rc.ROW -> + let data = Sqlite3.column_text stmt 0 in + let source = Sqlite3.column_text stmt 1 in + Some (data, source) + | _ -> None in + ignore (Sqlite3.finalize stmt); + result + +let lookup_result db command = + match lookup db command with + | None -> None + | Some (data, _source) -> + (try Some (help_result_of_json (parse_json data)) + with _ -> None) + +let has_command db command = + let stmt = Sqlite3.prepare db + "SELECT 1 FROM completions WHERE command = ?" in + ignore (Sqlite3.bind stmt 1 (Sqlite3.Data.TEXT command)); + let found = Sqlite3.step stmt = Sqlite3.Rc.ROW in + ignore (Sqlite3.finalize stmt); + found + +let all_commands db = + let stmt = Sqlite3.prepare db "SELECT command FROM completions ORDER BY command" in + let results = ref [] in + while Sqlite3.step stmt = Sqlite3.Rc.ROW do + results := Sqlite3.column_text stmt 0 :: !results + done; + ignore (Sqlite3.finalize stmt); + List.rev !results + +let delete db command = + let stmt = Sqlite3.prepare db "DELETE FROM completions WHERE command = ?" in + ignore (Sqlite3.bind stmt 1 (Sqlite3.Data.TEXT command)); + ignore (Sqlite3.step stmt); + ignore (Sqlite3.finalize stmt) + +let begin_transaction db = + match Sqlite3.exec db "BEGIN IMMEDIATE" with + | Sqlite3.Rc.OK -> () | _ -> () + +let commit db = + match Sqlite3.exec db "COMMIT" with + | Sqlite3.Rc.OK -> () | _ -> () + +let stats db = + let stmt = Sqlite3.prepare db + "SELECT COUNT(*), COUNT(DISTINCT source) FROM completions" in + let result = match Sqlite3.step stmt with + | Sqlite3.Rc.ROW -> + let count = Sqlite3.column_int stmt 0 in + let sources = Sqlite3.column_int stmt 1 in + (count, sources) + | _ -> (0, 0) in + ignore (Sqlite3.finalize stmt); + result diff --git a/nix/module.nix b/nix/module.nix index 2778e7c..4491dbf 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -41,6 +41,15 @@ in (discovered via XDG_DATA_DIRS). ''; }; + + ignoreCommands = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = []; + example = [ "meat" "problematic-tool" ]; + description = '' + List of command names to skip during completion generation. + ''; + }; }; config = lib.mkIf cfg.enable { @@ -62,6 +71,8 @@ in chmod -R u+w "$_cur" fi fi'') segments; + ignoreFile = pkgs.writeText "inshellah-ignore" (lib.concatStringsSep "\n" cfg.ignoreCommands); + ignoreFlag = lib.optionalString (cfg.ignoreCommands != []) " --ignore ${ignoreFile}"; in '' _cur="$out" @@ -71,7 +82,7 @@ in # Generate all completions in one pass: # native generators > manpages > --help fallback if [ -d "$out/bin" ] && [ -d "$out/share/man" ]; then - ${inshellah} generate "$out/bin" "$out/share/man" -o ${destDir} \ + ${inshellah} generate "$out/bin" "$out/share/man" -o ${destDir}${ignoreFlag} \ 2>/dev/null || true fi