Research: what the Fig-spec walker actually does — an REA analysis of microsoft/inshellisense’s runtime and withfig/autocomplete’s data, as the model for DropTerm’s Rust port

Date: 2026-11-07 Question: completions-breadth chose the withfig/autocomplete static corpus and named microsoft/inshellisense’s spec runtime as the engine to reverse-engineer. What does that parser→walker→filter pipeline do, token by token; which spec fields does it actually read (and which of the type’s fields does it ignore); where exactly do the dynamic parts (generators, templates, loadSpec) attach, so a static extraction can drop them without losing the tree; and what is the minimal Rust port that grows DropTerm’s two flat seats into nested subcommands, per-subcommand options, options-that-take-arguments, persistent options, and --flag=value?

TL;DR

The engine is ~750 lines across three files, and the part DropTerm needs is a pure function over an immutable tree — no Node, no execution, no shell. REA parsed the pinned sources as inert text (7 TypeScript runtime files, 0 parse failures; 5 representative specs, 0 parse failures) and retained both graphs; the walker itself was read directly from the pinned checkout.

  1. The lexer (parser.ts, 154 lines) keeps the last command after ||/&&/;/|, then walks characters through four states — quoted, quote-continued, flag, word — emitting tokens of {token, complete, isOption}. A token is complete only when a space follows it; the trailing unterminated token is the partial the menu filters on. = terminates a flag token, so --emit=asm lexes as --emit (complete, option) + asm — the walker then treats asm as that option’s argument. Escaped spaces and "quoted"word forms are unwrapped after lexing.
  2. The walker (runtime.ts) is three mutually recursive functions over Fig.Subcommand — runSubcommand / runOption / runArg — each consuming one complete token and recursing, stopping at the first incomplete (or absent) token to ask for suggestions. The rules it encodes, each a few lines: an option that declares args consumes the following token(s); isVariadic args consume until an option or subcommand appears; isOptional args let a token be re-tried as an option or subcommand first; isPersistent options are carried from a parent into every child subcommand; an isCommand arg (sudo-style) restarts the walk on a nested command’s own spec.
  3. The stop-point assembly (suggestion.ts) lists the node’s subcommands + options + argument suggestions, drops options already typed (removeAcceptedSuggestions) and options excluded by exclusiveOn, filters case-insensitively by prefix (or substring under filterStrategy: "fuzzy"), sorts by priority (options 45, everything else 50 unless the spec says), dedupes, and hides hidden entries unless typed exactly. Every row carries its description straight from the spec — the menu’s dim column.

The dynamic half attaches only to args. Across mkdir, rustc, node, git, gh, cargo, docker, kubectl, every generators:, script:, and template: key sits on an Arg — “what value goes here” — never on a subcommand or option name. Dropping them keeps the entire subcommand/option tree with descriptions intact; rustc and mkdir carry zero generators to begin with. template: "filepaths"/"folders" maps onto DropTerm’s existing Path provider; generators map onto the contracted git generator or nothing.

The engine ignores most of the type’s exotic fields. A key-read census over the runtime finds it never reads requiresSeparator, requiresEquals, optionsMustPrecedeArguments, parserDirectives, flagsArePosixNoncompliant, isRepeatable, dependsOn, deprecated, isRequired, or isDangerous. The only constraint field it honors is exclusiveOn. A Rust port that ignores the same set is not a simplification of the live engine — it is the live engine’s model.

Verdict for the port: grow SpecCommand into a recursive tree with typed options and args (five flags total: is_persistent, is_optional, is_variadic, hidden, exclusive_on), port the lexer (~100 lines of Rust) and the walker (~200 lines, three functions), and reuse MenuCandidate for the stop-point rows. DropTerm’s grid-scraped typed line replaces inshellisense’s PTY wrapper; everything downstream of parseCommand is the same pure computation. This record extends completions-breadth (its REA target, executed) and extends completions-seed-audit (the conversion now has an exact static subset to target).

Method

Findings

1. REA’s identity and export inventory of the engine

The engine’s three files and their exported surface, as REA recorded them (artifact SHA-256 per file; ranges exact):

File SHA-256 (prefix) Bytes Exports observed (range)
src/runtime/parser.ts 712ef3c8… 5,758 parseCommand L23-27, getCommandCacheKey L29
src/runtime/runtime.ts eb3d5cef… — loadLocalSpecsSet L88-104, getSuggestions L106-142, clearTransientSuggestionState L148 (plus getSpecNames L144-146 by source)
src/runtime/suggestion.ts dc0b0dac… 17,194 NerdFontIcons L27-61, getSubcommandDrivenRecommendation L358-409, getArgDrivenRecommendation L411-457

runtime.ts:4-8 statically imports @withfig/autocomplete/build/index.js — the compiled spec corpus as an npm package — which REA retained as an unresolved external (no node_modules in the checkout). That is the whole data dependency: the engine ships no knowledge of its own.

