add darwin support

This commit is contained in:
atagen 2026-05-24 18:10:30 +10:00
parent 73904c036f
commit 4a7febee6c
10 changed files with 774 additions and 47 deletions

View file

@ -8,5 +8,5 @@ see `doc/` for details:
- [building and installing](doc/building.md) — cargo, nix, post-install setup
- [nushell integration](doc/nushell-integration.md) — setup, the pipeline, the completer
- [nixos module](doc/nixos.md) — automatic build-time indexing + module options
- [nixos / nix-darwin module](doc/nixos.md) — automatic build-time indexing + module options
- [runtime completions](doc/runtime-completions.md) — on-the-fly caching via the completer

View file

@ -1,10 +1,12 @@
# nixos integration
# nixos / nix-darwin integration
inshellah provides a nixos module that indexes nushell completions for
every installed package at system build time, and a wrapped binary
that knows where to find the result.
inshellah provides a module that indexes nushell completions for every
installed package at system build time, and a wrapped binary that knows
where to find the result. the same module body backs both NixOS and
nix-darwin — on Linux it scrapes ELF binaries, on macOS Mach-O ones,
selected automatically by the inshellah build's target platform.
## enabling
## enabling (NixOS)
```nix
# flake.nix outputs:
@ -28,6 +30,34 @@ or importing directly:
}
```
## enabling (nix-darwin)
```nix
# flake.nix outputs:
{
darwinConfigurations.mymac = nix-darwin.lib.darwinSystem {
modules = [
inshellah.darwinModules.default
{ programs.inshellah.enable = true; }
];
};
}
```
the options and behaviour are identical to the NixOS module — it reads
the same `programs.inshellah.*` settings and writes the same completion
index and nushell shim under the system profile (`/run/current-system/sw`,
which nix-darwin also uses).
on macOS, the tools you reach through `/usr/bin` (`git`, `clang`, …) are
`xcrun` shims whose real binaries and manpages live under the active
developer dir, outside the nix system profile — so they aren't indexed by
default. rather than probe the host toolchain impurely, list the nix
equivalents you want completed in `extraScrapePackages`; the module rolls
their store paths into the build-time scrape (`inshellah index … --prefix
…`). this applies on NixOS too, for any package whose completions you want
indexed without putting it on the system path.
after rebuilding, completions are immediately available through the
autoloaded nushell shim.
@ -69,6 +99,11 @@ programs.inshellah = {
# commands to skip manpage parsing for (uses --help instead)
helpOnlyCommands = [ "nix" ];
# extra packages to scrape alongside the system profile; each store path
# is passed to `inshellah index --prefix`. handy on macOS for the nix
# equivalents of /usr/bin shim tools (git, clang, …)
extraScrapePackages = [ pkgs.git pkgs.clang ];
# per-subprocess timeout in ms during indexing (null = built-in
# default of 200ms)
timeoutMs = null;
@ -81,11 +116,39 @@ programs.inshellah = {
# set to 0 to omit native result-limit flags
dynamicLimit = 200;
# characters that trigger flag completions when a partial token begins
# with one of them. default "-"; e.g. "-+" also triggers on "+"
flagTriggers = "-";
# also surface flags on an empty token (right after a space), mixed in
# with subcommands. default false
flagOnEmpty = false;
# cap on candidates returned and nushell's max_results. 0 = no cap
# (nushell's built-in default of 200 still applies)
maxCompletions = 0;
# per-subprocess timeout (ms) for the completer's on-the-fly --help
# resolution of uncached commands. null = built-in default of 200ms.
# distinct from timeoutMs (indexing) and dynamicTimeoutMs (live shim)
completeTimeoutMs = null;
# worker-thread count for the parallel scrape
workers = null;
};
```
### flag-triggering behaviour
`flagTriggers` and `flagOnEmpty` control when option/flag completions are
offered. By default flags appear only after a leading `-`. Add characters
to `flagTriggers` (e.g. `"-+"`) to trigger on them as well — for a
non-dash trigger the text after it is matched against the bare flag name,
so `+ver` completes to `--verbose`. Set `flagOnEmpty = true` to list flags
immediately after a space, alongside subcommands. These map to the
`INSHELLAH_FLAG_TRIGGERS` / `INSHELLAH_FLAG_ON_EMPTY` environment variables
(see [runtime-completions.md](runtime-completions.md)).
## using the completer
the module installs the completer under nushell's vendor autoload path,

View file

@ -54,6 +54,35 @@ $env.config.completions.external = {
paths after the first in `--dir` are read-only system dirs.
## configuration
the `complete` path reads a few behavioural knobs from the environment.
each has a compiled-in default that reproduces the original behaviour, so
an unconfigured install is unchanged. on nixos these are set for you by
the module options (see [nixos.md](nixos.md)); elsewhere, export them in
your shell before nushell starts.
| variable | default | effect |
|---|---|---|
| `INSHELLAH_FLAG_TRIGGERS` | `-` | characters that surface flag completions when a partial token begins with one of them. set to `-+` to also trigger on `+`; whitespace is ignored. an empty value disables prefix-triggered flags (leaving only `INSHELLAH_FLAG_ON_EMPTY`). |
| `INSHELLAH_FLAG_ON_EMPTY` | `0` | when truthy (`1`/`true`/`yes`/`on`), also surface flags on an empty token — i.e. right after a space — alongside subcommands. otherwise an empty token hands off to file/dynamic completion. |
| `INSHELLAH_MAX_COMPLETIONS` | `0` | cap on the number of candidates returned (and nushell's `max_results` when sourcing the bundled snippet). `0` imposes no inshellah cap; nushell's own default of 200 still applies. |
| `INSHELLAH_TIMEOUT_MS` | `200` | per-subprocess timeout for the on-the-fly `--help` resolution. an explicit `--timeout-ms` flag overrides it. |
### flag triggering
by default flags are offered only once a token begins with `-`
(`git commit --<TAB>`). two overrides are available:
- **other trigger characters**`INSHELLAH_FLAG_TRIGGERS="-+"` makes a
leading `+` surface flags too. for non-dash triggers the typed text
after the trigger is matched against the bare flag name, so `+ver`
completes to `--verbose`. the emitted value keeps the tool's real
dashed flag.
- **flags after a space**`INSHELLAH_FLAG_ON_EMPTY=1` lists flags
immediately after a space, mixed in with subcommands, before any
character is typed.
## cache management
```sh
@ -84,3 +113,23 @@ for upfront indexing on non-nixos systems:
```sh
inshellah index /usr /usr/local
```
## macOS developer toolchain
`/usr/bin/git`, `/usr/bin/clang`, and friends are `xcrun` shims whose real
binaries and manpages live under the active developer dir (`xcode-select
-p` — Command Line Tools or full Xcode), outside the usual prefixes. to
index those, point `index` at the real prefix explicitly — either the
developer dir or, preferably, the nix equivalents:
```sh
# the active developer toolchain
inshellah index --prefix "$(xcode-select -p)/usr"
# or nix-provided tools, kept reproducible
inshellah index /run/current-system/sw --prefix /nix/store/…-git:/nix/store/…-clang
```
`--prefix` takes a colon-separated list of extra prefixes, scraped
alongside the positional ones. the nix module exposes this as
`programs.inshellah.extraScrapePackages` (see [nixos.md](nixos.md)).

View file

@ -268,11 +268,23 @@
}
);
# the module body in ./nix/module.nix only touches options common to
# both NixOS and nix-darwin (environment.{variables,systemPackages,
# pathsToLink,extraSetup} + a programs.inshellah namespace), so the two
# platform outputs share it verbatim and differ only in which package
# the host system resolves to.
nixosModules.default =
{ pkgs, ... }:
{
imports = [ ./nix/module.nix ];
programs.inshellah.package = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
};
darwinModules.default =
{ pkgs, ... }:
{
imports = [ ./nix/module.nix ];
programs.inshellah.package = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
};
};
}

