diff --git a/bin/main.ml b/bin/main.ml index ed3a049..fd1e665 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -10,19 +10,17 @@ let usage () = {|inshellah - nushell completions engine Usage: - inshellah index PREFIX... [--db PATH] - Index completions into a SQLite database. + inshellah index PREFIX... [--dir PATH] + Index completions into a directory of JSON/nu files. PREFIX is a directory containing bin/ and share/man/. - Default db: $XDG_CACHE_HOME/inshellah/completions.db - inshellah complete CMD [ARGS...] [--db PATH] + Default dir: $XDG_CACHE_HOME/inshellah + inshellah complete CMD [ARGS...] [--dir PATH] [--system-dir PATH] Nushell custom completer. Outputs JSON completion candidates. - Falls back to --help resolution if command is not in the database. - inshellah query CMD [--db PATH] - Print stored completion data for CMD as JSON. - inshellah clear [CMD...] [--db PATH] - Clear the database, or remove specific commands. - inshellah dump [--db PATH] - Show stats and commands in the database. + Falls back to --help resolution if command is not indexed. + inshellah query CMD [--dir PATH] [--system-dir PATH] + Print stored completion data for CMD. + inshellah dump [--dir PATH] [--system-dir PATH] + List indexed commands. inshellah manpage FILE Parse a manpage and emit nushell extern inshellah manpage-dir DIR Batch-process manpages under DIR @@ -390,9 +388,8 @@ let help_resolve_par ?(timeout=200) cmd rest name = Each child handles one binary completely (including subcommand resolution) and marshals results back via pipe. Children use help_resolve_par which forks per subcommand for parallelism. *) -let cmd_index bindirs mandirs ignorelist db_path = - let db = init db_path in - begin_transaction db; +let cmd_index bindirs mandirs ignorelist dir = + ensure_dir dir; let done_cmds = ref SSet.empty in let n_results = ref 0 in let index_bindir bindir mandir = @@ -418,12 +415,12 @@ let cmd_index bindirs mandirs ignorelist db_path = try Marshal.from_string data 0 with _ -> `None in (match result with | `Native src -> - upsert_raw db ~source:"native" name src; + write_native ~dir 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; + write_result ~dir ~source:"help" cmd_name r; done_cmds := SSet.add cmd_name !done_cmds; incr n_results end @@ -504,7 +501,7 @@ let cmd_index bindirs mandirs ignorelist db_path = | None -> () | Some (cmd, result) -> if not (SSet.mem cmd !done_cmds) then begin - upsert db ~source:"manpage" cmd result; + write_result ~dir ~source:"manpage" cmd result; done_cmds := SSet.add cmd !done_cmds; incr n_results end @@ -513,23 +510,16 @@ let cmd_index bindirs mandirs ignorelist db_path = ) 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 + Printf.printf "indexed %d commands into %s\n" !n_results dir -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 +let cmd_dump dirs = + let cmds = all_commands dirs in + Printf.printf "%d commands\n" (List.length cmds); List.iter (fun cmd -> - match lookup db cmd with - | None -> () - | Some (_data, source) -> - Printf.printf " %-40s [%s]\n" cmd source - ) cmds; - close db + let src = match file_type_of dirs cmd with + | Some s -> s | None -> "?" in + Printf.printf " %-40s [%s]\n" cmd src + ) cmds let find_in_path name = try @@ -543,10 +533,11 @@ let find_in_path name = go dirs with Not_found -> None -let resolve_and_cache db name path = +let resolve_and_cache ~dir name path = let pairs = help_resolve_par ~timeout:200 path [] name in if pairs <> [] then begin - List.iter (fun (cmd_name, r) -> upsert db cmd_name r) pairs; + ensure_dir dir; + List.iter (fun (cmd_name, r) -> write_result ~dir cmd_name r) pairs; Some pairs end else None @@ -580,18 +571,18 @@ let flag_completions prefix entries = ) entries; List.rev !candidates -let cmd_complete spans db_path = +let cmd_complete spans user_dir system_dirs = match spans with | [] -> print_string "[]\n" | cmd_name :: rest -> - let db = init db_path in + let dirs = user_dir :: system_dirs in (* Try longest subcommand match first: "git add" before "git" *) let rec find_result tokens = match tokens with | [] -> None | _ -> let try_name = String.concat " " tokens in - match lookup_result db try_name with + match lookup dirs try_name with | Some r -> Some (try_name, r, List.length tokens) | None -> find_result (List.rev (List.tl (List.rev tokens))) in @@ -607,9 +598,8 @@ let cmd_complete spans db_path = | None -> (match find_in_path cmd_name with | Some path -> - (match resolve_and_cache db cmd_name path with + (match resolve_and_cache ~dir:user_dir cmd_name path with | Some _pairs -> - (* Look up again after caching *) find_result all_tokens | None -> None) | None -> None) in @@ -620,50 +610,25 @@ let cmd_complete spans db_path = | None -> print_string "[]\n" | Some (_matched_name, r, _depth) -> let candidates = ref [] in - (* Flag completions when partial starts with - *) if String.starts_with ~prefix:"-" partial then candidates := flag_completions partial r.entries else begin - (* Subcommand completions *) List.iter (fun (sc : subcommand) -> if partial = "" || String.starts_with ~prefix:partial sc.name then candidates := completion_json sc.name sc.desc :: !candidates ) r.subcommands; candidates := List.rev !candidates; - (* Also offer flags if no subcommand prefix or few subcommand matches *) if partial = "" || !candidates = [] then candidates := !candidates @ flag_completions partial r.entries end; - Printf.printf "[%s]\n" (String.concat "," !candidates)); - close db + Printf.printf "[%s]\n" (String.concat "," !candidates)) -let cmd_query cmd db_path = - let db = init db_path in - (match lookup db cmd with - | None -> - Printf.eprintf "not found: %s\n" cmd; close db; exit 1 - | Some (data, source) -> - Printf.printf "# source: %s\n%s\n" source data); - close db - -let cmd_clear cmds db_path = - let db = init db_path in - (match cmds with - | [] -> - (match Sqlite3.exec db "DELETE FROM completions" with - | Sqlite3.Rc.OK -> - Printf.printf "cleared all commands from %s\n" db_path - | rc -> - Printf.eprintf "error: %s\n" (Sqlite3.Rc.to_string rc); exit 1) - | _ -> - List.iter (fun cmd -> - if has_command db cmd then begin - delete db cmd; - Printf.printf "removed %s\n" cmd - end else - Printf.eprintf "not found: %s\n" cmd - ) cmds); - close db +let cmd_query cmd dirs = + match lookup_raw dirs cmd with + | None -> + Printf.eprintf "not found: %s\n" cmd; exit 1 + | Some data -> + print_string data; print_newline () let load_ignorelist path = try @@ -679,47 +644,40 @@ let load_ignorelist path = 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 rec go prefixes dir ignore = function + | [] -> (List.rev prefixes, dir, ignore) + | "--dir" :: path :: rest -> go prefixes path ignore rest + | "--ignore" :: path :: rest -> go prefixes dir (SSet.union ignore (load_ignorelist path)) rest + | prefix :: rest -> go (prefix :: prefixes) dir ignore rest in + go [] (default_store_path ()) SSet.empty args + +let parse_dir_args args = + let rec go user_dir system_dirs rest_args = function + | [] -> (user_dir, system_dirs, List.rev rest_args) + | "--dir" :: path :: rest -> go path system_dirs rest_args rest + | "--system-dir" :: path :: rest -> go user_dir (path :: system_dirs) rest_args rest + | arg :: rest -> go user_dir system_dirs (arg :: rest_args) rest in + go (default_store_path ()) [] [] args let () = match Array.to_list Sys.argv |> List.tl with | "index" :: rest -> - let (prefixes, db_path, ignorelist) = parse_index_args rest in + let (prefixes, dir, 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 + cmd_index bindirs mandirs ignorelist dir | "complete" :: rest -> - let rec parse_complete_args spans db = function - | [] -> (List.rev spans, db) - | "--db" :: path :: rest -> parse_complete_args spans path rest - | arg :: rest -> parse_complete_args (arg :: spans) db rest in - let (spans, db_path) = parse_complete_args [] (default_db_path ()) rest in - cmd_complete spans db_path + let (user_dir, system_dirs, spans) = parse_dir_args rest in + cmd_complete spans user_dir system_dirs | "query" :: rest -> - let (cmd, db_path) = match rest with - | [cmd] -> (cmd, default_db_path ()) - | [cmd; "--db"; path] -> (cmd, path) - | _ -> Printf.eprintf "error: query CMD [--db PATH]\n"; exit 1 in - cmd_query cmd db_path - | "clear" :: rest -> - let rec parse_clear_args cmds db = function - | [] -> (List.rev cmds, db) - | "--db" :: path :: rest -> parse_clear_args cmds path rest - | cmd :: rest -> parse_clear_args (cmd :: cmds) db rest in - let (cmds, db_path) = parse_clear_args [] (default_db_path ()) rest in - cmd_clear cmds db_path + let (user_dir, system_dirs, args) = parse_dir_args rest in + (match args with + | [cmd] -> cmd_query cmd (user_dir :: system_dirs) + | _ -> Printf.eprintf "error: query CMD [--dir PATH] [--system-dir PATH]\n"; exit 1) | "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 + let (user_dir, system_dirs, _) = parse_dir_args rest in + cmd_dump (user_dir :: system_dirs) | ["manpage"; file] -> cmd_manpage file | ["manpage-dir"; dir] -> cmd_manpage_dir dir | _ -> usage () diff --git a/flake.nix b/flake.nix index 8636276..90a94f9 100644 --- a/flake.nix +++ b/flake.nix @@ -20,7 +20,6 @@ angstrom angstrom-unix camlzip - ocaml_sqlite3 ppx_inline_test ocaml-lsp ocamlformat @@ -44,7 +43,6 @@ angstrom angstrom-unix camlzip - ocaml_sqlite3 ]; meta.mainProgram = "inshellah"; diff --git a/lib/dune b/lib/dune index 338f98a..38defe1 100644 --- a/lib/dune +++ b/lib/dune @@ -1,3 +1,3 @@ (library (name inshellah) - (libraries angstrom angstrom-unix camlzip sqlite3 str unix)) + (libraries angstrom angstrom-unix camlzip str unix)) diff --git a/lib/store.ml b/lib/store.ml index b340192..467b798 100644 --- a/lib/store.ml +++ b/lib/store.ml @@ -1,35 +1,24 @@ open Parser -let default_db_path () = +let default_store_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" + Filename.concat cache "inshellah" -let ensure_parent path = - let dir = Filename.dirname path in +let ensure_dir dir = 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 filename_of_command cmd = + String.map (function + | ' ' -> '_' + | ('a'..'z' | 'A'..'Z' | '0'..'9' | '-' | '_' | '.') as c -> c + | _ -> '-') cmd -let close db = ignore (Sqlite3.db_close db) +let command_of_filename base = + String.map (function '_' -> ' ' | c -> c) base (* --- JSON serialization of help_result --- *) @@ -78,8 +67,9 @@ let json_positional_of p = 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}" +let json_of_help_result ?(source="help") r = + Printf.sprintf "{\"source\":%s,\"entries\":%s,\"subcommands\":%s,\"positionals\":%s}" + (json_string source) (json_list json_entry_of r.entries) (json_list json_subcommand_of r.subcommands) (json_list json_positional_of r.positionals) @@ -249,96 +239,115 @@ let help_result_of_json 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 --- *) +(* --- Filesystem 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 write_file path contents = + let oc = open_out path in + output_string oc contents; + close_out oc -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 read_file path = + try + let ic = open_in path in + let n = in_channel_length ic in + let s = Bytes.create n in + really_input ic s 0 n; + close_in ic; + Some (Bytes.to_string s) + with _ -> None -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 write_result ~dir ?(source="help") command result = + let path = Filename.concat dir (filename_of_command command ^ ".json") in + write_file path (json_of_help_result ~source 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 write_native ~dir command data = + let path = Filename.concat dir (filename_of_command command ^ ".nu") in + write_file path data -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 find_file dirs command = + let base = filename_of_command command in + let rec go = function + | [] -> None + | dir :: rest -> + let json_path = Filename.concat dir (base ^ ".json") in + if Sys.file_exists json_path then Some json_path + else + let nu_path = Filename.concat dir (base ^ ".nu") in + if Sys.file_exists nu_path then Some nu_path + else go rest in + go dirs -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 lookup dirs command = + let base = filename_of_command command in + let rec go = function + | [] -> None + | dir :: rest -> + let path = Filename.concat dir (base ^ ".json") in + (match read_file path with + | Some data -> + (try Some (help_result_of_json (parse_json data)) + with _ -> None) + | None -> go rest) in + go dirs -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 lookup_raw dirs command = + let base = filename_of_command command in + let rec go = function + | [] -> None + | dir :: rest -> + let json_path = Filename.concat dir (base ^ ".json") in + (match read_file json_path with + | Some _ as r -> r + | None -> + let nu_path = Filename.concat dir (base ^ ".nu") in + match read_file nu_path with + | Some _ as r -> r + | None -> go rest) in + go dirs -let begin_transaction db = - match Sqlite3.exec db "BEGIN IMMEDIATE" with - | Sqlite3.Rc.OK -> () | _ -> () +let has_command dirs command = + find_file dirs command <> None -let commit db = - match Sqlite3.exec db "COMMIT" with - | Sqlite3.Rc.OK -> () | _ -> () +let all_commands dirs = + let module SSet = Set.Make(String) in + let cmds = ref SSet.empty in + List.iter (fun dir -> + if Sys.file_exists dir && Sys.is_directory dir then + Array.iter (fun f -> + let base = + if Filename.check_suffix f ".json" then + Some (Filename.chop_suffix f ".json") + else if Filename.check_suffix f ".nu" then + Some (Filename.chop_suffix f ".nu") + else None in + match base with + | Some b -> cmds := SSet.add (command_of_filename b) !cmds + | None -> () + ) (Sys.readdir dir) + ) dirs; + SSet.elements !cmds -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 +let delete ~dir command = + let base = filename_of_command command in + let json_path = Filename.concat dir (base ^ ".json") in + let nu_path = Filename.concat dir (base ^ ".nu") in + (try Sys.remove json_path with Sys_error _ -> ()); + (try Sys.remove nu_path with Sys_error _ -> ()) + +let file_type_of dirs command = + let base = filename_of_command command in + let rec go = function + | [] -> None + | dir :: rest -> + let json_path = Filename.concat dir (base ^ ".json") in + if Sys.file_exists json_path then + (match read_file json_path with + | Some data -> + (try Some (json_to_string (json_get "source" (parse_json data))) + with _ -> Some "json") + | None -> Some "json") + else + let nu_path = Filename.concat dir (base ^ ".nu") in + if Sys.file_exists nu_path then Some "native" + else go rest in + go dirs diff --git a/nix/module.nix b/nix/module.nix index 4491dbf..8072a92 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -1,11 +1,12 @@ -# NixOS module: automatic nushell completion generation +# NixOS module: automatic nushell completion indexing # -# Generates completions using three strategies in priority order: +# Indexes completions using three strategies in priority order: # 1. Native completion generators (e.g. CMD completions nushell) # 2. Manpage parsing # 3. --help output parsing # -# Runs as a single pass during the system profile build. +# Produces a directory of .json/.nu files at build time. +# The `complete` command reads from this directory as a system overlay. # # Usage: # { pkgs, ... }: { @@ -25,20 +26,19 @@ let in { options.programs.inshellah = { - enable = lib.mkEnableOption "nushell completion generation via inshellah"; + enable = lib.mkEnableOption "nushell completion indexing via inshellah"; package = lib.mkOption { type = lib.types.package; - description = "The inshellah package to use for generating completions."; + description = "The inshellah package to use for indexing completions."; }; - generatedCompletionsPath = lib.mkOption { + completionsPath = lib.mkOption { type = lib.types.str; - default = "/share/nushell/vendor/autoload"; + default = "/share/inshellah"; description = '' Subdirectory within the merged environment where completion files - are placed. The default matches nushell's vendor autoload convention - (discovered via XDG_DATA_DIRS). + are placed. Used as the system-dir for the completer. ''; }; @@ -47,19 +47,19 @@ in default = []; example = [ "meat" "problematic-tool" ]; description = '' - List of command names to skip during completion generation. + List of command names to skip during completion indexing. ''; }; }; config = lib.mkIf cfg.enable { - environment.pathsToLink = [ cfg.generatedCompletionsPath ]; + environment.pathsToLink = [ cfg.completionsPath ]; environment.extraSetup = let inshellah = "${cfg.package}/bin/inshellah"; - destDir = "$out${cfg.generatedCompletionsPath}"; - segments = lib.filter (s: s != "") (lib.splitString "/" cfg.generatedCompletionsPath); + destDir = "$out${cfg.completionsPath}"; + segments = lib.filter (s: s != "") (lib.splitString "/" cfg.completionsPath); derefPath = lib.concatMapStringsSep "\n " (seg: '' _cur="$_cur/${seg}" if [ -L "$_cur" ]; then @@ -79,10 +79,10 @@ in ${derefPath} mkdir -p ${destDir} - # Generate all completions in one pass: + # Index 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}${ignoreFlag} \ + ${inshellah} index "$out" --dir ${destDir}${ignoreFlag} \ 2>/dev/null || true fi