The isPersistent trace (Evidence ev_8e1f14a5…) found exactly two reads, both in runtime.ts — L170:133 (inside getPersistentOptions) and L257:30 (inside getPersistentTokens) — each on a receiver REA could not resolve to a retained container (“object identity is complete; receiver unresolved”). That is the expected shape of a data-driven walker: every spec field is a property read on an object that arrives from outside the engine.

2. The lexer — parser.ts, read in full

3. The walker — runtime.ts:106-465

Entry (getSuggestions, :106-142): parseCommand (:109); an incomplete root token short-circuits to runCommand (:114-116) — prefix-match over the sorted spec names (binary search, :150-164) plus alias names (:437-465; aliases priority 100, specs 40). Otherwise aliasExpand (:118), loadSpec for the root command (:121), and runSubcommand(tokens[1..], …, spec) (:133). An empty result with no argument description is undefined — the “menu refuses to open” rule (:135).

runSubcommand (:378-435) — at a subcommand node with the remaining tokens:

runOption (:264-299): if the option declares args, the next tokens are consumed by runArg with fromOption = true (:280-283); otherwise return to runSubcommand with the option token appended to the accepted set, flagged isPersistent when it came from the persistent list (:284-298).

runArg (:301-376):

genSubcommand (:188-250) resolves a child by name (string or array name, :191) and, when the child carries loadSpec, lazily loads and splices it. Three loadSpec shapes (function / string / object, :200-245) — all dynamic, all dropped by a static extraction; the breadth record counted them under its 61.5% static measure.

Persistence is two helpers: getPersistentOptions (:166-173) carries any option with isPersistent === true into the child’s option set (deduplicated by name); getPersistentTokens (:256-258) keeps only the accepted tokens flagged persistent across the boundary, so a parent’s --verbose still counts as “already typed” inside git commit.

4. The stop-point assembly — suggestion.ts

getSubcommandDrivenRecommendation (:358-409), the function that produces what the menu shows at a subcommand node:

  1. If args were depleted and came from the subcommand, show nothing (:371-373) — the positional slots are full.
  2. The partial is the incomplete token’s text; a path partial is reduced to its basename (:374-377).
  3. Unless the walk arrived through a subcommand’s own args: push the node’s subcommands and its options (persistent + own) (:384-387). Options are filtered by exclusiveOn against the options already accepted (optionSuggestions, :298-307).
  4. If the node has args, push the first arg’s generators, suggestions (static strings or objects), and template results (:388-394) — the dynamic tier, in that order.
  5. Pipeline (:396-408, inside-out): sort → fix up path insert values → remove already-accepted names → remove ~ folder when ~ was typed → remove hidden unless typed exactly → remove empty names → dedupe by name.

filter (:160-237): default strategy is a case-insensitive startsWith over each name (array names match if any element does, and the matched element becomes the displayed name, :207-220); "fuzzy" is a case-insensitive includes (:170-202). No partial → everything (:166).

sortSuggestions (:143-158): descending priority only; path suggestions additionally re-rank among themselves by match quality (getMatchRelevance, :130-138: exact 3 / prefix 2 / substring 1), length, then name — so subcommands and options keep their spec-authored order. getSuggestionPriority (:119-127): anything whose name starts with - is 45, otherwise the spec’s priority or 50.

toSuggestion (:105-117) is the row: name, description, icon, allNames, priority, insertValue, type, hidden. DropTerm’s MenuCandidate { label, suffix, description, source } already holds the three that matter; insertValue (what to write when it differs from the display name, e.g. a folder with a trailing /) is the suffix DropTerm already computes.

5. The field census — what the engine reads, and what it ignores

Property reads across runtime.ts, suggestion.ts, generator.ts, template.ts (count of .field occurrences):

Read Count Where it matters
name 54 identity and matching, string or array
subcommands 16 the tree
args 15 option-args and positionals
priority 10 sort
filterStrategy 10 prefix vs fuzzy
description 6 the row’s dim column
insertValue, hidden 6 each row
options 5 the tree
icon 5 row (inshellisense’s Nerd Font glyphs; DropTerm has no icon column)
loadSpec 4 dynamic — dropped
isPersistent 2 walker
isVariadic, isOptional 2 each walker
template, suggestions, generators, debounce 2 each arg tier (dynamic except suggestions)
isCommand 1 walker (sudo shape)
exclusiveOn 1 option filter
getQueryTerm, postProcess generator-only dynamic — dropped

Zero reads of: requiresSeparator, requiresEquals, optionsMustPrecedeArguments, parserDirectives, flagsArePosixNoncompliant, isRepeatable, dependsOn, deprecated, isRequired, isDangerous, isModule, isScript. The type (index.d.ts: BaseSuggestion L353, Subcommand L524, Option L708, Arg L870, Generator L1116) declares them; the live engine does not consult them. The constraint vocabulary a port must honor is therefore exactly: isPersistent, isOptional, isVariadic, isCommand, hidden, exclusiveOn, plus priority and filterStrategy for ordering.

6. Where the dynamic half lives in the data

Key counts in eight specs at the pinned commit:

Spec Lines generators: script: loadSpec: template:
mkdir.ts 36 0 0 0 1
rustc.ts 358 0 0 0 3
node.ts 503 1 0 0 4
gh.ts 3,228 26 4 0 11
kubectl.ts 3,866 13 12 0 16
docker.ts 6,853 44 20 1 11
cargo.ts 7,562 190 7 0 13
git.ts 9,812 82 14 1 48

Every one of those keys is a property of an Arg (or of a Generator inside an Arg) — the type puts generators, template, and suggestions on Arg (L870), and loadSpec on Subcommand as a lazy replacement for the node’s own static body, never as a supplement to the names. So the static extraction rule is one sentence: keep every Subcommand/Option with its name, description, and the honored flags; keep each Arg’s name, isOptional, isVariadic, template kind, and literal suggestions; drop generators, script, postProcess, loadSpec, generateSpec. mkdir.ts in full (:1-36) is the shape the extraction leaves behind for every spec: one positional with template: "folders", six options with descriptions, two of them taking an argument.

What the drop costs, concretely: git checkout <branch> loses Fig’s branch generator (DropTerm’s contracted git generator already supplies it); cargo run --bin <name> loses the Cargo.toml-reading generator (nothing replaces it — the option still completes, its value does not); kubectl get <resource> loses the live-cluster generator (nothing replaces it, by ruling). The names, flags, and descriptions — the breadth — survive in every case.

7. What this adds to DropTerm’s seats, exactly

Today (src/terminal/suggest.rs:966-986, per the breadth record): a flat SpecCommand { name, subcommands: Vec<SpecEntry>, flags: Vec<SpecEntry> }; the subcommand seat is the first argument only; -- flags from any seat; no option-arguments; no nesting; no persistence; no =. The walker above adds, in order of user-visible value:

  1. Per-subcommand options — git commit --am lists commit’s flags, not git’s whole flag table (today’s table has one flag list per command).
  2. Options that take an argument — after --emit or --emit=, the next seat is the option’s arg (its suggestions or template), not another flag.
  3. Nested subcommands — docker compose up, gh pr create, cargo clippy, to any depth.
  4. Persistent options — git --no-pager log --one still finds --oneline because --no-pager rode through.
  5. Variadic and optional positionals — git add a b c -- keeps offering paths until an option appears.
  6. The sudo shape (isCommand) — sudo apt ins walks apt’s spec.

Each is a branch in one of three ~60-line Rust functions. The grid-scraped typed string is the lexer’s input; nothing before parseCommand in inshellisense (the PTY wrapper, src/isterm/) is needed, because DropTerm already has its own answer to “what is on the line.”

Recommendation

Port the three functions, not the engine. The Rust shape:

struct SpecCommand {            // today's name, grown
    name: Vec<String>,          // Fig allows aliases: ["co", "checkout"]
    description: Option<String>,
    subcommands: Vec<SpecCommand>,
    options: Vec<SpecOption>,
    args: Vec<SpecArg>,
    hidden: bool, priority: Option<u8>,
}
struct SpecOption {
    names: Vec<String>, description: Option<String>,
    args: Vec<SpecArg>, is_persistent: bool,
    exclusive_on: Vec<String>, hidden: bool,
}
struct SpecArg {
    name: String, is_optional: bool, is_variadic: bool,
    is_command: bool,
    template: Option<Template>,          // Filepaths | Folders
    suggestions: Vec<SpecEntry>,         // literal strings/objects only
}

plus lex(typed) -> Vec<Token> (the four-state loop, one escape char) and walk(tokens, &spec) -> StopPoint (run_subcommand / run_option / run_arg), with the stop point rendering into the existing MenuCandidate rows under menu_source_rank’s specs tier. The #[serde(deny_unknown_fields)] boundary stays: a script or generators key still fails the file whole (suggest.rs:769-773); the converter simply never emits one. Open design choices for the spec, not this record: whether the recursive tree stays in TOML (nested [[…]] arrays) or moves the generated built-in table to RON; whether filterStrategy: "fuzzy" is honored (the breadth record’s pinned filter_rank is the obvious seat) or flattened to prefix everywhere.

Order of work: (1) the schema and lexer with the no-shrink tests pinned to the current seed; (2) the walker, pinned by table-driven tests transcribed from this record’s branch list (each bullet in finding 7 is one test); (3) the extractor emitting the static subset at the pinned commit for the top-100 tranche; (4) the embedded table. Each step ships on its own and the existing five-plus-fourteen keep working at every step.

Unverified / open questions

Sources

All read 2026-11-07 in the checkouts the breadth record fetched on 2026-11-06; nothing executed.