View file

@ -37,6 +37,18 @@ let inshellah_limit_args = { |flag|
if $inshellah_dynamic_limit == 0 { [] } else { [$flag $inshellah_dynamic_limit] }
}
# nushell's own cap on how many external completions it will display.
# mirrors the Rust completer's INSHELLAH_MAX_COMPLETIONS cap so both ends
# agree. 0 (or unset) keeps the historical default of 200.
let inshellah_default_max_results = 200
let inshellah_max_results = do {
let raw = (try {
$env.INSHELLAH_MAX_COMPLETIONS? | default 0 | into int
} catch { 0 })
if $raw > 0 { $raw } else { $inshellah_default_max_results }
}
let inshellah_with_timeout = { |body|
if $inshellah_dynamic_timeout_ms == 0 {
try { do $body } catch { null }
@ -853,4 +865,4 @@ let inshellah_complete = { |spans|
}
}
$env.config.completions.external = {enable: true, max_results: 200, completer: $inshellah_complete}
$env.config.completions.external = {enable: true, max_results: $inshellah_max_results, completer: $inshellah_complete}

View file

@ -1,4 +1,4 @@
# NixOS module: automatic nushell completion indexing
# NixOS / nix-darwin module: automatic nushell completion indexing
#
# Indexes completions using three strategies in priority order:
# 1. Native completion generators (e.g. CMD completions nushell)
@ -8,11 +8,18 @@
# Produces a directory of .json/.nu files at build time.
# The `complete` command reads from this directory as a system overlay.
#
# Usage:
# This module body only uses options shared by NixOS and nix-darwin
# (environment.{variables,systemPackages,pathsToLink,extraSetup}), so the
# same file backs both flake outputs. On macOS the indexer scrapes Mach-O
# binaries; on Linux, ELF — selected by the inshellah build's target os.
#
# Usage (NixOS):
# { pkgs, ... }: {
# imports = [ ./path/to/inshellah-rs/nix/module.nix ];
# programs.inshellah.enable = true;
# }
# Usage (nix-darwin): identical — import the same file (or the flake's
# darwinModules.default) and set programs.inshellah.enable = true.
{
config,
@ -100,6 +107,22 @@ in
'';
};
extraScrapePackages = lib.mkOption {
type = lib.types.listOf lib.types.package;
default = [ ];
example = lib.literalExpression "[ pkgs.git pkgs.clang ]";
description = ''
additional packages to scrape for completions alongside the system
profile. each package's store path is passed to `inshellah index`
via `--prefix`, so it must contain bin/ and/or share/man/.
useful on macOS, where the active developer toolchain (git, clang,
) lives outside the nix system profile behind /usr/bin shims:
install the nix equivalents and list them here so their completions
get indexed reproducibly, rather than probing the host toolchain.
'';
};
timeoutMs = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
@ -132,6 +155,56 @@ in
'';
};
flagTriggers = lib.mkOption {
type = lib.types.str;
default = "-";
example = "-+";
description = ''
characters that trigger flag (option) completions when a partial
token begins with one of them. the default "-" reproduces the
original behaviour where only a leading dash surfaces flags. each
character is taken literally; whitespace is ignored. exported as
INSHELLAH_FLAG_TRIGGERS.
'';
};
flagOnEmpty = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
also surface flag completions when nothing has been typed yet
i.e. right after a space/tab alongside subcommands. when false
(the default) an empty token hands off to file/dynamic completion.
exported as INSHELLAH_FLAG_ON_EMPTY.
'';
};
maxCompletions = lib.mkOption {
type = lib.types.int;
default = 0;
example = 100;
description = ''
upper bound on the number of static completion candidates returned,
and the nushell `max_results` shown. 0 means no inshellah-imposed
cap (nushell's built-in default of 200 still applies). exported as
INSHELLAH_MAX_COMPLETIONS.
'';
};
completeTimeoutMs = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
example = 400;
description = ''
per-subprocess timeout in milliseconds for the on-the-fly --help
resolution the completer performs for uncached commands. distinct
from `timeoutMs` (build-time indexing) and `dynamicTimeoutMs` (the
nushell shim's live providers). null uses the binary's compiled
default (currently 200ms). exported as INSHELLAH_TIMEOUT_MS.
'';
};
workers = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
@ -155,6 +228,12 @@ in
config = lib.mkIf cfg.enable {
environment.variables.INSHELLAH_DYNAMIC_TIMEOUT_MS = toString cfg.dynamicTimeoutMs;
environment.variables.INSHELLAH_DYNAMIC_LIMIT = toString cfg.dynamicLimit;
environment.variables.INSHELLAH_FLAG_TRIGGERS = cfg.flagTriggers;
environment.variables.INSHELLAH_FLAG_ON_EMPTY = if cfg.flagOnEmpty then "1" else "0";
environment.variables.INSHELLAH_MAX_COMPLETIONS = toString cfg.maxCompletions;
environment.variables.INSHELLAH_TIMEOUT_MS = lib.mkIf (
cfg.completeTimeoutMs != null
) (toString cfg.completeTimeoutMs);
environment.systemPackages =
let
@ -191,13 +270,18 @@ in
helpOnlyFlag = lib.optionalString (cfg.helpOnlyCommands != [ ]) " --help-only ${helpOnlyFile}";
timeoutFlag = lib.optionalString (cfg.timeoutMs != null) " --timeout-ms ${toString cfg.timeoutMs}";
workersFlag = lib.optionalString (cfg.workers != null) " --workers ${toString cfg.workers}";
# roll the explicit extra packages up into a single colon-separated
# --prefix so they're scraped alongside the system profile.
prefixFlag = lib.optionalString (cfg.extraScrapePackages != [ ]) (
" --prefix " + lib.concatStringsSep ":" (map toString cfg.extraScrapePackages)
);
snippetFile = pkgs.writeText "inshellah-completer.nu" cfg.snippet;
in
''
mkdir -p ${destDir}
if [ -d "$out/bin" ] && [ -d "$out/share/man" ]; then
${inshellah} index "$out" --dir ${destDir}${ignoreFlag}${helpOnlyFlag}${timeoutFlag}${workersFlag} \
${inshellah} index "$out" --dir ${destDir}${ignoreFlag}${helpOnlyFlag}${prefixFlag}${timeoutFlag}${workersFlag} \
2>/dev/null || true
fi

233
src/config.rs Normal file
View file

@ -0,0 +1,233 @@
//! runtime configuration for the `complete` path.
//!
//! the completer reads a handful of behavioural knobs from the
//! environment. this matches the mechanism already used for the dynamic
//! nushell shim (`INSHELLAH_DYNAMIC_*`): the nixos module exports the
//! variables via `environment.variables`, and users sourcing the snippet
//! by hand can export them directly. every field has a compiled-in
//! default that reproduces the historical behaviour, so an unconfigured
//! install behaves exactly as before.
/// per-subprocess timeout default for the dynamic `--help` resolve path
/// when neither `--timeout-ms` nor `INSHELLAH_TIMEOUT_MS` is set.
pub const DEFAULT_TIMEOUT_MS: u64 = 200;
/// the historical (and default) flag-trigger set: a partial token starting
/// with `-` asks for flag completions.
pub const DEFAULT_FLAG_TRIGGERS: &str = "-";
/// behavioural configuration resolved once at startup.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
/// characters that, when a partial token begins with one of them,
/// cause flag completions to be emitted. defaults to `['-']` — the
/// only trigger in the original behaviour.
pub flag_triggers: Vec<char>,
/// also emit flags when the partial token is empty, i.e. right after a
/// space/tab with nothing typed yet. defaults to `false`.
pub flag_on_empty: bool,
/// upper bound on the number of completion candidates returned by the
/// static completer. `0` means no inshellah-imposed cap (nushell's own
/// `max_results` still applies).
pub max_completions: usize,
/// per-subprocess timeout (ms) for the dynamic `--help` resolve path.
pub timeout_ms: u64,
}
impl Default for Config {
fn default() -> Self {
Config {
flag_triggers: DEFAULT_FLAG_TRIGGERS.chars().collect(),
flag_on_empty: false,
max_completions: 0,
timeout_ms: DEFAULT_TIMEOUT_MS,
}
}
}
impl Config {
/// resolve configuration from the process environment, falling back to
/// the compiled-in defaults for anything unset or unparseable.
pub fn from_env() -> Self {
Self::from_lookup(|key| std::env::var(key).ok())
}
/// inner resolver, parameterised over the variable source so tests can
/// drive it without mutating the real (process-global) environment.
pub fn from_lookup(mut get: impl FnMut(&str) -> Option<String>) -> Self {
let mut cfg = Config::default();
if let Some(raw) = get("INSHELLAH_FLAG_TRIGGERS") {
// tokens are split on whitespace before they reach us, so a
// whitespace character can never be the first byte of a partial
// token — drop any from the trigger set rather than letting it
// silently never match. an explicitly empty value disables
// prefix-triggered flags entirely (leaving only flag_on_empty).
cfg.flag_triggers = raw.chars().filter(|c| !c.is_whitespace()).collect();
}
if let Some(raw) = get("INSHELLAH_FLAG_ON_EMPTY") {
cfg.flag_on_empty = parse_bool(&raw);
}
if let Some(raw) = get("INSHELLAH_MAX_COMPLETIONS")
&& let Ok(n) = raw.trim().parse::<usize>()
{
cfg.max_completions = n;
}
if let Some(raw) = get("INSHELLAH_TIMEOUT_MS")
&& let Ok(n) = raw.trim().parse::<u64>()
{
cfg.timeout_ms = n;
}
cfg
}
/// whether a partial token should surface flag completions. an empty
/// token is governed by [`Config::flag_on_empty`]; otherwise the first
/// character is matched against the trigger set.
pub fn triggers_flags(&self, token: &str) -> bool {
match token.chars().next() {
None => self.flag_on_empty,
Some(c) => self.flag_triggers.contains(&c),
}
}
/// derive the needle used to score flag candidates for a triggering
/// token, plus whether that needle should match the *bare* flag name
/// (dashes stripped) rather than the canonical dashed form.
///
/// the `-` trigger keeps the dashed form so long-vs-short ranking is
/// preserved exactly (`--ver` prefers `--verbose`, `-v` prefers `-v`).
/// any other trigger character has no dash semantics, so we strip the
/// single leading trigger char and match the remainder against the bare
/// name — letting `+ver` match `--verbose`. an empty token yields an
/// empty bare needle, which matches every flag.
pub fn flag_needle<'a>(&self, token: &'a str) -> FlagNeedle<'a> {
match token.chars().next() {
None => FlagNeedle {
needle: token,
bare: true,
},
Some('-') => FlagNeedle {
needle: token,
bare: false,
},
Some(c) => FlagNeedle {
needle: &token[c.len_utf8()..],
bare: true,
},
}
}
}
/// the scoring needle for flag candidates: [`FlagNeedle::needle`] is matched
/// against the bare flag name when [`FlagNeedle::bare`] is set, else against
/// the dashed form.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FlagNeedle<'a> {
pub needle: &'a str,
pub bare: bool,
}
/// permissive truthy parse for boolean env vars.
fn parse_bool(s: &str) -> bool {
matches!(
s.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn cfg_from(pairs: &[(&str, &str)]) -> Config {
let map: HashMap<String, String> = pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
Config::from_lookup(|k| map.get(k).cloned())
}
#[test]
fn defaults_match_historical_behaviour() {
let cfg = Config::default();
assert_eq!(cfg.flag_triggers, vec!['-']);
assert!(!cfg.flag_on_empty);
assert_eq!(cfg.max_completions, 0);
assert_eq!(cfg.timeout_ms, DEFAULT_TIMEOUT_MS);
// only "-" prefixes trigger; empty does not.
assert!(cfg.triggers_flags("-"));
assert!(cfg.triggers_flags("--verbose"));
assert!(!cfg.triggers_flags(""));
assert!(!cfg.triggers_flags("build"));
}
#[test]
fn flag_on_empty_opens_flags_after_a_space() {
let cfg = cfg_from(&[("INSHELLAH_FLAG_ON_EMPTY", "true")]);
assert!(cfg.flag_on_empty);
assert!(cfg.triggers_flags(""));
// a bare word still does not trigger flags.
assert!(!cfg.triggers_flags("sub"));
}
#[test]
fn custom_trigger_chars_replace_the_dash() {
let cfg = cfg_from(&[("INSHELLAH_FLAG_TRIGGERS", "-+")]);
assert_eq!(cfg.flag_triggers, vec!['-', '+']);
assert!(cfg.triggers_flags("+ver"));
assert!(cfg.triggers_flags("-v"));
assert!(!cfg.triggers_flags("/x"));
}
#[test]
fn whitespace_in_triggers_is_dropped() {
let cfg = cfg_from(&[("INSHELLAH_FLAG_TRIGGERS", "- ")]);
assert_eq!(cfg.flag_triggers, vec!['-']);
}
#[test]
fn dash_needle_keeps_dashes_other_triggers_go_bare() {
let cfg = cfg_from(&[("INSHELLAH_FLAG_TRIGGERS", "-+")]);
assert_eq!(
cfg.flag_needle("--ver"),
FlagNeedle {
needle: "--ver",
bare: false
}
);
assert_eq!(
cfg.flag_needle("+ver"),
FlagNeedle {
needle: "ver",
bare: true
}
);
assert_eq!(
cfg.flag_needle(""),
FlagNeedle {
needle: "",
bare: true
}
);
}
#[test]
fn numeric_knobs_parse_and_fall_back() {
let cfg = cfg_from(&[
("INSHELLAH_MAX_COMPLETIONS", "50"),
("INSHELLAH_TIMEOUT_MS", "1000"),
]);
assert_eq!(cfg.max_completions, 50);
assert_eq!(cfg.timeout_ms, 1000);
// garbage leaves the default intact.
let bad = cfg_from(&[
("INSHELLAH_MAX_COMPLETIONS", "lots"),
("INSHELLAH_TIMEOUT_MS", "soon"),
]);
assert_eq!(bad.max_completions, 0);
assert_eq!(bad.timeout_ms, DEFAULT_TIMEOUT_MS);
}
}

View file

@ -1,3 +1,4 @@
pub mod config;
pub mod parsers;
pub mod pool;
pub mod store;

View file

@ -21,6 +21,7 @@ use std::time::{Duration, Instant};
use parking_lot::Mutex;
use inshellah::config::{Config, DEFAULT_TIMEOUT_MS};
use inshellah::parsers::help::help_parser;
use inshellah::parsers::manpage::{
ManpageEntry, ManpageResult, ManpageSubcommand, OwnedParam, OwnedSwitch,
@ -35,26 +36,20 @@ use inshellah::store::{
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]
[--prefix PATH[:PATH...]] [--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
--prefix PATHS extra scrape prefixes, colon-separated (in addition
to the positional PREFIX args)
--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]
@ -69,6 +64,12 @@ Usage:
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
Configuration (environment, read by `complete`):
INSHELLAH_FLAG_TRIGGERS chars that surface flags (default \"-\"; e.g. \"-+\")
INSHELLAH_FLAG_ON_EMPTY 1 to also surface flags on an empty token
INSHELLAH_MAX_COMPLETIONS cap on candidates returned (0 = no cap)
INSHELLAH_TIMEOUT_MS default --help resolve timeout (--timeout-ms wins)
"
);
}
@ -262,12 +263,41 @@ fn skip_name(name: &str) -> bool {
|| name.contains('/')
}
// --- ELF scanning ---
// --- executable image 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<String> {
/// is `magic` the leading 4 bytes of an executable image we know how to
/// string-scan on *this* platform? the scan itself is byte-oriented and
/// format-agnostic; this gate just keeps us from slurping data files that
/// happen to carry the executable bit.
///
/// recognition is strictly per-platform: a macOS build honours only Mach-O
/// (thin 32/64-bit either endianness, plus fat/universal), every other
/// (ELF) target honours only ELF. keeping them mutually exclusive means a
/// Linux build never treats `CA FE BA BE` as an image — that's FAT_MAGIC to
/// Mach-O but also a Java class file, which a Linux box can plausibly carry.
fn is_scannable_magic(magic: &[u8; 4]) -> bool {
#[cfg(target_os = "macos")]
{
matches!(
magic,
[0xce, 0xfa, 0xed, 0xfe] // MH_MAGIC (thin 32-bit, little-endian)
| [0xcf, 0xfa, 0xed, 0xfe] // MH_MAGIC_64 (thin 64-bit, little-endian)
| [0xfe, 0xed, 0xfa, 0xce] // MH_MAGIC (thin 32-bit, big-endian)
| [0xfe, 0xed, 0xfa, 0xcf] // MH_MAGIC_64 (thin 64-bit, big-endian)
| [0xca, 0xfe, 0xba, 0xbe] // FAT_MAGIC (universal)
| [0xca, 0xfe, 0xba, 0xbf] // FAT_MAGIC_64
)
}
#[cfg(not(target_os = "macos"))]
{
magic == b"\x7fELF"
}
}
/// scan an executable image (ELF on Linux, Mach-O on macOS) 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 image_scan(path: &Path, needles: &[&str]) -> HashSet<String> {
let mut found: HashSet<String> = HashSet::new();
let real = match fs::canonicalize(path) {
Ok(p) => p,
@ -288,8 +318,8 @@ fn elf_scan(path: &Path, needles: &[&str]) -> HashSet<String> {
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
if !is_scannable_magic(&magic) {
// not a recognised executable image — return empty so caller decides
return found;
}
let max_needle = needles.iter().map(|s| s.len()).max().unwrap_or(0);
@ -410,9 +440,9 @@ enum Classify {
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"]);
/// classify an executable image by scanning for help/completion needles.
fn classify_image(path: &Path) -> Classify {
let found = image_scan(path, &["-h", "--help", "complet"]);
if found.contains("complet") {
Classify::HasNativeCompletions
} else if found.contains("-h") || found.contains("--help") {
@ -422,18 +452,19 @@ fn classify_elf(path: &Path) -> Classify {
}
}
/// classify a binary by its actual nature: script, ELF, or nix wrapper.
/// classify a binary by its actual nature: script, native image, or nix
/// wrapper. native images are ELF on Linux and Mach-O on macOS.
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);
return classify_image(&target);
}
if let Some(target) = nix_script_wrapper_target(full) {
return classify_elf(&target);
return classify_image(&target);
}
classify_elf(full)
classify_image(full)
}
// --- help text extraction ---
@ -836,6 +867,71 @@ mod main_tests {
r#"{"value":"a\"b","description":"line\nnext"}"#
);
}
#[test]
fn completion_dir_mandir_resolves_to_prefix_share_man() {
// <prefix>/share/inshellah -> <prefix>/share/man, no doubled "share".
assert_eq!(
mandir_for_completion_dir(Path::new("/run/current-system/sw/share/inshellah")),
Some(PathBuf::from("/run/current-system/sw/share/man"))
);
assert_eq!(
mandir_for_completion_dir(Path::new("/etc/profiles/per-user/alice/share/inshellah")),
Some(PathBuf::from("/etc/profiles/per-user/alice/share/man"))
);
}
#[test]
fn index_prefix_flag_appends_colon_separated_prefixes() {
let args = [
"/sys".to_string(),
"--prefix".to_string(),
"/a:/b/c".to_string(),
"--prefix".to_string(),
"/d".to_string(),
];
let parsed = parse_index_args(&args);
// positional first, then each --prefix segment, in order.
assert_eq!(
parsed.prefixes,
vec![
PathBuf::from("/sys"),
PathBuf::from("/a"),
PathBuf::from("/b/c"),
PathBuf::from("/d"),
]
);
}
#[test]
fn non_executable_magic_is_never_scannable() {
// a PNG header, a shebang, plain text — none are images on any platform.
assert!(!is_scannable_magic(&[0x89, b'P', b'N', b'G']));
assert!(!is_scannable_magic(b"#!/b"));
assert!(!is_scannable_magic(b"text"));
}
// recognition is strictly per-platform: each build honours only its
// native container and rejects the other.
#[cfg(target_os = "macos")]
#[test]
fn macos_scans_mach_o_only() {
// thin 64-bit little-endian — the common arm64/x86_64 layout.
assert!(is_scannable_magic(&[0xcf, 0xfa, 0xed, 0xfe]));
// fat/universal.
assert!(is_scannable_magic(&[0xca, 0xfe, 0xba, 0xbe]));
// ELF is not a native macOS image.
assert!(!is_scannable_magic(b"\x7fELF"));
}
#[cfg(not(target_os = "macos"))]
#[test]
fn elf_targets_scan_elf_only() {
assert!(is_scannable_magic(b"\x7fELF"));
// Mach-O magics are rejected; FAT_MAGIC also collides with java class.
assert!(!is_scannable_magic(&[0xca, 0xfe, 0xba, 0xbe]));
assert!(!is_scannable_magic(&[0xcf, 0xfa, 0xed, 0xfe]));
}
}
/// shared state passed to every pool worker. nothing inside mutates
@ -1806,6 +1902,7 @@ fn cmd_complete(
system_dirs: &[PathBuf],
mandirs: &[PathBuf],
timeout_ms: u64,
cfg: &Config,
) {
let mut dirs: Vec<PathBuf> = system_dirs.to_vec();
dirs.push(user_dir.to_path_buf());
@ -1951,7 +2048,10 @@ fn cmd_complete(
}
}
let typing_flag = last_token.starts_with('-') && !last_token.is_empty();
// flag completions are gated on a configurable trigger: by default a
// leading "-", but the user may add other characters or opt into
// surfacing flags on an empty token (right after a space).
let typing_flag = cfg.triggers_flags(&last_token);
let fallback_subcommands = match &found {
Some((matched_name, r, _)) if r.subcommands.is_empty() => {
subcommands_of(&dirs, matched_name)
@ -2000,25 +2100,38 @@ fn cmd_complete(
}
}
}
// flag candidates
// flag candidates. the needle — and whether it scores against
// the bare flag name or the dashed form — depends on which
// trigger the user typed (see Config::flag_needle). the default
// "-" trigger keeps the dashed form, so ranking is unchanged.
if typing_flag {
let fneedle = cfg.flag_needle(&last_token);
let score_against = |dashed: &str, bare_name: &str| -> i32 {
if fneedle.bare {
fuzzy_score(fneedle.needle, bare_name)
} else {
fuzzy_score(fneedle.needle, dashed)
}
};
for e in &r.entries {
let (flag, aka, score) = match &e.switch {
OwnedSwitch::Long(l) => {
let flag = format!("--{l}");
let score = fuzzy_score(&last_token, &flag);
let score = score_against(&flag, l);
(flag, None, score)
}
OwnedSwitch::Short(c) => {
let flag = format!("-{c}");
let score = fuzzy_score(&last_token, &flag);
let short_bare = c.to_string();
let score = score_against(&flag, &short_bare);
(flag, None, score)
}
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);
let short_bare = c.to_string();
let ls = score_against(&long_flag, l);
let ss = score_against(&short_flag, &short_bare);
if ss > ls {
(short_flag, Some(long_flag), ss)
} else {
@ -2040,6 +2153,9 @@ fn cmd_complete(
}
}
scored.sort_by(|a, b| b.0.cmp(&a.0));
if cfg.max_completions > 0 {
scored.truncate(cfg.max_completions);
}
scored.into_iter().map(|(_, json)| json).collect()
}
};
@ -2128,6 +2244,17 @@ fn parse_index_args(args: &[String]) -> IndexArgs {
out.help_only = Some(PathBuf::from(&args[i]));
}
}
// additional scrape prefixes beyond the positional ones, as a
// colon-separated list. lets callers (notably the nix module's
// extraScrapePackages) roll up extra packages without relying on
// positional ordering.
"--prefix" => {
i += 1;
if i < args.len() {
out.prefixes
.extend(args[i].split(':').filter(|s| !s.is_empty()).map(PathBuf::from));
}
}
"--timeout-ms" => {
i += 1;
if i < args.len()
@ -2164,13 +2291,24 @@ fn man_dir_of_prefix(prefix: &Path) -> PathBuf {
prefix.join("share/man")
}
/// derive the manpage dir colocated with a read-only system completion dir.
/// the completer is pointed at `<prefix>/share/inshellah`, so the install
/// prefix is two levels up and its manpages live at `<prefix>/share/man` —
/// the same bin↔share/man colocation `index` and the binary-prefix walk
/// assume. portable across Linux and macOS prefixes (nix profile, Homebrew,
/// /usr, CommandLineTools).
fn mandir_for_completion_dir(dir: &Path) -> Option<PathBuf> {
dir.parent().and_then(Path::parent).map(man_dir_of_prefix)
}
/// 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<String>, Vec<PathBuf>, u64) {
/// dir as the single entry. the timeout is `None` when `--timeout-ms`
/// isn't passed, so the caller can fall back to the configured default.
fn parse_dir_args(args: &[String]) -> (Vec<String>, Vec<PathBuf>, Option<u64>) {
let mut positional = Vec::new();
let mut dirs: Option<Vec<PathBuf>> = None;
let mut timeout_ms = DEFAULT_TIMEOUT_MS;
let mut timeout_ms: Option<u64> = None;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
@ -2185,7 +2323,7 @@ fn parse_dir_args(args: &[String]) -> (Vec<String>, Vec<PathBuf>, u64) {
if i < args.len()
&& let Ok(n) = args[i].parse::<u64>()
{
timeout_ms = n;
timeout_ms = Some(n);
}
}
_ => {
@ -2262,19 +2400,31 @@ fn main() {
}
}
"complete" => {
let (positional, dirs, timeout_ms) = parse_dir_args(&args[2..]);
let cfg = Config::from_env();
let (positional, dirs, timeout_override) = parse_dir_args(&args[2..]);
// explicit --timeout-ms wins; otherwise fall back to the
// configured default (INSHELLAH_TIMEOUT_MS or the compiled one).
let timeout_ms = timeout_override.unwrap_or(cfg.timeout_ms);
// first dir is the writable user cache; rest are read-only system dirs
let (user_dir, system_dirs): (PathBuf, Vec<PathBuf>) = 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
// mandirs default to the share/man colocated with each system
// completion dir's install prefix (<prefix>/share/inshellah).
let mandirs: Vec<PathBuf> = system_dirs
.iter()
.filter_map(|d| d.parent().map(|p| p.join("share/man")))
.filter_map(|d| mandir_for_completion_dir(d))
.filter(|p| p.is_dir())
.collect();
cmd_complete(&positional, &user_dir, &system_dirs, &mandirs, timeout_ms);
cmd_complete(
&positional,
&user_dir,
&system_dirs,
&mandirs,
timeout_ms,
&cfg,
);
}
"query" => {
let (positional, dirs, _timeout_ms) = parse_dir_args(&args[2..]);

View file

@ -641,3 +641,126 @@ exit 2
let _ = fs::remove_dir_all(root);
}
/// write a single-command cache directory exposing the given long flags,
/// returning the cache dir. callers drive `inshellah complete demo ...`.
fn flag_demo_cache(name: &str, flags: &[&str]) -> std::path::PathBuf {
let root = unique_temp_dir(name);
let cache_dir = root.join("cache");
fs::create_dir_all(&cache_dir).expect("cache dir");
let result = ManpageResult {
entries: flags
.iter()
.map(|f| ManpageEntry {
switch: OwnedSwitch::Long((*f).to_string()),
param: None,
desc: format!("{f} flag"),
})
.collect(),
subcommands: Vec::new(),
positionals: Vec::new(),
description: String::new(),
};
write_result(&cache_dir, "demo", "help", &result).expect("cache");
cache_dir
}
#[test]
fn complete_flag_on_empty_env_surfaces_flags_after_space() {
let cache_dir = flag_demo_cache("inshellah-flag-on-empty", &["verbose"]);
// baseline: empty token without the env knob yields no flags.
let baseline = Command::new(env!("CARGO_BIN_EXE_inshellah"))
.args(["complete", "--dir"])
.arg(&cache_dir)
.args(["demo", ""])
.output()
.expect("run inshellah complete");
assert_eq!(
String::from_utf8_lossy(&baseline.stdout).trim(),
"null",
"empty token should not surface flags by default"
);
// with INSHELLAH_FLAG_ON_EMPTY, the empty token surfaces flags.
let opted_in = Command::new(env!("CARGO_BIN_EXE_inshellah"))
.env("INSHELLAH_FLAG_ON_EMPTY", "1")
.args(["complete", "--dir"])
.arg(&cache_dir)
.args(["demo", ""])
.output()
.expect("run inshellah complete");
let stdout = String::from_utf8_lossy(&opted_in.stdout);
assert!(
stdout.contains(r#""value":"--verbose""#),
"stdout = {stdout}"
);
let _ = fs::remove_dir_all(cache_dir.parent().unwrap());
}
#[test]
fn complete_custom_trigger_char_surfaces_flags() {
let cache_dir = flag_demo_cache("inshellah-custom-trigger", &["verbose"]);
// "+" is not a trigger by default — treated as an argument prefix.
let baseline = Command::new(env!("CARGO_BIN_EXE_inshellah"))
.args(["complete", "--dir"])
.arg(&cache_dir)
.args(["demo", "+v"])
.output()
.expect("run inshellah complete");
assert_eq!(
String::from_utf8_lossy(&baseline.stdout).trim(),
"null",
"'+' should not trigger flags by default"
);
// configured as a trigger, "+v" fuzzy-matches the bare flag name.
let opted_in = Command::new(env!("CARGO_BIN_EXE_inshellah"))
.env("INSHELLAH_FLAG_TRIGGERS", "-+")
.args(["complete", "--dir"])
.arg(&cache_dir)
.args(["demo", "+v"])
.output()
.expect("run inshellah complete");
let stdout = String::from_utf8_lossy(&opted_in.stdout);
assert!(
stdout.contains(r#""value":"--verbose""#),
"stdout = {stdout}"
);
let _ = fs::remove_dir_all(cache_dir.parent().unwrap());
}
#[test]
fn complete_max_completions_caps_results() {
let cache_dir = flag_demo_cache(
"inshellah-max-completions",
&["verbose", "version", "verify", "verbatim"],
);
let capped = Command::new(env!("CARGO_BIN_EXE_inshellah"))
.env("INSHELLAH_MAX_COMPLETIONS", "2")
.args(["complete", "--dir"])
.arg(&cache_dir)
.args(["demo", "--ver"])
.output()
.expect("run inshellah complete");
let stdout = String::from_utf8_lossy(&capped.stdout);
let count = stdout.matches(r#""value":"#).count();
assert_eq!(count, 2, "expected 2 capped candidates, stdout = {stdout}");
// without the cap, all four matching flags come back.
let uncapped = Command::new(env!("CARGO_BIN_EXE_inshellah"))
.args(["complete", "--dir"])
.arg(&cache_dir)
.args(["demo", "--ver"])
.output()
.expect("run inshellah complete");
let stdout = String::from_utf8_lossy(&uncapped.stdout);
let count = stdout.matches(r#""value":"#).count();
assert_eq!(count, 4, "expected 4 candidates, stdout = {stdout}");
let _ = fs::remove_dir_all(cache_dir.parent().unwrap());
}