feat(completion): add in-process JS resolver for dynamic value completion#353
feat(completion): add in-process JS resolver for dynamic value completion#353dqn wants to merge 145 commits into
Conversation
…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.
commit: |
|
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 #349 keeps the static path itself fresh on upgrade, and this PR is meant to land on top of that — |
…on-resolver # Conflicts: # src/core/arg-registry.ts
…on-resolver # Conflicts: # tests/completion.test.ts
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.
…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
Add
completion.custom.resolveso 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 usesresolve.Usage
The resolver receives
currentWord,shell,parsedArgs(other args parsed so far),previousValues(already-supplied values for the same option), andsubcommandPath. It can return synchronously or via a Promise.Verification
End-to-end manual run against the sample CLI in this branch:
api GetApplication --fieldreturns endpoint-specific keysworkspaceId=,applicationName=api GetApplication --field=applifilters by inline prefix--field=applicationName=onlyapi CreateApplication -f cors=https://a -fde-dups via previousValuescorsexcluded, others returnedDirectoryCompletiondirective:32directive on the wire:(e.g.:hover,::before)COMPREPLY=(workspaceId= applicationName=):66(Error | NoFileCompletion), shell unaffectedMain Changes
completion.custom.resolveand theDynamicCompletionContext/DynamicCompletionResult/DynamicCompletionResolvertypes. MixingresolvewithchoicesorshellCommandthrows at command-definition time.parseCompletionContextnow collects best-effortparsedArgsandpreviousValuesfor the resolver and accepts an optionalglobalArgsSchemaso resolvers attached to global options remain reachable from any subcommand.__<fn>_invoke_completeand__<fn>_apply_dynamic_outputhelpers 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.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 internalrunis async to match.withCompletionCommandare unaffected.Notes
minorper the 0.x policy becausegenerateCandidatesandparseCompletionContextgained a required argument and an optional one respectively.MYCLI_BINenv 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./bin/bashshipped with macOS. Bothcompletion.custom.expandandcompletion.custom.resolveavoid bash 4 builtins — associative arrays are replaced with prefix-scalar variables, andmapfileis replaced with a portablewhile readloop. Seedocs/shell-completion.mdfor details.__completecommand is exempt fromglobalArgsSchemavalidation and from running globaleffectcallbacks. Shell scripts invoke__completeagainst 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
Details
Code coverage of files in pull request scope (90.5% → 95.3%)
Reported by octocov