Skip to content

feat(completion): add in-process JS resolver for dynamic value completion#353

Open
dqn wants to merge 145 commits into
mainfrom
feat/dynamic-completion-resolver
Open

feat(completion): add in-process JS resolver for dynamic value completion#353
dqn wants to merge 145 commits into
mainfrom
feat/dynamic-completion-resolver

Conversation

@dqn
Copy link
Copy Markdown
Collaborator

@dqn dqn commented Apr 28, 2026

Add completion.custom.resolve so dynamic completion candidates can be computed in-process from a TS callback that sees other arg values typed so far. Static shell scripts (bash/zsh/fish) delegate to <program> __complete --shell <shell> whenever a field uses resolve.

Usage

field: arg(z.array(z.string()).default([]), {
  alias: "f",
  completion: {
    custom: {
      resolve: ({ parsedArgs, previousValues }) => {
        const endpoint = parsedArgs.endpoint as string | undefined;
        if (!endpoint) return { candidates: [] };
        const used = new Set(previousValues.map((v) => v.split("=")[0]));
        return { candidates: lookupFieldsFor(endpoint).filter((k) => !used.has(k)) };
      },
    },
  },
});

The resolver receives currentWord, shell, parsedArgs (other args parsed so far), previousValues (already-supplied values for the same option), and subcommandPath. It can return synchronously or via a Promise.

Verification

End-to-end manual run against the sample CLI in this branch:

Scenario Result
api GetApplication --field returns endpoint-specific keys workspaceId=, applicationName=
api GetApplication --field=appli filters by inline prefix --field=applicationName= only
api CreateApplication -f cors=https://a -f de-dups via previousValues cors excluded, others returned
Resolver returning DirectoryCompletion directive :32 directive on the wire
Candidates starting with : (e.g. :hover, ::before) preserved, not parsed as directive
Generated bash sourced in a real shell, COMPREPLY filled COMPREPLY=(workspaceId= applicationName=)
Resolver throws :66 (Error | NoFileCompletion), shell unaffected

Main Changes

  • Add completion.custom.resolve and the DynamicCompletionContext / DynamicCompletionResult / DynamicCompletionResolver types. Mixing resolve with choices or shellCommand throws at command-definition time.
  • parseCompletionContext now collects best-effort parsedArgs and previousValues for the resolver and accepts an optional globalArgsSchema so resolvers attached to global options remain reachable from any subcommand.
  • bash/zsh/fish generators emit __<fn>_invoke_complete and __<fn>_apply_dynamic_output helpers that delegate to <program> __complete --shell <shell>. Each shell honours the resolver-supplied directive bits (FileCompletion / DirectoryCompletion / NoSpace) and treats only the trailing :<digits> line as the directive sentinel so :-prefixed candidate values survive.
  • Generators preserve the per-frame array semantics of rawGlobalArgs (the first write to a global array in a new frame replaces the inherited value) and the runtime's "next token starting with - is not a value" rule, so resolver contexts match runtime behaviour even on partial command lines.
  • generateCandidates(context, { shell }) is now async and takes a required second argument so resolvers may return Promises. __complete's internal run is async to match.
  • Existing callers of withCompletionCommand are unaffected.

Notes

  • Versioned as minor per the 0.x policy because generateCandidates and parseCompletionContext gained a required argument and an optional one respectively.
  • MYCLI_BIN env var (uppercased program name) overrides the binary the static script invokes, useful for local development before the CLI is on PATH. When the program name starts with a digit, the override variable is prefixed with _ (e.g. _2FA_BIN) so it remains a valid shell parameter name.
  • The generated bash script runs on Bash 3.2 or newer, including the default /bin/bash shipped with macOS. Both completion.custom.expand and completion.custom.resolve avoid bash 4 builtins — associative arrays are replaced with prefix-scalar variables, and mapfile is replaced with a portable while read loop. See docs/shell-completion.md for details.
  • The internal __complete command is exempt from globalArgsSchema validation and from running global effect callbacks. Shell scripts invoke __complete against partial command lines that legitimately omit required globals, so the resolver always receives a context regardless of whether globals are present, and user-defined effects never fire mid-TAB.

Code Metrics Report

main (cb9a35e) #353 (7b64b82) +/-
Coverage 87.8% 90.4% +2.5%
Test Execution Time 9s 12s +3s
Details
  |                     | main (cb9a35e) | #353 (7b64b82) |  +/-  |
  |---------------------|----------------|----------------|-------|
+ | Coverage            |          87.8% |          90.4% | +2.5% |
  |   Files             |             51 |             55 |    +4 |
  |   Lines             |           4272 |           5182 |  +910 |
+ |   Covered           |           3753 |           4686 |  +933 |
- | Test Execution Time |             9s |            12s |   +3s |

Code coverage of files in pull request scope (90.5% → 95.3%)

Files Coverage +/- Status
src/completion/bash.ts 99.2% +3.7% modified
src/completion/dynamic/candidate-generator.ts 92.0% +10.1% modified
src/completion/dynamic/complete-command.ts 88.8% +7.0% modified
src/completion/dynamic/context-parser.ts 90.5% +4.0% modified
src/completion/dynamic/shell-formatter.ts 96.5% -0.6% modified
src/completion/expand-resolver.ts 96.9% +96.9% added
src/completion/extractor.ts 97.9% +14.3% modified
src/completion/fish.ts 99.2% +4.0% modified
src/completion/index.ts 64.7% 0.0% modified
src/completion/shell-shared.ts 96.6% +96.6% added
src/completion/types.ts 0.0% 0.0% modified
src/completion/value-completion-resolver.ts 93.3% +4.4% modified
src/completion/zsh.ts 98.4% +3.2% modified
src/core/arg-registry.ts 100.0% 0.0% modified
src/core/dynamic-completion-types.ts 0.0% 0.0% added
src/core/expand-completion-types.ts 0.0% 0.0% added
src/core/runner.ts 86.8% +0.0% modified
src/index.ts 0.0% 0.0% modified
src/parser/arg-parser.ts 91.0% +0.1% modified

Reported by octocov


Open in Devin Review

dqn added 10 commits April 28, 2026 20:04
…tion

Add `completion.custom.resolve` so a TypeScript callback can compute
candidates from other arg values typed so far. The resolver receives a
`DynamicCompletionContext` (currentWord, shell, parsedArgs, previousValues,
subcommandPath) and may return synchronously or via Promise. Static shell
scripts (bash/zsh/fish) now delegate to `<program> __complete --shell <shell>`
whenever a field uses `resolve`. Specifying more than one of choices,
shellCommand, or resolve on the same field throws.

Internal: `generateCandidates` is now async and takes `{shell}` as a second
arg; `__complete`'s run is async; parseCompletionContext records option
and positional values into the context so resolvers can see them.

Includes playground/27-dynamic-completion modeled after tailor-sdk's
`api <endpoint> --field key=value` use case.
- bash: parse the resolver-supplied directive line and apply matching
  compopt flags so FileCompletion/DirectoryCompletion/NoSpace overrides
  reach the shell.
- context-parser: clamp positionalIndex to the last positional when
  computing previousValues so a variadic positional still receives its
  prior values once positionalIndex outruns the schema.
- complete-command + index: forward globalArgsSchema into __complete and
  merge global options at every command level so resolvers attached to
  global args are reachable from any subcommand.
Bash already dispatched on the trailing :<directive> line. Zsh and fish
dropped it, so a resolver returning DirectoryCompletion or FileCompletion
silently produced no candidates on those shells. Replace the silent
filter helper with an apply helper that parses the directive and
dispatches to _files / _files -/ on zsh and __fish_complete_path /
__fish_complete_directories on fish.
… fish

- context-parser: store global option values in a separate map so they
  survive subcommand descent. Runtime accumulates globals across the
  command path; the parser now matches that, so a resolver attached to
  a subcommand sees `--profile` set regardless of whether it was
  supplied before or after the subcommand name.
- fish: thread the partially-typed token into the apply helper so a
  resolver returning FileCompletion or DirectoryCompletion respects the
  prefix the user has typed (e.g. `--config src/`), matching how the
  static file/directory paths already pass `$_cur`.
… globals correctly

- bash/zsh/fish: only treat the trailing `:<digits>` line as the directive
  sentinel. Intermediate lines starting with `:` (e.g. CSS pseudo classes,
  protocol prefixes) are legitimate candidate values and were being
  silently dropped.
- context-parser: when a local option shadows a same-cliName global, the
  same camelCase name no longer routes the local value into
  `globalParsedArgs`. Recompute the effective global-name set at every
  subcommand frame so a local declaration takes precedence end-to-end.
- core/dynamic-completion-types: document that `subcommandPath` reflects
  what the user typed and is NOT normalized through aliases.
…e slices

- candidate-generator: reuse the public DynamicCompletionContext type as
  the resolver invocation context instead of the parallel
  ResolverInvocationContext interface, drop the explicit five-property
  splat, and normalize string-vs-object candidates with one assignment.
- complete-command: hoist the inline-prefix-stripped current word into a
  single `effectiveWord` constant rather than slicing twice.
- completion/index: remove a stale duplicate re-export comment.
…helper

Four tests opened a console.log spy, awaited runCommand on the
__complete subcommand, restored the spy, and split the captured output
into lines. Extract that into a single runComplete helper so each call
site only states the argv it cares about. The helper restores the spy
in `finally` so a thrown expectation no longer leaks the mock.
…e guard

`src/completion/dynamic-completion-types.ts` re-exported types that
already live in `src/core/dynamic-completion-types.ts` and exposed an
`isDynamicValueCompletion` type guard with no internal callers. Inline
the re-exports through `src/completion/index.ts` straight from the core
module and drop the guard from the public surface — there is no
documented or covered usage to preserve.
…s a value

The argv parser deliberately stops at `argv.length - 2` when consuming
an option's value so the word the user is currently completing never
gets recorded as `--opt`'s value. Add a test that asserts both
parsedArgs.config remains undefined and currentWord exposes the
in-flight token.
…ext additions

createDynamicCompleteCommand and parseCompletionContext both gained an
optional `globalArgsSchema` argument so resolvers attached to global
options remain reachable from any subcommand. CompletionContext also
exposes `parsedArgs` and `previousValues` for resolver consumption.
Reflect both in the API reference.
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 28, 2026

Open in StackBlitz

npm i https://pkg.pr.new/politty@353

commit: 612e5c3

Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@dqn dqn requested a review from toiroakr April 29, 2026 06:50
@toiroakr
Copy link
Copy Markdown
Owner

toiroakr commented May 7, 2026

This would reproduce the issue you fixed in #136, so I believe some kind of countermeasure is necessary even if we change it back to dynamic.

@dqn
Copy link
Copy Markdown
Collaborator Author

dqn commented May 9, 2026

This would reproduce the issue you fixed in #136, so I believe some kind of countermeasure is necessary even if we change it back to dynamic.

Keeping as-is: the static script only delegates to __complete for fields that explicitly opt into resolve — in src/completion/bash.ts the __<fn>_invoke_complete path is gated on hasDynamicCompletion(root) and only the case "dynamic": branch invokes it, so subcommand/option names, choices, shellCommand, file, directory, and enum-derived values all stay in-shell and #136's static-script speed is preserved for every TAB that isn't on a resolver-backed value. The Node spawn reappears only on the specific values that need parsedArgs, which is the trade the feature asks the consumer to opt into.

#349 keeps the static path itself fresh on upgrade, and this PR is meant to land on top of that — resolve is the explicit opt-in for the cases that need parsedArgs and can't be precomputed into the cache.

dqn added 11 commits May 20, 2026 02:42
Bake candidate tables into the static shell script at generation time
when sibling args (`dependsOn`) have a finite, known value set. The
shell dispatches via a case lookup on the runtime sibling values — no
Node process is spawned at TAB time, matching the latency of static
`choices`. Use this whenever the dependency graph collapses to a
build-time-known matrix; keep `resolve` for state the shell cannot
observe (filesystem, network, schema-of-the-day).

Generators: bash and zsh hoist per-spec associative arrays (declared
once at script load via `declare -gA` / `typeset -gA` array-literal
form so zsh evaluates `$'…'` keys correctly); fish emits an inline
`switch` (no associative arrays). zsh and fish preserve descriptions;
bash drops them. The main scan loop populates `_arg_values` so the
lookup can dispatch.

Validation throws at command-definition time when `dependsOn`
references a non-sibling, a sibling without static choices/enum, the
field itself, or is empty; when `expand` is mixed with `choices`,
`shellCommand`, or `resolve`; or when `enumerate` raises (the error is
wrapped with the offending field name and `deps` snapshot).
When `expand` is attached to a repeatable array option, the generated
shell script now tracks which `key=` prefixes the user already typed
and filters them from subsequent candidates. Restores the dedup that
the prior `resolve`-based implementation provided via
`previousValues.split("=")[0]`, without spawning a Node process on TAB.

- Plumb `isArrayOption` + `optionTokens` through `ExpandSpecLocation`.
- Emit a `__<fn>_track_array_expand` helper alongside `__track_opt`
  (separate function so case patterns don't collide when an option is
  both a dependsOn target and an array expand host).
- Filter candidates in bash/zsh expand value lines; statically guard
  each candidate in fish (no associative arrays).
- Document the behaviour under "Array option deduplication" in
  docs/shell-completion.md (`=` is the assumed delimiter; non-`=`
  candidates pass through untouched).
- Cover with unit assertions on each generator and shell-level e2e
  scenarios in bash/zsh/fish.
- Track sibling values along every alias-expanded subcommand path so
  expand dependencies resolve when the user typed an alias for the
  parent subcommand. `walk` now carries all path variants; bash, zsh,
  and fish tracker case patterns enumerate each one.
- Escape `:` (and `\`) in zsh `_describe` candidate values so URLs or
  namespaced identifiers do not get truncated at the value/description
  separator.
- Guard the bash expand lookup against an empty dependency key. Before,
  `cli -f <TAB>` (with the positional dep not yet typed) would
  dereference `${arr[]}` and bash raised `bad array subscript`, leaving
  COMPREPLY in a corrupted state.
- Record boolean flags (and their negations) in `parsedArgs` during
  context parsing so dynamic resolvers can switch candidates based on
  flag state. The positive form sets `true`; the negation form
  (`--no-foo` or a custom `negationDisplay`) sets `false`.
- Slice the zsh dynamic-completion delegate's argv to `words[2,CURRENT]`
  so tokens typed after the cursor (e.g. `cli -f <TAB> --other x`) no
  longer leak into the resolver as the trailing currentWord.
- Backslash-escape glob metacharacters (`*`, `?`, `[`, `]`) in fish
  `case` patterns. Without this, a key like `prod*` matches runtime
  values such as `production` and emits the wrong expand candidates.
- Recognize the implicit `--no-<cliName>` (and camelCase `--noCliName`)
  negation in `parseCompletionContext` for boolean fields even when the
  user has not opted into `negation: true`. The runtime parser already
  accepts those forms, so dynamic resolvers must see `parsedArgs.cache`
  flip to `false` for `--no-cache --field <TAB>`.
- Call `__track_pos` from the `_after_dd` branch in bash, zsh, and fish
  scanners so positional expand dependencies still resolve when the user
  inserts a `--` separator before the dependent positional.
- Drop the `@ext:` / `@matcher:` filter from the dynamic apply helper.
  Those markers come from the static shellCommand pipeline only, so
  filtering here would silently discard valid resolver candidates that
  happen to start with the literal prefixes.
- Carry `defaultNegationAccepted` on `CompletableOption` so the dynamic
  context parser can refuse implicit `--no-<cliName>` when the user set
  `negation: false` or supplied a custom-string negation (which the
  runtime parser already suppresses).
- Split `findOption` into an explicit-match pass followed by an
  implicit-negation pass so a real field literally named `noFoo` wins
  over flipping a sibling `foo` boolean.
`-f pageDirection=<TAB>` previously fed the partial cursor value into
`__track_array_expand`, marking the key as already used and filtering
the very candidates the user was trying to complete. Gate the tracker
in bash, zsh, and fish so the cursor token never enters the dedup
bucket; sibling-value tracking continues unchanged because overwriting
the latest value remains correct there.
… candidates

- Clear `_arg_values` (and `_used_field_keys`) when the scanner descends
  into a subcommand. `dependsOn` is scoped to the local frame, so
  letting a parent's sibling value bleed into a child with the same
  field name otherwise fed the wrong key to the child's expand lookup.
  Mirrored across bash, zsh, and fish.
- Stop early-returning from the zsh dynamic apply helper when the
  resolver requests `FileCompletion` / `DirectoryCompletion`. The
  helper now emits the resolver candidates via `__cdescribe` first and
  then layers `_files` on top, matching the bash/fish behaviour so
  resolvers returning candidates plus a directive are not silently
  dropped.
Splitting trackers into local and global buckets so values supplied
through `globalArgsSchema` survive subcommand descent — previously the
unconditional `_arg_values=()` reset wiped them.

- Carry `isGlobal` on `CompletableOption`, `TrackedFieldRef`, and
  `ExpandSpecLocation`; mark options derived from `globalArgsSchema`
  as global when extracting.
- Generate parallel `_global_arg_values` / `_global_used_field_keys`
  buckets in bash and zsh, and matching `_global_arg_values_<field>`
  / `_global_used_field_keys_<bucket>` per-field globals in fish.
- Tracker case bodies route writes to the bucket matching `t.isGlobal`
  / `spec.isGlobal`; the descent reset touches only the local bucket.
- Expand lookups fall back to the global bucket so a value written at
  a parent frame is still visible after the local bucket was cleared.
- Resolve each `dependsOn` entry's globality at codegen time and route
  the lookup to the matching bucket — local deps from `_arg_values`,
  global deps from `_global_arg_values`. Falling back across buckets
  let a sibling-named global value silently satisfy a missing local
  dep at a child frame. Mirrored across bash, zsh, and fish.
- Drop the global fallback from the array-dedup bucket lookup too —
  the host's scope decides the bucket exclusively.
- Make the scanner's inline-with-value branch match `-*=*` instead of
  only `--*=*`. The runtime parser accepts `-e=prod`, so the tracker
  has to split it before the dep value escapes detection.
dqn added 29 commits May 21, 2026 08:02
…2 compat

`compopt -o dirnames` is unavailable on bash 3.2 (macOS), so the fallback
silently leaked the top-level `complete -o default` registration and
listed files alongside directories. Always populate COMPREPLY from
`compgen -d` instead of branching on inline-prefix presence.
- parser: scan for --help/--version only before the `--` separator so
  `mycli __complete --shell bash -- foo --help` no longer surfaces
  builtin help instead of the resolver output.
- candidate-generator: limit the two-stage key=value collapse and
  NoSpace flip to `dynamic` and `expand` sources. `choices` and
  `shellCommand` values containing `=` (e.g. `foo=bar`) are concrete
  and must reach the shell unchanged, matching the static script paths.
When a dynamic resolver returns `DirectoryCompletion` and `compgen -d`
finds no matches, bash 3.2's missing `compopt` lets the top-level
`complete -o default` fall back to file completion, leaking files into
a dir-only directive. Seed `COMPREPLY=( "" )` like the NoFileCompletion
branch so the fallback is suppressed.
…Candidates

`__complete` previously did the stripping, so direct callers of the
public `parseCompletionContext()` + `generateCandidates()` API would
hand the resolver a `currentWord` of `--field=foo` instead of `foo`,
breaking the `DynamicCompletionContext` contract and the key=value
post-processing prefix filter. Move detection/strip into
`generateCandidates`'s option-value branch and expose
`detectInlineOptionPrefix` for the formatter side of `__complete`.
`setupExpandTestContext` adds a third full completion-script generation
in the top-level beforeAll, so each shell project blew past Vitest's
default 10s hook budget and aborted before any tests ran. Bump
`hookTimeout` to 60s for all three shell projects (testTimeout itself
stays at 10s).
The dynamic `__invoke_complete` branches that populate COMPREPLY via
`compgen -d`/`-f` did not set `-o filenames`, so candidates containing
spaces or other shell metacharacters were inserted unescaped — breaking
the command line for paths like `my dir/file`. Match the static file/
directory branches by toggling `-o filenames` on best-effort.
- `__<fn>_cdescribe` now forwards trailing compadd-pass-through options
  (e.g. `-S ''`) when `_describe` falls back to `compadd`. Without this,
  the two-stage `key=` fallback path silently re-added a trailing space
  for `-`-prefixed words.
- Drop `escapeDesc` from the zsh expand-table description tail. The
  surrounding `ansiC(...)` already handles shell-level escaping, and
  `_describe` only consumes the first unescaped `:` to split value vs.
  description — so the description tail must not pick up extra
  backslashes for `$`, `"`, `` ` ``, or `:` (which previously surfaced
  as e.g. `cost \$5` in the rendered description).
- Parenthesize the `FilterPrefix | NoFileCompletion` default in the
  resolver directive fallback so the precedence with `??` is explicit at
  the read site.
- Replace the per-candidate `math $_emitted + 1` counter in fish's
  `__apply_dynamic_output` with a 0/1 flag. The downstream branch only
  cares about "any candidate emitted yet?", so the counter shape was
  spending a `math` invocation per resolver candidate for no signal.
…ator

Make `??` vs `|` precedence explicit at the read site. The behaviour is
unchanged (bitwise `|` already binds tighter than `??`), but the prior
polish commit message promised this clarification and only landed the
fish-side change.
…on-resolver

# Conflicts:
#	tests/shell-completion/bash.test.ts
#	tests/shell-completion/fish.test.ts
#	tests/shell-completion/zsh.test.ts
@dqn dqn marked this pull request as ready for review May 21, 2026 11:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants