//! inshellah CLI. //! //! subcommands: //! index PREFIX... scan PREFIX/bin and PREFIX/share/man, write JSON cache //! manpage FILE parse a single manpage, emit nushell extern //! manpage-dir DIR batch-process manpages under DIR //! complete CMD ARG... nushell external completer; reads the cache, //! falls back to on-the-fly --help if uncached //! query CMD print stored data for CMD //! dump list indexed commands //! completions emit nushell completion definitions for inshellah itself use std::collections::HashSet; use std::fs; use std::io::Read; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::Arc; use std::time::{Duration, Instant}; use parking_lot::Mutex; use inshellah::parsers::help::help_parser; use inshellah::parsers::manpage::{ ManpageEntry, ManpageResult, ManpageSubcommand, OwnedParam, OwnedSwitch, extract_synopsis_command, parse_manpage_string, parse_manpage_with_subs, read_manpage_file, }; use inshellah::parsers::nushell::{generate_extern, generate_module, is_nushell_builtin}; use inshellah::pool::{ScrapePool, Submitter}; use inshellah::store::{ all_commands, default_store_path, ensure_dir, file_type_of, filename_of_command, lookup, lookup_raw, parse_nu_completions, subcommands_of, write_native, write_result, }; const COMMAND_SECTIONS: &[u8] = &[1, 8]; /// per-subprocess timeout default when --timeout-ms isn't passed. /// empirically tuned so that a slow-to-print binary doesn't block the /// pool, while fast-responding ones (the vast majority) print their /// --help well inside the window. with `n` parallel workers a 200ms /// ceiling means the worst-case waste from an unresponsive binary is /// `200ms / n_workers` of wall time. const DEFAULT_TIMEOUT_MS: u64 = 200; fn usage() { eprintln!( "inshellah - nushell completions engine Usage: inshellah index PREFIX... [--dir PATH] [--ignore FILE] [--help-only FILE] [--timeout-ms N] [--workers N] Index completions into a directory of JSON/nu files. PREFIX is a directory containing bin/ and share/man/. Default dir: $XDG_CACHE_HOME/inshellah --ignore FILE skip listed commands entirely --help-only FILE skip manpages for listed commands, use --help instead --timeout-ms N per-subprocess timeout in milliseconds (default 200) --workers N parallel scrape workers (default: cpu count) inshellah complete CMD [ARGS...] [--dir PATH[:PATH...]] [--timeout-ms N] Nushell custom completer. Outputs JSON completion candidates. Falls back to --help resolution if command is not indexed. --dir takes colon-separated paths. The first path is the writable user cache; additional paths are read-only system directories. inshellah query CMD [--dir PATH[:PATH...]] Print stored completion data for CMD. inshellah dump [--dir PATH[:PATH...]] List indexed commands. inshellah manpage FILE Parse a manpage and emit nushell extern inshellah manpage-dir DIR Batch-process manpages under DIR inshellah completions Generate nushell completions for inshellah " ); } // --- subprocess management --- /// sanitized env: strip display-related variables to prevent gui tools from /// popping up windows when run with --help. cached once per process — /// `vars_os` walks the whole env every call, which adds up across thousands /// of spawns. fn safe_env_vars() -> &'static [(std::ffi::OsString, std::ffi::OsString)] { static CACHE: std::sync::OnceLock> = std::sync::OnceLock::new(); CACHE.get_or_init(|| { std::env::vars_os() .filter(|(k, _)| { let s = k.to_string_lossy(); !(s == "DISPLAY" || s == "WAYLAND_DISPLAY" || s == "DBUS_SESSION_BUS_ADDRESS" || s == "XAUTHORITY") }) .collect() }) } /// run a command with a timeout, capturing stdout+stderr merged. /// returns None if the process couldn't be started, produced no output, /// or was killed due to timeout. /// /// uses `poll(2)` on the pipe fds directly from the calling thread — no /// reader threads, no try_wait polling loop. we block in the kernel for /// either data (POLLIN), peer-close (POLLHUP), or the timeout deadline, /// so the cost per subprocess is roughly one syscall per data chunk /// plus the spawn itself. /// /// unix process groups still apply: the child is its own pgid leader, so /// on timeout we killpg(pgid, SIGKILL) and the whole tree (wrapper /// scripts, forked grandchildren) dies, closing the pipe writers and /// letting our reads finish cleanly. fn run_cmd(args: &[String], timeout_ms: u64) -> Option { use std::io::Read; use std::os::fd::AsRawFd; use std::os::unix::process::CommandExt; if args.is_empty() { return None; } let mut cmd = Command::new(&args[0]); cmd.args(&args[1..]); cmd.stdin(Stdio::null()); cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); cmd.env_clear(); for (k, v) in safe_env_vars() { cmd.env(k, v); } cmd.current_dir("/tmp"); cmd.process_group(0); let mut child = cmd.spawn().ok()?; let pgid = child.id() as i32; let mut stdout = child.stdout.take()?; let mut stderr = child.stderr.take()?; let stdout_fd = stdout.as_raw_fd(); let stderr_fd = stderr.as_raw_fd(); // both pipe fds must be non-blocking so poll-then-read can drain // everything available without blocking on the next chunk. unsafe { for fd in [stdout_fd, stderr_fd] { let flags = libc::fcntl(fd, libc::F_GETFL); libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK); } } let deadline = Instant::now() + Duration::from_millis(timeout_ms); let mut buf: Vec = Vec::with_capacity(4096); let mut chunk = [0u8; 4096]; let mut stdout_open = true; let mut stderr_open = true; let mut timed_out = false; while stdout_open || stderr_open { let now = Instant::now(); if now >= deadline { timed_out = true; break; } let remaining_ms = (deadline - now).as_millis().min(i32::MAX as u128) as i32; let mut fds = [ libc::pollfd { fd: if stdout_open { stdout_fd } else { -1 }, events: libc::POLLIN, revents: 0, }, libc::pollfd { fd: if stderr_open { stderr_fd } else { -1 }, events: libc::POLLIN, revents: 0, }, ]; let n = unsafe { libc::poll(fds.as_mut_ptr(), fds.len() as libc::nfds_t, remaining_ms) }; if n < 0 { // EINTR — retry. anything else: bail and let the child reap below. if std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted { continue; } break; } if n == 0 { // poll itself returned without events — deadline check at top // of next iter will catch it. continue; } // drain whichever fds are ready until EAGAIN or EOF. for (i, pfd) in fds.iter().enumerate() { if pfd.revents == 0 { continue; } let (reader, open): (&mut dyn Read, &mut bool) = if i == 0 { (&mut stdout as &mut dyn Read, &mut stdout_open) } else { (&mut stderr as &mut dyn Read, &mut stderr_open) }; loop { match reader.read(&mut chunk) { Ok(0) => { *open = false; break; } Ok(read) => buf.extend_from_slice(&chunk[..read]), Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break, Err(_) => { *open = false; break; } } } if pfd.revents & (libc::POLLHUP | libc::POLLERR) != 0 { *open = false; } } } if timed_out { unsafe { libc::killpg(pgid, libc::SIGKILL); } } let _ = child.wait(); if buf.is_empty() { None } else { Some(String::from_utf8_lossy(&buf).into_owned()) } } // --- file classification --- fn is_executable(path: &Path) -> bool { use std::os::unix::fs::PermissionsExt; fs::metadata(path) .map(|m| m.is_file() && (m.permissions().mode() & 0o111) != 0) .unwrap_or(false) } fn is_script(path: &Path) -> bool { let real = match fs::canonicalize(path) { Ok(p) => p, Err(_) => return false, }; let Ok(mut f) = fs::File::open(&real) else { return false; }; let mut buf = [0u8; 2]; f.read_exact(&mut buf) .map(|_| &buf == b"#!") .unwrap_or(false) } /// skip filenames that aren't real commands (e.g. doc/locale paths). fn skip_name(name: &str) -> bool { name.starts_with('.') || name.ends_with(".so") || name.ends_with(".a") || name.ends_with(".la") || name.contains('/') } // --- ELF scanning --- /// scan an ELF binary (or any file) for string needles. returns the set of /// needles that appeared. on read failure all needles are reported found /// (conservative — we'd rather try --help than skip). fn elf_scan(path: &Path, needles: &[&str]) -> HashSet { let mut found: HashSet = HashSet::new(); let real = match fs::canonicalize(path) { Ok(p) => p, Err(_) => { for n in needles { found.insert((*n).to_string()); } return found; } }; let Ok(mut f) = fs::File::open(&real) else { for n in needles { found.insert((*n).to_string()); } return found; }; let mut magic = [0u8; 4]; if f.read_exact(&mut magic).is_err() { return found; } if magic != [0x7f, b'E', b'L', b'F'] { // not ELF — return empty so caller decides return found; } let max_needle = needles.iter().map(|s| s.len()).max().unwrap_or(0); let chunk_size = 65536usize; let mut buf = vec![0u8; chunk_size + max_needle]; let mut carry = 0usize; let needles_b: Vec<&[u8]> = needles.iter().map(|s| s.as_bytes()).collect(); loop { let n: usize = f .read(&mut buf[carry..carry + chunk_size]) .unwrap_or_default(); if n == 0 { break; } let total = carry + n; for (i, needle) in needles_b.iter().enumerate() { let key = needles[i]; if found.contains(key) { continue; } if needle.len() > total { continue; } let win = &buf[..total]; if win.windows(needle.len()).any(|w| w == *needle) { found.insert(key.to_string()); } } if found.len() == needles.len() { break; } let new_carry = max_needle.min(total); buf.copy_within(total - new_carry..total, 0); carry = new_carry; } found } // --- nix wrapper detection --- fn read_to_string_capped(path: &Path, cap: usize) -> Option { let real = fs::canonicalize(path).ok()?; let md = fs::metadata(&real).ok()?; if md.len() as usize > cap { return None; } fs::read_to_string(&real).ok() } /// detect nix-generated c wrappers; return the real binary path. fn nix_wrapper_target(path: &Path) -> Option { let contents = read_to_string_capped(path, 65536)?; if !contents.contains("makeCWrapper") { return None; } // pattern: /nix/store/-/bin/ extract_nix_bin_path(&contents) } /// detect nix-generated bash/sh wrappers. fn nix_script_wrapper_target(path: &Path) -> Option { let contents = read_to_string_capped(path, 4096)?; if !contents.starts_with("#!") { return None; } if !contents.contains("/nix/store/") { return None; } if !(contents.contains("exec ") || contents.contains("exec\t")) { return None; } extract_nix_bin_path(&contents) } fn extract_nix_bin_path(contents: &str) -> Option { let needle = "/nix/store/"; let bytes = contents.as_bytes(); let mut idx = 0; while let Some(rel) = contents[idx..].find(needle) { let start = idx + rel; // find end of the path (whitespace, quote, or null) let mut end = start + needle.len(); while end < bytes.len() { let b = bytes[end]; if b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' || b == b'"' || b == b'\'' || b == 0 { break; } end += 1; } let candidate = &contents[start..end]; if candidate.contains("/bin/") { let path = PathBuf::from(candidate); if path.exists() { return Some(path); } } idx = end; } None } // --- binary classification --- #[derive(Debug, Clone, PartialEq, Eq)] enum Classify { /// can try --help TryHelp, /// the tool likely speaks the "nushell" completion subcommand HasNativeCompletions, /// skip — doesn't look like a CLI we can extract from Skip, } /// classify an ELF binary by scanning for help/completion needles. fn classify_elf(path: &Path) -> Classify { let found = elf_scan(path, &["-h", "--help", "complet"]); if found.contains("complet") { Classify::HasNativeCompletions } else if found.contains("-h") || found.contains("--help") { Classify::TryHelp } else { Classify::Skip } } /// classify a binary by its actual nature: script, ELF, or nix wrapper. fn classify_binary(_bindir: &Path, full: &Path) -> Classify { if is_script(full) { return Classify::TryHelp; } if let Some(target) = nix_wrapper_target(full) { return classify_elf(&target); } if let Some(target) = nix_script_wrapper_target(full) { return classify_elf(&target); } classify_elf(full) } // --- help text extraction --- /// try `--help`, then `-h`, returning the first non-empty output (with /// ANSI escapes stripped). each attempt gets the same per-call timeout. /// we deliberately skip the third historical `help`-subcommand variant: /// if neither flag yielded usable text, a positional `help` is unlikely /// to do anything different and the extra spawn dominates indexing cost. fn try_help(bin: &Path, timeout_ms: u64) -> Option { let bin_s = bin.to_string_lossy().to_string(); for variant in [&["--help"][..], &["-h"][..]] { let mut args = vec![bin_s.clone()]; args.extend(variant.iter().map(|s| s.to_string())); if let Some(out) = run_cmd(&args, timeout_ms) { let cleaned = fast_strip_ansi::strip_ansi_string(&out); if !cleaned.trim().is_empty() { return Some(cleaned.to_string()); } } } None } fn is_nushell_source(text: &str) -> bool { text.len() > 20 && (text.contains("export extern") || text.contains("export def") || (text.contains("module ") && text.contains("export"))) } /// look for words that contain a known needle within the text (used to /// find subcommand names that might be a native-completion command). fn extract_matching_words(text: &str, needles: &[&str]) -> Vec { let mut out: Vec = Vec::new(); let mut seen: HashSet = HashSet::new(); for token in text.split(|c: char| c.is_whitespace() || c == ',' || c == '|') { let word = token.trim_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != '_'); if word.len() < 2 || word.starts_with('-') { continue; } for needle in needles { if word.contains(needle) && !seen.contains(word) { seen.insert(word.to_string()); out.push(word.to_string()); break; } } } out } /// try to get native nushell completions from a binary that supports them. fn try_native_completion(bin: &Path, timeout_ms: u64) -> Option { let help_text = try_help(bin, timeout_ms)?; // look for words like "completion", "completions" — typical subcommand let candidates = extract_matching_words(&help_text, &["complet"]); let bin_s = bin.to_string_lossy().to_string(); for sub in &candidates { for args_form in [ vec![bin_s.clone(), sub.clone(), "nushell".to_string()], vec![ bin_s.clone(), sub.clone(), "--shell".to_string(), "nushell".to_string(), ], vec![bin_s.clone(), sub.clone(), "--shell=nushell".to_string()], ] { if let Some(out) = run_cmd(&args_form, timeout_ms) { let cleaned = fast_strip_ansi::strip_ansi_string(&out); if is_nushell_source(&cleaned) { return Some(cleaned.to_string()); } } } } None } // --- subcommand recursion --- const MAX_RESOLVE_RESULTS: usize = 500; const MAX_RECURSE_DEPTH: u32 = 5; fn parse_help_text(text: &str) -> ManpageResult { let cleaned: String = fast_strip_ansi::strip_ansi_string(text).into_owned(); match help_parser(&cleaned) { Ok((_, r)) => (&r).into(), Err(_) => ManpageResult::default(), } } /// recursively resolve subcommands, returning a vec of (cmd_path, result) /// where cmd_path is the full "git stash apply" form. used by the /// dynamic-resolve path in `cmd_complete`; the batch indexer uses the /// pool instead, which expresses this same BFS shape with workers. fn help_resolve( bin: &Path, cmd: &str, depth: u32, timeout_ms: u64, acc: &mut Vec<(String, ManpageResult)>, ) { if acc.len() >= MAX_RESOLVE_RESULTS { return; } let Some(help_text) = try_help(bin, timeout_ms) else { return; }; let result = parse_help_text(&help_text); acc.push((cmd.to_string(), result)); let initial_subs: Vec = acc .last() .map(|(_, r)| { r.subcommands .iter() .map(|sc| sc.name.clone()) .filter(|n| n.len() >= 2 && !n.starts_with('-')) .collect() }) .unwrap_or_default(); let bin_s = bin.to_string_lossy().to_string(); for sub in initial_subs { recurse_subcommand( &bin_s, cmd, std::slice::from_ref(&sub), depth + 1, timeout_ms, acc, ); } } fn recurse_subcommand( bin_s: &str, base_cmd: &str, sub_args: &[String], depth: u32, timeout_ms: u64, acc: &mut Vec<(String, ManpageResult)>, ) { if acc.len() >= MAX_RESOLVE_RESULTS || depth > MAX_RECURSE_DEPTH { return; } let full_cmd = format!("{base_cmd} {}", sub_args.join(" ")); let Some(text) = try_help_args(bin_s, sub_args, timeout_ms) else { return; }; let result = parse_help_text(&text); if result.entries.is_empty() && result.subcommands.is_empty() && result.positionals.is_empty() { return; } if let Some(leaf) = sub_args.last() { let self_listed = result .subcommands .iter() .any(|sc| sc.name.eq_ignore_ascii_case(leaf)); if self_listed { return; } } let inner_subs: Vec = result .subcommands .iter() .map(|sc| sc.name.clone()) .filter(|n| n.len() >= 2 && !n.starts_with('-') && n != "help") .collect(); acc.push((full_cmd, result)); for sub in inner_subs { if acc.len() >= MAX_RESOLVE_RESULTS { break; } let mut next = sub_args.to_vec(); next.push(sub); recurse_subcommand(bin_s, base_cmd, &next, depth + 1, timeout_ms, acc); } } /// try `bin sub_path... --help` first, then `... -h` if --help came back /// empty or "No manual entry…". used by deep subcommand recursion. fn try_help_args(bin_s: &str, sub_args: &[String], timeout_ms: u64) -> Option { let mut primary_args: Vec = vec![bin_s.to_string()]; primary_args.extend(sub_args.iter().cloned()); primary_args.push("--help".to_string()); let primary = run_cmd(&primary_args, timeout_ms); let primary_text = primary .as_deref() .map(|s| fast_strip_ansi::strip_ansi_string(s).into_owned()); let primary_useful = primary_text .as_ref() .map(|t| { let trimmed = t.trim(); !trimmed.is_empty() && !trimmed.starts_with("No manual entry") && !trimmed.starts_with("man:") }) .unwrap_or(false); if primary_useful { return primary_text; } let mut fallback_args: Vec = vec![bin_s.to_string()]; fallback_args.extend(sub_args.iter().cloned()); fallback_args.push("-h".to_string()); if let Some(out) = run_cmd(&fallback_args, timeout_ms) { let cleaned = fast_strip_ansi::strip_ansi_string(&out).into_owned(); if !cleaned.trim().is_empty() { return Some(cleaned); } } primary_text } // --- manpage handling --- fn cmd_name_of_manpage(path: &Path) -> String { let mut base = path .file_name() .and_then(|s| s.to_str()) .unwrap_or("") .to_string(); if base.ends_with(".gz") { base.truncate(base.len() - 3); } // strip section suffix: "ls.1" -> "ls" if let Some(dot) = base.rfind('.') { base.truncate(dot); } base } fn find_manpage_path(mandirs: &[PathBuf], hyphenated: &str) -> Option { for mandir in mandirs { for section in COMMAND_SECTIONS { let secdir = mandir.join(format!("man{section}")); for ext in ["", ".gz"] { let path = secdir.join(format!("{hyphenated}.{section}{ext}")); if path.is_file() { return Some(path); } } } } None } /// derive the command name a manpage documents. the SYNOPSIS section /// is authoritative because manpage filenames are ambiguous — /// "btrfs-check.8" could mean either a standalone binary `btrfs-check` /// or the subcommand `btrfs check`. we clamp to the number of /// hyphen-separated parts in the filename to prevent synopsis lines /// like "btrfs check [options] " from absorbing the device /// placeholder into the command name. fn resolve_manpage_cmd_name(file: &Path, contents: &str) -> String { let fallback = cmd_name_of_manpage(file); let max_words = fallback.matches('-').count() + 1; match extract_synopsis_command(contents) { Some(name) => { let words: Vec<&str> = name.split(' ').filter(|w| !w.is_empty()).collect(); if words.len() > max_words { words[..max_words].join(" ") } else { name } } None => fallback, } } type NamedManpageResult = (String, ManpageResult); type ProcessedManpage = (String, ManpageResult, Vec); /// process a manpage and return (cmd_name, main_result, per-subcommand results). /// the sub_results come from clap-style `.SH SUBCOMMAND` sections — each is /// a self-contained command with its own flags. fn process_manpage(file: &Path) -> Option { let contents = read_manpage_file(file).ok()?; let (mut result, sub_sections) = parse_manpage_with_subs(&contents); if result.entries.is_empty() && result.subcommands.is_empty() && sub_sections.is_empty() { return None; } let name = resolve_manpage_cmd_name(file, &contents); if name.is_empty() { return None; } strip_manpage_subcmd_prefixes(&mut result, file, &name); // namespace the sub-section names under the resolved cmd name: // e.g. nh's SUBCOMMAND "os" becomes the stored command "nh os". let subs: Vec<(String, ManpageResult)> = sub_sections .into_iter() .map(|(sub_name, sub_result)| (format!("{name} {sub_name}"), sub_result)) .collect(); Some((name, result, subs)) } fn list_manpages(mandirs: &[PathBuf]) -> Vec { let mut out = Vec::new(); for mandir in mandirs { for section in COMMAND_SECTIONS { let secdir = mandir.join(format!("man{section}")); if let Ok(entries) = fs::read_dir(&secdir) { for entry in entries.flatten() { out.push(entry.path()); } } } } out } // --- index command --- fn load_ignorelist(path: &Path) -> HashSet { let mut out = HashSet::new(); if let Ok(contents) = fs::read_to_string(path) { for line in contents.lines() { let line = line.trim(); if !line.is_empty() && !line.starts_with('#') { out.insert(line.to_string()); } } } out } fn list_binaries(bindirs: &[PathBuf]) -> Vec<(String, PathBuf)> { let mut all: Vec<(String, PathBuf)> = Vec::new(); let mut seen: HashSet = HashSet::new(); for bd in bindirs { let Ok(entries) = fs::read_dir(bd) else { continue; }; for entry in entries.flatten() { let path = entry.path(); let Some(name) = path.file_name().and_then(|s| s.to_str()) else { continue; }; if skip_name(name) || is_nushell_builtin(name) { continue; } if !is_executable(&path) { continue; } if seen.insert(name.to_string()) { all.push((name.to_string(), path)); } } } all.sort_by(|a, b| a.0.cmp(&b.0)); all } /// shared state passed to every pool worker. nothing inside mutates /// except `indexed`, which is wrapped in a parking_lot::Mutex. struct ScrapeCtx { cache_dir: PathBuf, mandirs: Vec, help_only: HashSet, indexed: Mutex>, timeout_ms: u64, } #[derive(Debug)] struct PoolJob { bin_path: PathBuf, /// the binary's basename — e.g. "git". stays constant across the /// whole recursion tree for this binary. base_cmd: String, /// chain of subcommand tokens past the base. empty for the /// top-level scrape, ["clone"] for `git clone`, ["stash","apply"] /// for `git stash apply`. sub_args: Vec, depth: u32, } impl PoolJob { fn full_cmd(&self) -> String { if self.sub_args.is_empty() { self.base_cmd.clone() } else { format!("{} {}", self.base_cmd, self.sub_args.join(" ")) } } } /// hyphenated form used to look up a manpage for a (possibly nested) /// command — "git" for top-level, "git-remote" for `git remote`, /// "git-stash-apply" for `git stash apply`. fn hyphenated_cmd(job: &PoolJob) -> String { if job.sub_args.is_empty() { job.base_cmd.clone() } else { format!("{}-{}", job.base_cmd, job.sub_args.join("-")) } } /// some manpages list subcommands with the parent's name as a prefix — /// git.1 has \fBgit-add\fR(1), \fBgit-remote-ext\fR(1), etc. downstream /// expects bare subcommand names ("add", "remote-ext") so they dispatch /// as `git add` / `git remote-ext`. strips a leading "{base}-" wherever /// present; a no-op when the manpage already uses bare names. fn strip_subcmd_prefix(result: &mut ManpageResult, base: &str) { let prefix = format!("{base}-"); for sc in &mut result.subcommands { if let Some(rest) = sc.name.strip_prefix(&prefix) { sc.name = rest.to_string(); } } } fn strip_manpage_subcmd_prefixes(result: &mut ManpageResult, file: &Path, cmd_name: &str) { let filename_base = cmd_name_of_manpage(file); if !filename_base.is_empty() { strip_subcmd_prefix(result, &filename_base); } let hyphenated_cmd = cmd_name.replace(' ', "-"); if !hyphenated_cmd.is_empty() && hyphenated_cmd != filename_base { strip_subcmd_prefix(result, &hyphenated_cmd); } } /// enqueue child jobs for each discovered subcommand. shared between the /// manpage and help branches of process_pool_job. fn enqueue_subcommands( job: &PoolJob, subcommands: &[ManpageSubcommand], submit: &Submitter, ) { // matches the sequential recurse_subcommand depth check (`depth > MAX`), // not `>=`, so we get 6 levels (0..=5) of recursion. without this we // were cutting off the last layer of deep clap trees like jay. if job.depth > MAX_RECURSE_DEPTH { return; } for sc in subcommands { if sc.name.len() < 2 || sc.name.starts_with('-') || sc.name == "help" { continue; } let mut next = job.sub_args.clone(); next.push(sc.name.clone()); submit.submit(PoolJob { bin_path: job.bin_path.clone(), base_cmd: job.base_cmd.clone(), sub_args: next, depth: job.depth + 1, }); } } /// per-job handler called by every worker. populates the cache + enqueues /// child jobs (one per discovered subcommand) onto the same pool. /// /// source priority is: (1) native completions, (2) manpage, (3) --help. /// --help text is fetched at step 1 only as a probe for the completions /// subcommand; it is not mined for content unless steps 1 and 2 both miss. fn process_pool_job(ctx: &ScrapeCtx, job: PoolJob, submit: &Submitter) { let full_cmd = job.full_cmd(); if ctx.indexed.lock().contains(&full_cmd) { return; } let bin_s = job.bin_path.to_string_lossy().to_string(); // 1. native completions (top-level only — sub-commands don't ship // their own completion payloads). classify_binary scans the ELF for // "complet" needles, and try_native_completion confirms by invoking // the completions subcommand. if job.sub_args.is_empty() { let class = classify_binary(&job.bin_path, &job.bin_path); if matches!(class, Classify::Skip) { return; } if matches!(class, Classify::HasNativeCompletions) && let Some(nu) = try_native_completion(&job.bin_path, ctx.timeout_ms) { let _ = write_native(&ctx.cache_dir, &full_cmd, &nu); ctx.indexed.lock().insert(full_cmd); return; } } // 2. manpage as primary content source — structured documentation // over the curated --help summary. if !ctx.help_only.contains(&job.base_cmd) && !ctx.help_only.contains(&full_cmd) { let hyphenated = hyphenated_cmd(&job); if let Some(mp_path) = find_manpage_path(&ctx.mandirs, &hyphenated) && let Ok(contents) = read_manpage_file(&mp_path) { let mut mp_result = parse_manpage_string(&contents); if !mp_result.entries.is_empty() || !mp_result.subcommands.is_empty() { strip_subcmd_prefix(&mut mp_result, &hyphenated); let _ = write_result(&ctx.cache_dir, &full_cmd, "manpage", &mp_result); ctx.indexed.lock().insert(full_cmd); enqueue_subcommands(&job, &mp_result.subcommands, submit); return; } } } // 3. fallback: scrape --help text for content. let text = if job.sub_args.is_empty() { try_help(&job.bin_path, ctx.timeout_ms) } else { try_help_args(&bin_s, &job.sub_args, ctx.timeout_ms) }; let Some(text) = text else { return }; let result = parse_help_text(&text); if result.entries.is_empty() && result.subcommands.is_empty() && result.positionals.is_empty() { return; } // self-listing detection for sub-probes: if the leaf token shows up in // the result's subcommand list, the binary probably echoed the parent // help (didn't recognize the token). discard. if let Some(leaf) = job.sub_args.last() && result .subcommands .iter() .any(|sc| sc.name.eq_ignore_ascii_case(leaf)) { return; } let _ = write_result(&ctx.cache_dir, &full_cmd, "help", &result); ctx.indexed.lock().insert(full_cmd); enqueue_subcommands(&job, &result.subcommands, submit); } fn cmd_index( bindirs: &[PathBuf], mandirs: &[PathBuf], ignorelist: &HashSet, help_only: &HashSet, dir: &Path, timeout_ms: u64, num_workers: usize, ) -> std::io::Result<()> { ensure_dir(dir)?; let binaries = list_binaries(bindirs); // phase 1: parallel scrape of every eligible binary via the BFS pool. // shared state lives in an Arc; the `indexed` set is the // one mutable bit and uses parking_lot::Mutex. let ctx = Arc::new(ScrapeCtx { cache_dir: dir.to_path_buf(), mandirs: mandirs.to_vec(), help_only: help_only.clone(), indexed: Mutex::new(HashSet::new()), timeout_ms, }); let pool = ScrapePool::new(num_workers, { let ctx = ctx.clone(); move |job: PoolJob, submit: &Submitter| { process_pool_job(&ctx, job, submit); } }); for (name, path) in &binaries { if ignorelist.contains(name) { continue; } pool.submit(PoolJob { bin_path: path.clone(), base_cmd: name.clone(), sub_args: Vec::new(), depth: 0, }); } pool.wait(); // unwrap the indexed set back out for phase 2 — by this point no // workers are alive so the Arc has only one strong reference. let mut indexed: HashSet = Arc::try_unwrap(ctx) .ok() .map(|c| c.indexed.into_inner()) .unwrap_or_default(); // process manpages for commands not yet indexed (unless they're in help-only). // shorter filenames sort first so parent manpages (e.g. nix-env.1) are // processed before subpage manpages (nix-env-install.1). let mut manpages = list_manpages(mandirs); manpages.sort_by(|a, b| { let alen = a.file_name().map(|s| s.len()).unwrap_or(0); let blen = b.file_name().map(|s| s.len()).unwrap_or(0); alen.cmp(&blen).then_with(|| a.cmp(b)) }); for manpage_path in manpages { let Some((name, result, sub_sections)) = process_manpage(&manpage_path) else { continue; }; let base_cmd = cmd_name_of_manpage(&manpage_path); if indexed.contains(&name) { if name != base_cmd { eprintln!( "warning: {} extracted cmd \"{}\" (already indexed), skipping", manpage_path .file_name() .and_then(|s| s.to_str()) .unwrap_or(""), name ); } continue; } if help_only.contains(&name) { continue; } if is_nushell_builtin(&name) { continue; } // clap-style SUBCOMMAND sections produce real, fully-populated // sub-files (each with its own flags + positionals); they take // priority over COMMANDS-section leaf stubs. write_result(dir, &name, "manpage", &result)?; indexed.insert(name.clone()); for (sub_cmd, sub_result) in &sub_sections { if indexed.contains(sub_cmd) { continue; } write_result(dir, sub_cmd, "manpage", sub_result)?; indexed.insert(sub_cmd.clone()); } // for COMMANDS-section subcommands that aren't already covered by // a SUBCOMMAND section (or a per-subcommand manpage), write a // description-only stub so the completer treats them as leaves. // a real per-subcommand manpage processed later will overwrite the // stub since we deliberately don't add it to `indexed`. if sub_sections.is_empty() { for sc in &result.subcommands { let sub_cmd = format!("{name} {}", sc.name); if indexed.contains(&sub_cmd) { continue; } let stub = ManpageResult { entries: Vec::new(), subcommands: Vec::new(), positionals: Default::default(), description: sc.desc.clone(), }; write_result(dir, &sub_cmd, "manpage", &stub)?; } } } println!("indexed {} commands into {}", indexed.len(), dir.display()); Ok(()) } // --- manpage subcommand --- fn cmd_manpage(file: &Path) -> std::io::Result<()> { if let Some((name, result, sub_sections)) = process_manpage(file) { print!("{}", generate_extern(&name, &result)); for (sub_cmd, sub_result) in sub_sections { print!("{}", generate_extern(&sub_cmd, &sub_result)); } } Ok(()) } fn cmd_manpage_dir(dir: &Path) -> std::io::Result<()> { for section in COMMAND_SECTIONS { let secdir = dir.join(format!("man{section}")); let Ok(entries) = fs::read_dir(&secdir) else { continue; }; for entry in entries.flatten() { let path = entry.path(); if let Some((name, result, sub_sections)) = process_manpage(&path) { print!("{}", generate_extern(&name, &result)); for (sub_cmd, sub_result) in sub_sections { print!("{}", generate_extern(&sub_cmd, &sub_result)); } } } } Ok(()) } // --- query / dump / complete --- fn cmd_query(cmd: &str, dirs: &[PathBuf]) -> std::io::Result<()> { match lookup_raw(dirs, cmd) { Some(data) => { print!("{data}"); Ok(()) } None => { eprintln!("not found: {cmd}"); std::process::exit(1); } } } fn cmd_dump(dirs: &[PathBuf]) { let cmds = all_commands(dirs); println!("{} commands", cmds.len()); for cmd in &cmds { let src = file_type_of(dirs, cmd).unwrap_or_else(|| "?".to_string()); println!("{src:>8} {cmd}"); } } /// look up a command's path in $PATH. fn find_in_path(name: &str) -> Option { let path_var = std::env::var("PATH").ok()?; for dir in path_var.split(':') { let candidate = Path::new(dir).join(name); if is_executable(&candidate) { return Some(candidate); } } None } fn executable_span_path(span: &str) -> Option { if !span.contains('/') { return None; } let path = PathBuf::from(span); is_executable(&path).then_some(path) } fn command_name_for_path(path: &Path) -> Option { path.file_name() .and_then(|name| name.to_str()) .filter(|name| !name.is_empty()) .map(ToOwned::to_owned) } /// compute completion match quality. zero means no match. /// /// scoring tiers: /// - exact match: 1000 /// - prefix match: 900 + length bonus /// - subsequence match: per-character score with bonuses for word boundaries /// and consecutive matches fn fuzzy_score(needle: &str, haystack: &str) -> i32 { let needle_len = needle.chars().count(); let haystack_len = haystack.chars().count(); if needle_len == 0 { return 1; } if needle_len > haystack_len { return 0; } if needle == haystack { return 1000; } let needle_lc = needle.to_ascii_lowercase(); let haystack_lc = haystack.to_ascii_lowercase(); if haystack_lc.starts_with(&needle_lc) { return 900 + (needle_len as i32 * 100 / haystack_len as i32); } let needle_chars: Vec = needle_lc.chars().collect(); let haystack_chars: Vec = haystack.chars().collect(); let haystack_lc_chars: Vec = haystack_lc.chars().collect(); let mut needle_idx = 0usize; let mut score = 0i32; let mut prev_match: Option = None; for (hay_idx, c) in haystack_lc_chars.iter().enumerate() { if needle_idx >= needle_len { break; } if *c != needle_chars[needle_idx] { continue; } let boundary = hay_idx == 0 || haystack_chars[hay_idx - 1] == '-' || haystack_chars[hay_idx - 1] == '_' || (haystack_chars[hay_idx - 1].is_ascii_lowercase() && haystack_chars[hay_idx].is_ascii_uppercase()); let consecutive = prev_match == Some(hay_idx.saturating_sub(1)); score += if boundary { 50 } else { 10 }; if consecutive { score += 20; } needle_idx += 1; prev_match = Some(hay_idx); } if needle_idx == needle_len { score } else { 0 } } fn json_escape(s: &str) -> String { let mut out = String::with_capacity(s.len() + 2); for c in s.chars() { match c { '"' => out.push_str("\\\""), '\\' => out.push_str("\\\\"), '\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"), c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), c => out.push(c), } } out } fn completion_json(value: &str, desc: &str) -> String { format!( r#"{{"value":"{}","description":"{}"}}"#, json_escape(value), json_escape(desc) ) } /// dynamically scrape --help for a command not in the cache, write the result /// into the user store, and return its parsed form. discovered subcommands /// are also written. fn resolve_and_cache( user_dir: &Path, mandirs: &[PathBuf], cmd_name: &str, path: &Path, timeout_ms: u64, ) -> Option { resolve_command_path_and_cache(user_dir, mandirs, cmd_name, &[], path, timeout_ms) } fn resolve_command_path_and_cache( user_dir: &Path, mandirs: &[PathBuf], base_cmd: &str, sub_args: &[String], path: &Path, timeout_ms: u64, ) -> Option { let full_cmd = if sub_args.is_empty() { base_cmd.to_string() } else { format!("{base_cmd} {}", sub_args.join(" ")) }; let hyphenated = if sub_args.is_empty() { base_cmd.to_string() } else { format!("{base_cmd}-{}", sub_args.join("-")) }; // 1. native completions if matches!(classify_binary(path, path), Classify::HasNativeCompletions) && let Some(nu) = try_native_completion(path, timeout_ms) { let _ = write_native(user_dir, base_cmd, &nu); return Some(parse_nu_completions(&full_cmd, &nu)); } // 2. manpage as primary content source. if let Some(mp_path) = find_manpage_path(mandirs, &hyphenated) && let Ok(contents) = read_manpage_file(&mp_path) { let mut result = parse_manpage_string(&contents); if !result.entries.is_empty() || !result.subcommands.is_empty() { strip_subcmd_prefix(&mut result, &hyphenated); let _ = write_result(user_dir, &full_cmd, "manpage", &result); return Some(result); } } // 3. fallback: scrape --help text. let text = if sub_args.is_empty() { try_help(path, timeout_ms) } else { let bin_s = path.to_string_lossy().to_string(); try_help_args(&bin_s, sub_args, timeout_ms) }?; let parsed = parse_help_text(&text); if parsed.entries.is_empty() && parsed.subcommands.is_empty() && parsed.positionals.is_empty() { return None; } if let Some(leaf) = sub_args.last() && parsed .subcommands .iter() .any(|sc| sc.name.eq_ignore_ascii_case(leaf)) { return None; } let _ = write_result(user_dir, &full_cmd, "help", &parsed); if sub_args.is_empty() { let mut sub_acc: Vec<(String, ManpageResult)> = Vec::new(); help_resolve(path, base_cmd, 1, timeout_ms, &mut sub_acc); for (cmd, r) in sub_acc.into_iter().skip(1) { let _ = write_result(user_dir, &cmd, "help", &r); } } else { let bin_s = path.to_string_lossy().to_string(); let inner_subs: Vec = parsed .subcommands .iter() .map(|sc| sc.name.clone()) .filter(|n| n.len() >= 2 && !n.starts_with('-') && n != "help") .collect(); let mut sub_acc: Vec<(String, ManpageResult)> = Vec::new(); for sub in inner_subs { let mut next = sub_args.to_vec(); next.push(sub); recurse_subcommand( &bin_s, base_cmd, &next, sub_args.len() as u32 + 2, timeout_ms, &mut sub_acc, ); } for (cmd, r) in sub_acc { let _ = write_result(user_dir, &cmd, "help", &r); } } Some(parsed) } const ELEVATION_COMMANDS: &[&str] = &["sudo", "doas", "pkexec", "su", "run0"]; fn cmd_complete( spans: &[String], user_dir: &Path, system_dirs: &[PathBuf], mandirs: &[PathBuf], timeout_ms: u64, ) { let mut dirs: Vec = system_dirs.to_vec(); dirs.push(user_dir.to_path_buf()); // skip past elevation wrappers (sudo, doas) to find the real command let mut explicit_cmd_path: Option = None; let mut spans: Vec = match spans.first() { Some(first) if ELEVATION_COMMANDS.contains(&first.as_str()) => { let rest = &spans[1..]; let mut real_spans = None; for (idx, s) in rest.iter().enumerate() { if let Some(path) = executable_span_path(s) && let Some(name) = command_name_for_path(&path) { let mut target = rest[idx..].to_vec(); target[0] = name; explicit_cmd_path = Some(path); real_spans = Some(target); break; } if !s.is_empty() && !s.starts_with('-') && (lookup(&dirs, s).is_some() || find_in_path(s).is_some()) { real_spans = Some(rest[idx..].to_vec()); break; } } real_spans.unwrap_or_else(|| spans.to_vec()) } _ => spans.to_vec(), }; if explicit_cmd_path.is_none() && let Some(first) = spans.first() && let Some(path) = executable_span_path(first) && let Some(name) = command_name_for_path(&path) { spans[0] = name; explicit_cmd_path = Some(path); } if spans.is_empty() { println!("null"); return; } let cmd_name = spans[0].clone(); let rest: Vec = spans[1..].to_vec(); // strip intermediate flag tokens — they aren't part of subcommand path let mut tokens: Vec = vec![cmd_name.clone()]; if !rest.is_empty() { let (last, leading) = rest.split_last().unwrap(); for t in leading { if !t.starts_with('-') || t.is_empty() { tokens.push(t.clone()); } } tokens.push(last.clone()); } let last_token = rest.last().cloned().unwrap_or_default(); // lookup tokens exclude the partial unless the user has typed a trailing space let lookup_tokens: Vec = if last_token.is_empty() { tokens.clone() } else if tokens.len() > 1 { tokens[..tokens.len() - 1].to_vec() } else { vec![cmd_name.clone()] }; // try longest-prefix match: "git stash apply" → "git stash" → "git" let find_result = |toks: &[String]| -> Option<(String, ManpageResult, usize)> { let n = toks.len(); for drop in 0..n { let prefix = &toks[..n - drop]; if prefix.is_empty() { continue; } let name = prefix.join(" "); if let Some(r) = lookup(&dirs, &name) { return Some((name, r, prefix.len())); } } None }; let mut found = find_result(&lookup_tokens); // dynamic resolve: if nothing matches or only a parent matched, try --help let resolve_tokens: Vec = lookup_tokens .iter() .filter(|t| !t.is_empty()) .cloned() .collect(); let lookup_depth = lookup_tokens.len(); let resolve_depth = resolve_tokens.len(); let need_resolve = match &found { Some((_, _, depth)) => *depth < resolve_depth, None => resolve_depth > 0, }; if need_resolve && let Some(path) = explicit_cmd_path .as_ref() .cloned() .or_else(|| find_in_path(&cmd_name)) { // build extended mandirs from the binary's own prefix as well let mut all_mandirs = mandirs.to_vec(); if let Some(parent) = path.parent() && let Some(prefix) = parent.parent() { let share_man = prefix.join("share/man"); if share_man.is_dir() { all_mandirs.push(share_man); } } let sub_args = if resolve_tokens.len() > 1 { resolve_tokens[1..].to_vec() } else { Vec::new() }; let resolved = if sub_args.is_empty() { resolve_and_cache(user_dir, &all_mandirs, &cmd_name, &path, timeout_ms) } else { resolve_command_path_and_cache( user_dir, &all_mandirs, &cmd_name, &sub_args, &path, timeout_ms, ) }; if resolved.is_some() { found = find_result(&lookup_tokens); } } let typing_flag = last_token.starts_with('-') && !last_token.is_empty(); let candidates: Vec = match &found { None => Vec::new(), Some((matched_name, r, depth)) => { let mut scored: Vec<(i32, String)> = Vec::new(); // subcommand candidates (skip if match is too shallow) if *depth >= lookup_depth.saturating_sub(1) { let subs: Vec = if !r.subcommands.is_empty() { r.subcommands.clone() } else { subcommands_of(&dirs, matched_name) }; for sc in &subs { let s = fuzzy_score(&last_token, &sc.name); if s > 0 { scored.push((s, completion_json(&sc.name, &sc.desc))); } } } // flag candidates if typing_flag { for e in &r.entries { let base_desc = match &e.param { Some(OwnedParam::Mandatory(p)) => { if e.desc.is_empty() { format!("<{p}>") } else { format!("{} <{p}>", e.desc) } } Some(OwnedParam::Optional(p)) => { if e.desc.is_empty() { format!("[{p}]") } else { format!("{} [{p}]", e.desc) } } None => e.desc.clone(), }; let (flag, desc) = match &e.switch { OwnedSwitch::Long(l) => (format!("--{l}"), base_desc), OwnedSwitch::Short(c) => (format!("-{c}"), base_desc), OwnedSwitch::Both(c, l) => { let long_flag = format!("--{l}"); let short_flag = format!("-{c}"); let ls = fuzzy_score(&last_token, &long_flag); let ss = fuzzy_score(&last_token, &short_flag); if ss > ls { (short_flag, format!("(aka {long_flag}) {base_desc}")) } else { (long_flag.clone(), format!("(aka {short_flag}) {base_desc}")) } } }; let s = fuzzy_score(&last_token, &flag); if s > 0 { scored.push((s, completion_json(&flag, &desc))); } } } scored.sort_by(|a, b| b.0.cmp(&a.0)); scored.into_iter().map(|(_, json)| json).collect() } }; // protocol: null = hand off to nushell's file completer; [...] = our candidates let has_subs = match &found { Some((matched_name, r, _)) => { !r.subcommands.is_empty() || !subcommands_of(&dirs, matched_name).is_empty() } None => false, }; // hand off at non-flag leaf positions so file and dynamic completers can // answer argument prefixes. when the token starts with "-", keep flags. let want_files = !typing_flag && !has_subs && (last_token.is_empty() || candidates.is_empty()); if want_files || candidates.is_empty() { println!("null"); } else { println!("[{}]", candidates.join(",")); } } // --- completions self-emission --- fn cmd_completions() { // emit completions for inshellah itself. let entries: Vec = vec![ManpageEntry { switch: OwnedSwitch::Both('h', "help".to_string()), param: None, desc: "show help".to_string(), }]; let subs = [ "index", "manpage", "manpage-dir", "complete", "query", "dump", "completions", ]; let mut subcommands = Vec::new(); for s in subs { subcommands.push(ManpageSubcommand { name: s.to_string(), desc: String::new(), }); } let result = ManpageResult { entries, subcommands, positionals: Default::default(), description: "nushell completions engine".to_string(), }; print!("{}", generate_module("inshellah", &result)); } // --- argument parsing --- struct IndexArgs { prefixes: Vec, dir: Option, ignore: Option, help_only: Option, timeout_ms: u64, workers: usize, } fn parse_index_args(args: &[String]) -> IndexArgs { let mut out = IndexArgs { prefixes: Vec::new(), dir: None, ignore: None, help_only: None, timeout_ms: DEFAULT_TIMEOUT_MS, workers: default_workers(), }; let mut i = 0; while i < args.len() { match args[i].as_str() { "--dir" => { i += 1; if i < args.len() { out.dir = Some(PathBuf::from(&args[i])); } } "--ignore" => { i += 1; if i < args.len() { out.ignore = Some(PathBuf::from(&args[i])); } } "--help-only" => { i += 1; if i < args.len() { out.help_only = Some(PathBuf::from(&args[i])); } } "--timeout-ms" => { i += 1; if i < args.len() && let Ok(n) = args[i].parse::() { out.timeout_ms = n; } } "--workers" => { i += 1; if i < args.len() && let Ok(n) = args[i].parse::() { out.workers = n.max(1); } } other => { out.prefixes.push(PathBuf::from(other)); } } i += 1; } out } /// best-effort thread count default: `available_parallelism` (1.59+), else 4. fn default_workers() -> usize { std::thread::available_parallelism() .map(|n| n.get()) .unwrap_or(4) } fn man_dir_of_prefix(prefix: &Path) -> PathBuf { prefix.join("share/man") } /// parse --dir PATH[:PATH...], optional --timeout-ms N, plus any /// positional args. when --dir isn't supplied, returns the default cache /// dir as the single entry. fn parse_dir_args(args: &[String]) -> (Vec, Vec, u64) { let mut positional = Vec::new(); let mut dirs: Option> = None; let mut timeout_ms = DEFAULT_TIMEOUT_MS; let mut i = 0; while i < args.len() { match args[i].as_str() { "--dir" => { i += 1; if i < args.len() { dirs = Some(args[i].split(':').map(PathBuf::from).collect()); } } "--timeout-ms" => { i += 1; if i < args.len() && let Ok(n) = args[i].parse::() { timeout_ms = n; } } _ => { positional.push(args[i].clone()); } } i += 1; } let dirs = dirs.unwrap_or_else(|| vec![default_store_path()]); (positional, dirs, timeout_ms) } fn main() { let args: Vec = std::env::args().collect(); if args.len() < 2 { usage(); std::process::exit(1); } match args[1].as_str() { "index" => { let parsed = parse_index_args(&args[2..]); if parsed.prefixes.is_empty() { eprintln!("error: index requires at least one PREFIX"); std::process::exit(1); } let dir = parsed.dir.unwrap_or_else(default_store_path); let ignorelist = parsed .ignore .as_deref() .map(load_ignorelist) .unwrap_or_default(); let help_only = parsed .help_only .as_deref() .map(load_ignorelist) .unwrap_or_default(); let bindirs: Vec = parsed.prefixes.iter().map(|p| p.join("bin")).collect(); let mandirs: Vec = parsed .prefixes .iter() .map(|p| man_dir_of_prefix(p)) .collect(); if let Err(e) = cmd_index( &bindirs, &mandirs, &ignorelist, &help_only, &dir, parsed.timeout_ms, parsed.workers, ) { eprintln!("index failed: {e}"); std::process::exit(1); } } "manpage" => { if args.len() < 3 { eprintln!("error: manpage requires a FILE argument"); std::process::exit(1); } if let Err(e) = cmd_manpage(Path::new(&args[2])) { eprintln!("manpage failed: {e}"); std::process::exit(1); } } "manpage-dir" => { if args.len() < 3 { eprintln!("error: manpage-dir requires a DIR argument"); std::process::exit(1); } if let Err(e) = cmd_manpage_dir(Path::new(&args[2])) { eprintln!("manpage-dir failed: {e}"); std::process::exit(1); } } "complete" => { let (positional, dirs, timeout_ms) = parse_dir_args(&args[2..]); // first dir is the writable user cache; rest are read-only system dirs let (user_dir, system_dirs): (PathBuf, Vec) = match dirs.split_first() { Some((first, rest)) => (first.clone(), rest.to_vec()), None => (default_store_path(), Vec::new()), }; // mandirs default to share/man siblings of each system dir let mandirs: Vec = system_dirs .iter() .filter_map(|d| d.parent().map(|p| p.join("share/man"))) .filter(|p| p.is_dir()) .collect(); cmd_complete(&positional, &user_dir, &system_dirs, &mandirs, timeout_ms); } "query" => { let (positional, dirs, _timeout_ms) = parse_dir_args(&args[2..]); if positional.is_empty() { eprintln!("error: query requires a CMD argument"); std::process::exit(1); } let cmd = positional.join(" "); if let Err(e) = cmd_query(&cmd, &dirs) { eprintln!("query failed: {e}"); std::process::exit(1); } } "dump" => { let (_, dirs, _timeout_ms) = parse_dir_args(&args[2..]); cmd_dump(&dirs); } "completions" => cmd_completions(), "--help" | "-h" | "help" => usage(), other => { eprintln!("unknown subcommand: {other}"); usage(); std::process::exit(1); } } // make warning go away let _ = filename_of_command; }