SAVE GAME
This commit is contained in:
parent
5de8e62e66
commit
1d0d3465c1
7 changed files with 641 additions and 10 deletions
236
bin/main.ml
236
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
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
angstrom
|
||||
angstrom-unix
|
||||
camlzip
|
||||
sqlite3
|
||||
(ppx_inline_test :with-test))
|
||||
(tags
|
||||
(shell completions nushell parser angstrom)))
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
2
lib/dune
2
lib/dune
|
|
@ -1,3 +1,3 @@
|
|||
(library
|
||||
(name inshellah)
|
||||
(libraries angstrom angstrom-unix camlzip str unix))
|
||||
(libraries angstrom angstrom-unix camlzip sqlite3 str unix))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
344
lib/store.ml
Normal file
344
lib/store.ml
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue