From 0a5b2fb40fc502f15a80a1111a11bdefd65f406a Mon Sep 17 00:00:00 2001 From: Gregor Noczinski Date: Tue, 20 Oct 2026 15:26:27 +0200 Subject: [PATCH 01/14] Add context-aware Bash and Nushell completion --- .../actions/setup-rootless-podman/action.yml | 18 ++ .github/workflows/ci.yml | 28 +- .../workflows/update-mise-dependencies.yml | 4 + README.md | 27 +- _examples/README.md | 1 + _examples/completion/README.md | 54 ++++ _examples/completion/go.mod | 12 + _examples/completion/go.sum | 8 + _examples/completion/inventory.go | 78 +++++ _examples/completion/main.go | 51 +++ application-requirement.go | 78 ++++- application-requirement_create.go | 22 +- application_parse.go | 18 ++ argument-requirement.go | 36 ++- argument.go | 30 +- codec/completion.go | 260 ++++++++++++++++ codec/completion_handler.go | 141 +++++++++ codec/completion_handler_test.go | 79 +++++ codec/completion_test.go | 71 +++++ codec/doc.go | 57 ++++ codec/raw.go | 10 +- compile-node_create.go | 12 + completion.go | 126 ++++++++ completion_bash_input.go | 74 +++++ completion_command.go | 151 +++++++++ completion_command_test.go | 204 ++++++++++++ completion_example_test.go | 54 ++++ completion_handler.go | 13 + completion_handler_test.go | 119 +++++++ completion_protocol.go | 164 ++++++++++ completion_protocol_test.go | 116 +++++++ completion_scan.go | 293 ++++++++++++++++++ completion_shell.go | 245 +++++++++++++++ completion_shell_test.go | 144 +++++++++ completion_test.go | 262 ++++++++++++++++ completion_values.go | 84 +++++ definition-lifecycle.go | 14 +- doc.go | 106 ++++++- docs/development.md | 36 +++ flag-requirement.go | 43 ++- flag.go | 38 ++- go.mod | 6 +- internal/integration/cases_test.go | 30 ++ internal/integration/module_archive_test.go | 3 + internal/shelltestcmd/Containerfile | 11 + internal/shelltestcmd/bash.sh | 13 + internal/shelltestcmd/config.nu | 14 + internal/shelltestcmd/container_linux_test.go | 186 +++++++++++ internal/shelltestcmd/main.go | 139 +++++++++ internal/shelltestcmd/main_test.go | 31 ++ internal/shelltestcmd/terminal_linux_test.go | 136 ++++++++ internal/testdata/completion/main.go | 98 ++++++ invocation-lifecycle.go | 2 + invocation.go | 6 + mise.toml | 18 +- 55 files changed, 3999 insertions(+), 75 deletions(-) create mode 100644 .github/actions/setup-rootless-podman/action.yml create mode 100644 _examples/completion/README.md create mode 100644 _examples/completion/go.mod create mode 100644 _examples/completion/go.sum create mode 100644 _examples/completion/inventory.go create mode 100644 _examples/completion/main.go create mode 100644 codec/completion.go create mode 100644 codec/completion_handler.go create mode 100644 codec/completion_handler_test.go create mode 100644 codec/completion_test.go create mode 100644 completion.go create mode 100644 completion_bash_input.go create mode 100644 completion_command.go create mode 100644 completion_command_test.go create mode 100644 completion_example_test.go create mode 100644 completion_handler.go create mode 100644 completion_handler_test.go create mode 100644 completion_protocol.go create mode 100644 completion_protocol_test.go create mode 100644 completion_scan.go create mode 100644 completion_shell.go create mode 100644 completion_shell_test.go create mode 100644 completion_test.go create mode 100644 completion_values.go create mode 100644 internal/shelltestcmd/Containerfile create mode 100644 internal/shelltestcmd/bash.sh create mode 100644 internal/shelltestcmd/config.nu create mode 100644 internal/shelltestcmd/container_linux_test.go create mode 100644 internal/shelltestcmd/main.go create mode 100644 internal/shelltestcmd/main_test.go create mode 100644 internal/shelltestcmd/terminal_linux_test.go create mode 100644 internal/testdata/completion/main.go diff --git a/.github/actions/setup-rootless-podman/action.yml b/.github/actions/setup-rootless-podman/action.yml new file mode 100644 index 0000000..42b6784 --- /dev/null +++ b/.github/actions/setup-rootless-podman/action.yml @@ -0,0 +1,18 @@ +name: Set up rootless Podman +description: Prepare an unprivileged Podman runner for mandatory shell E2E tests +runs: + using: composite + steps: + - name: Install Podman prerequisites + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install --yes podman uidmap slirp4netns + if [[ -z "$(getsubids "$USER" 2>/dev/null)" ]]; then + sudo usermod --add-subuids 1000000-1065535 "$USER" + fi + if [[ -z "$(getsubids -g "$USER" 2>/dev/null)" ]]; then + sudo usermod --add-subgids 1000000-1065535 "$USER" + fi + [[ "$(podman info --format '{{.Host.Security.Rootless}}')" == true ]] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f0ad52..48bc023 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,6 +117,26 @@ jobs: retention-days: 1 if-no-files-found: warn + shell-e2e: + name: Shell E2E (rootless Podman) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install mise + uses: jdx/mise-action@9b200bcecbfd6303e69a4a261b2e09e3757440a7 # v5.2.0 + with: + version: 2026.9.18 + install: true + install_args: --locked + + - name: Set up rootless Podman + uses: ./.github/actions/setup-rootless-podman + + - name: Test Bash and Nushell in real terminals + run: mise run test:shells --engine podman + quality: name: Quality gate runs-on: ubuntu-latest @@ -193,7 +213,7 @@ jobs: finish: name: Finish - needs: [ lint, quality, test, coverage ] + needs: [ lint, quality, test, shell-e2e, coverage ] if: ${{ always() }} runs-on: ubuntu-latest steps: @@ -203,12 +223,14 @@ jobs: LINT_RESULT: ${{ needs.lint.result }} QUALITY_RESULT: ${{ needs.quality.result }} TEST_RESULT: ${{ needs.test.result }} + SHELL_E2E_RESULT: ${{ needs.shell-e2e.result }} COVERAGE_RESULT: ${{ needs.coverage.result }} run: | if [[ "$LINT_RESULT" != "success" || "$QUALITY_RESULT" != "success" || - "$TEST_RESULT" != "success" || + "$TEST_RESULT" != "success" || + "$SHELL_E2E_RESULT" != "success" || "$COVERAGE_RESULT" != "success" ]]; then - echo "::error::Required CI jobs did not all succeed: lint=${LINT_RESULT}, quality=${QUALITY_RESULT}, test=${TEST_RESULT}, coverage=${COVERAGE_RESULT}" + echo "::error::Required CI jobs did not all succeed: lint=${LINT_RESULT}, quality=${QUALITY_RESULT}, test=${TEST_RESULT}, shell-e2e=${SHELL_E2E_RESULT}, coverage=${COVERAGE_RESULT}" exit 1 fi diff --git a/.github/workflows/update-mise-dependencies.yml b/.github/workflows/update-mise-dependencies.yml index 27071f9..be34e80 100644 --- a/.github/workflows/update-mise-dependencies.yml +++ b/.github/workflows/update-mise-dependencies.yml @@ -50,6 +50,10 @@ jobs: echo "changed=true" >> "$GITHUB_OUTPUT" fi + - name: Set up rootless Podman + if: steps.update.outputs.changed == 'true' + uses: ./.github/actions/setup-rootless-podman + - name: Verify updates if: steps.update.outputs.changed == 'true' run: mise run check:full diff --git a/README.md b/README.md index 6d38197..70eb3dd 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # cli4g Typed command-line applications for Go: flags, arguments, subcommands, -environment values, automatic help, and invocation-owned resources. +environment values, automatic help, shell completion, and invocation-owned resources. ## Install @@ -78,7 +78,7 @@ Qualitative ratings for the [compared versions](_benchmarks): | Lifecycle and resource cleanup | ★★★ | ★★☆ | ★★☆ | ★★☆ | — | | Compile once, isolated concurrent parses | ★★★ | — | — | — | — | | Per-parse schema profiles | ★★★ | — | — | — | — | -| Shell completion | — | ★★★ | ★★★ | — | — | +| Shell completion | ★★☆ | ★★★ | ★★★ | — | — | | Setup/compile + first invocation speed[^performance] | ★★☆ | ★★☆ | ★★★ | ★☆☆ | ★★★ | | Parse/execute speed on reuse[^performance] | ★★★ | ★★☆ | ★★★ | ★☆☆ | ★★★ | @@ -97,6 +97,29 @@ includes rollback after partial initialization failures; hooks alone need applic See [benchmarks and methodology](docs/benchmarks.md) for timings, allocations, code size, and cognitive complexity. +## Shell completion + +Bash **4.4+** and Nushell **0.114+** adapters query the current binary, so binary +updates need no completion reinstall. Install once with: + +```shell +your-app completion install bash # Requires a configured bash-completion loader +your-app completion install nu # Autoloaded at the next interactive Nu startup +``` + +`WithCompletion(true)` selects `DefaultCompletionCommand` and +`DefaultCompletionRequestCommandName`. The public command is a normal command; +replace it with `WithCompletionCommand(...)`, or rename a fresh +`NewCompletionCommand().WithName(...)`. The special adapter entry is configured +independently with `WithCompletionRequestCommandName(...)`. Nil/empty disables +the respective part; `WithCompletion(false)` clears both. +Commands, flags, enums, and files work automatically. Use +[`codec.WithCompletionHandler`](https://pkg.go.dev/github.com/echocat/cli4g/codec#WithCompletionHandler) +for domain-specific values, or set a handler on an individual flag/argument. +See the [GoDoc guide](https://pkg.go.dev/github.com/echocat/cli4g#hdr-Shell_completion) +for API, installation, protocol, lifecycle, and performance details. +The [completion example](_examples/completion) shows this on the `ship` deployment tool's CLI. + ## Documentation - [Struct-tag/Annotations reference](docs/annotations.md) diff --git a/_examples/README.md b/_examples/README.md index 2b482e3..f147e7e 100644 --- a/_examples/README.md +++ b/_examples/README.md @@ -19,6 +19,7 @@ Run from the repository root with `mise example [arguments...]`. | [validation-errors](validation-errors) | Explicit validation markers, technical errors, and preserved causes | | [environment](environment) | CLI/environment/default priority | | [enums](enums) | Typed text/Value enums, enum tags, and choices in help and errors | +| [completion](completion) | The `ship` deployment tool with automatic completion and inventory-aware namespace/service suggestions | | [profiles](profiles) | Per-parse typed flag, argument, and command variants | | [repeatable-flags](repeatable-flags) | Collecting repeated flag values in a slice | | [date-time](date-time) | Date/DateTime codecs, custom layouts, and repeatable dates | diff --git a/_examples/completion/README.md b/_examples/completion/README.md new file mode 100644 index 0000000..9420369 --- /dev/null +++ b/_examples/completion/README.md @@ -0,0 +1,54 @@ +# Ship: plan a service deployment + +`ship` plans service deployments to development or production namespaces. A local inventory makes the example runnable without a cluster; the command prints its deployment plan. + +The options and `deploy` command are defined once in `main.go`. Completion uses that normal CLI definition: `WithCompletion(true)` selects the default public command and request entry, and `codec.WithCompletionHandler` adds inventory lookups to ordinary string codecs. + +## Run the command + +Run from the repository root: + +```shell +mise example completion deploy api +# Plan: api -> development/default (replicas=1, wait=false) + +mise example completion deploy checkout --context production --namespace payments --replicas 3 --wait +# Plan: checkout -> production/payments (replicas=3, wait=true) +``` + +Omitting `--context` uses `CLI4G_SHIP_CONTEXT`, then the `development` default. + +## Try interactive completion + +Build the example as `ship` (`ship.exe` on Windows), then make that executable available on `PATH`. The directory is named after the demonstrated feature; the application itself is a deployment tool: + +```shell +go -C _examples/completion build -o ship . +``` + +In **Bash 4.4+**, with a configured `bash-completion` loader: + +```bash +ship completion install bash +# At the prompt, type and press Tab: +# ship --context production deploy checkout --namespace pa +``` + +In **Nushell 0.114+**: + +```nu +ship completion install nu +# Start a new interactive Nu session, then type and press Tab: +# ship --context production --namespace payments deploy ch +``` + +The standard handler completes `deploy`, flag names, the `development`/`production` context enum, and bool values automatically. The two callbacks in `inventory.go` add domain knowledge: + +- Namespace suggestions depend on the context and the selected service. +- Service suggestions depend on the context and namespace. + +cli4g supplies the complete input context, filters prefixes, and handles shell output. The callbacks return plain values; the same inventory is used by the normal deployment handler. A real client lookup would use the supplied cancellation context. Installed adapters continue to work after binary updates. + +The public setup command is the ordinary `DefaultCompletionCommand`, initially named `completion`. It uses the same lifecycle and parent requirements as `deploy`. Applications can replace it with `WithCompletionCommand(...)`, including a fresh `NewCompletionCommand().WithName("shells")`. Only the separate request entry (initially `__complete`) bypasses normal validation and initialization. Configure it with `WithCompletionRequestCommandName`; empty disables it. + +See the [GoDoc guide](https://pkg.go.dev/github.com/echocat/cli4g#hdr-Shell_completion) for API and setup details. diff --git a/_examples/completion/go.mod b/_examples/completion/go.mod new file mode 100644 index 0000000..9b68cf4 --- /dev/null +++ b/_examples/completion/go.mod @@ -0,0 +1,12 @@ +module github.com/echocat/cli4g/_examples/completion + +go 1.27.0 + +replace github.com/echocat/cli4g => ../.. + +require github.com/echocat/cli4g v0.0.0 + +require ( + golang.org/x/sys v0.49.0 // indirect + golang.org/x/term v0.47.0 // indirect +) diff --git a/_examples/completion/go.sum b/_examples/completion/go.sum new file mode 100644 index 0000000..30632a7 --- /dev/null +++ b/_examples/completion/go.sum @@ -0,0 +1,8 @@ +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= +golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= diff --git a/_examples/completion/inventory.go b/_examples/completion/inventory.go new file mode 100644 index 0000000..71e17a7 --- /dev/null +++ b/_examples/completion/inventory.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "slices" + + "github.com/echocat/cli4g/codec" +) + +// A real deployment tool could load this inventory from a cluster client. +// The local data keeps both planning and shell suggestions self-contained. +var inventory = map[string]map[string][]string{ + "development": { + "default": {"api", "worker"}, + "payments": {"checkout", "billing"}, + }, + "production": { + "default": {"api"}, + "payments": {"checkout", "billing"}, + "platform": {"gateway", "worker"}, + }, +} + +func serviceExists(cluster, namespace, service string) bool { + return slices.Contains(inventory[cluster][namespace], service) +} + +func completeNamespaces(ctx context.Context, input codec.CompletionInput) (codec.CompletionResult, error) { + cluster, err := completionFlag(ctx, input, "context") + if err != nil { + return codec.CompletionResult{}, err + } + service := "" + if values := input.Arguments["service"]; len(values) > 0 { + service = values[0] + } + result := codec.CompletionResult{Handled: true} + for namespace, services := range inventory[cluster] { + if err := ctx.Err(); err != nil { + return codec.CompletionResult{}, err + } + if service == "" || slices.Contains(services, service) { + result.Candidates = append(result.Candidates, codec.CompletionCandidate{ + Value: namespace, Description: "Namespace in " + cluster, + }) + } + } + return result, nil +} + +func completeServices(ctx context.Context, input codec.CompletionInput) (codec.CompletionResult, error) { + cluster, err := completionFlag(ctx, input, "context") + if err != nil { + return codec.CompletionResult{}, err + } + namespace, err := completionFlag(ctx, input, "namespace") + if err != nil { + return codec.CompletionResult{}, err + } + result := codec.CompletionResult{Handled: true} + for _, service := range inventory[cluster][namespace] { + if err := ctx.Err(); err != nil { + return codec.CompletionResult{}, err + } + result.Candidates = append(result.Candidates, codec.CompletionCandidate{ + Value: service, Description: "Service in " + namespace, + }) + } + return result, nil +} + +func completionFlag(ctx context.Context, input codec.CompletionInput, name string) (string, error) { + value, _, err := input.Effective.Flag(ctx, name) + if err != nil || len(value.Values) == 0 { + return "", err + } + return value.Values[len(value.Values)-1], nil +} diff --git a/_examples/completion/main.go b/_examples/completion/main.go new file mode 100644 index 0000000..9fd184a --- /dev/null +++ b/_examples/completion/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/echocat/cli4g" + "github.com/echocat/cli4g/codec" +) + +type options struct { + Context string `flag:"context|cluster" short:"c" enum:"development|production" env:"CLI4G_SHIP_CONTEXT" default:"development" description:"Cluster context."` + Namespace string `flag:"namespace|ns" short:"n" type:"namespace" default:"default" description:"Namespace to use."` +} + +type deployOptions struct { + Service string `argument:"service" type:"service" required:"true" description:"Service to deploy."` + Replicas uint32 `flag:"replicas" short:"r" default:"1" description:"Desired replica count."` + Wait bool `flag:"wait" description:"Wait for the planned rollout."` +} + +func (opts deployOptions) Validate(context.Context) error { + if opts.Replicas == 0 { + return cli.NewValidationError("replicas must be greater than zero") + } + return nil +} + +func main() { + cli.NewApplication[options]("ship", "Plan service deployments to a cluster."). + WithCompletion(true). + WithCodec(codec.WithCompletionHandler(codec.NewString(), completeNamespaces), "namespace"). + WithCodec(codec.WithCompletionHandler(codec.NewString(), completeServices), "service"). + WithCommand(cli.NewCommand[deployOptions]("deploy", "Plan a service deployment."). + WithHandler(planDeployment), + ). + MustCreateParseAndExecute(context.Background(), nil, os.Args[1:]...) +} + +func planDeployment(ctx context.Context, opts deployOptions) error { + application, found := cli.ApplicationFromContext[options](ctx) + if !found { + return fmt.Errorf("application options are unavailable") + } + if !serviceExists(application.Context, application.Namespace, opts.Service) { + return cli.NewValidationError("service is unavailable in the selected namespace") + } + return cli.Printf(ctx, "Plan: %s -> %s/%s (replicas=%d, wait=%t)\n", + opts.Service, application.Context, application.Namespace, opts.Replicas, opts.Wait) +} diff --git a/application-requirement.go b/application-requirement.go index c17adb8..2646262 100644 --- a/application-requirement.go +++ b/application-requirement.go @@ -12,6 +12,7 @@ import ( // O must be a struct or pointer to a struct; exported tags define inputs. // By default, input and execution errors use [DefaultErrorHandler], and missing // context streams fall back to process stdin/stdout/stderr. MaxCommandDepth is 7. +// Completion is opt-in through [ApplicationRequirement.WithCompletion]. // The first optional string sets the name; further strings form the description, // joined with spaces. With no strings, configure the name later; Create validates // the completed builder. @@ -36,15 +37,23 @@ type ApplicationRequirement[O any] struct { Description string // Help enables automatic --help/-h throughout the command tree. Constructors // enable it by default; a zero-valued requirement has it disabled. - Help bool - HelpHandler HelpHandler - ErrorHandler ErrorHandler - errorHandlerDisabled bool + Help bool + // CompletionCommand is an optional ordinary root command, compiled alongside + // Commands. Nil means absent; Create never substitutes a default. Configure it + // directly or use WithCompletion(true) to select DefaultCompletionCommand. + CompletionCommand CommandRequirementLike + // CompletionRequestCommandName independently enables the special adapter entry + // point. Empty disables it; Create never substitutes a default. Keep a configured + // name stable across binary updates. This entry bypasses the options lifecycle. + CompletionRequestCommandName string + HelpHandler HelpHandler + ErrorHandler ErrorHandler + errorHandlerDisabled bool // FallbackToStdio supplies process stdin/stdout/stderr only when no stream // configuration exists in the operation's context. Constructors enable it by default; // a zero-valued requirement has it disabled. FallbackToStdio bool - // MaxCommandDepth limits subcommand levels below the application (depth 0). + // MaxCommandDepth limits user-defined subcommand levels below the application (depth 0). // Zero permits only the application; constructors default to 7. Create checks // every definition, including inactive profiles, and snapshots the limit. MaxCommandDepth uint8 @@ -88,6 +97,65 @@ func (this *ApplicationRequirement[O]) WithHelp(v bool) *ApplicationRequirement[ func (this *ApplicationRequirement[O]) SetHelp(v bool) { this.Help = v } +// WithCompletion is a shortcut for configuring the public command and special +// request entry. True assigns the current DefaultCompletionCommand and +// DefaultCompletionRequestCommandName; false clears both, including custom values. +// Call this before individual overrides. There is no separate enabled state, and +// neither value is implicitly restored during Create. The direct Complete API +// remains available independently of both settings. +// +// The public command is an ordinary command: parent input requirements, lifecycle, +// profiles, help and depth limits apply normally. Only the request entry bypasses +// parsing/validation/initialization. The standard handler derives suggestions from +// the existing CLI schema and optional flag/argument/codec completion handlers. +func (this *ApplicationRequirement[O]) WithCompletion(v bool) *ApplicationRequirement[O] { + this.SetCompletion(v) + return this +} + +// SetCompletion is the setter counterpart of WithCompletion. +func (this *ApplicationRequirement[O]) SetCompletion(v bool) { + if v { + this.CompletionCommand = DefaultCompletionCommand + this.CompletionRequestCommandName = DefaultCompletionRequestCommandName + } else { + this.CompletionCommand = nil + this.CompletionRequestCommandName = "" + } +} + +// WithCompletionCommand configures an ordinary public management command. +// Nil removes it. Use NewCompletionCommand().WithName("shells") to customize the +// standard command independently, or supply any CommandRequirement with your own +// options and handlers. Its name belongs to that command, not a separate setting. +// The request entry is unaffected. Create compiles the command normally without +// modifying the requirement or adding it permanently to Commands. +func (this *ApplicationRequirement[O]) WithCompletionCommand(v CommandRequirementLike) *ApplicationRequirement[O] { + this.SetCompletionCommand(v) + return this +} + +// SetCompletionCommand is the setter counterpart of WithCompletionCommand. +func (this *ApplicationRequirement[O]) SetCompletionCommand(v CommandRequirementLike) { + this.CompletionCommand = v +} + +// WithCompletionRequestCommandName names the hidden adapter request command. +// Empty disables it. True in WithCompletion selects the current default variable. +// Generated adapters use the captured name, so choose it before installation and +// retain it across upgrades. It must be a single command word, distinct from all +// root commands/aliases, including inactive profiles. This out-of-band entry is +// not registered as a command and is absent from Commands(), help and suggestions. +func (this *ApplicationRequirement[O]) WithCompletionRequestCommandName(v string) *ApplicationRequirement[O] { + this.SetCompletionRequestCommandName(v) + return this +} + +// SetCompletionRequestCommandName is the setter counterpart of WithCompletionRequestCommandName. +func (this *ApplicationRequirement[O]) SetCompletionRequestCommandName(v string) { + this.CompletionRequestCommandName = v +} + // WithFallbackToStdio controls fallback to process stdin/stdout/stderr when the // context has no streams. NewApplication enables it by default. Explicit streams // from [codec.ContextWithStdio], including nil streams, always take precedence. diff --git a/application-requirement_create.go b/application-requirement_create.go index 72a78fe..f4060bf 100644 --- a/application-requirement_create.go +++ b/application-requirement_create.go @@ -17,7 +17,11 @@ func (r *ApplicationRequirement[O]) Create() (_ *Application[O], err error) { if r == nil || r.Name == "" { return nil, errSchema("application needs a name") } - definition := &definitionLifecycle{fallbackToStdio: r.FallbackToStdio, maxCommandDepth: r.MaxCommandDepth} + definition := &definitionLifecycle{fallbackToStdio: r.FallbackToStdio, maxCommandDepth: r.MaxCommandDepth, + completionRequestCommandName: r.CompletionRequestCommandName} + if err := validateCompletionRequestName(definition.completionRequestCommandName); err != nil { + return nil, fmt.Errorf("application %q: %w", r.Name, err) + } defer func() { if err != nil { err = errors.Join(err, definition.Close()) @@ -68,7 +72,11 @@ func (r *ApplicationRequirement[O]) Create() (_ *Application[O], err error) { definition.errorHandler = app.errorHandler root := &compileNode{draft: draft, container: app} active := make(map[commandRequirementIdentity]bool) - for _, requirement := range r.Commands { + commands := r.Commands + if r.CompletionCommand != nil { + commands = append(slices.Clone(commands), r.CompletionCommand) + } + for index, requirement := range commands { child, err := createRequirementNode(requirement, app.commands, []containerSchema{draft.schema}, active, r.MaxCommandDepth) if err != nil { if depth, ok := err.(*commandDepthError); ok { @@ -78,7 +86,17 @@ func (r *ApplicationRequirement[O]) Create() (_ *Application[O], err error) { } root.children = append(root.children, child) command := child.container.(CommandLike) + if definition.completionRequestCommandName != "" { + for _, name := range command.Names() { + if name == definition.completionRequestCommandName { + return nil, errSchema("application %q: command name or alias %q is reserved for completion requests", r.Name, name) + } + } + } app.commands = append(app.commands, command) + if index == len(r.Commands) { + definition.completionCommand = command + } if len(command.Profiles()) == 0 { for _, name := range command.Names() { app.commandsByName[name] = command diff --git a/application_parse.go b/application_parse.go index 144b3df..0adc48c 100644 --- a/application_parse.go +++ b/application_parse.go @@ -21,6 +21,9 @@ import ( // Direct Execute calls from parse hooks share this operation's error boundary. // Context streams are available throughout parsing and the options lifecycle. // File aliases resolve now; pass the same stream context to Execute. +// A configured completion request name in the first token instead creates a +// metadata-only invocation without constructing options. The public completion +// command uses the ordinary lifecycle. Use -- before a literal request-name value. func (this *Application[O]) Parse(ctx context.Context, opts *ParseOpts, vs ...string) (_ *Invocation[O], err error) { ctx, err = this.prepareParseContext(ctx) if err != nil { @@ -42,6 +45,21 @@ func (this *Application[O]) prepareParseContext(ctx context.Context) (context.Co // The operation boundary prepares streams exactly once, before parsing or // managed execution. The incoming error-boundary marker remains unchanged. func (this *Application[O]) parsePrepared(ctx context.Context, opts *ParseOpts, vs ...string) (_ *Invocation[O], err error) { + if this.definition.completionRequestCommandName != "" && len(vs) > 0 && vs[0] == this.definition.completionRequestCommandName { + invocation := this.newMetadataInvocation(nil) + invocation.lifecycle.ready = true + args := slices.Clone(vs[1:]) + var captured *ParseOpts + if opts != nil { + copy := *opts + copy.Profiles = slices.Clone(opts.Profiles) + captured = © + } + invocation.lifecycle.operation = func(ctx context.Context) error { + return this.queryCompletion(ctx, captured, args) + } + return invocation, nil + } var input parseInput var profiles selectedProfiles if opts != nil { diff --git a/argument-requirement.go b/argument-requirement.go index 458496f..a6502fa 100644 --- a/argument-requirement.go +++ b/argument-requirement.go @@ -38,6 +38,9 @@ type ArgumentRequirement[V, R any] struct { Required bool Placeholder string Codec codec.Codec[V, R] + // CompletionHandler optionally overrides value suggestions for this argument. + // Returning Handled=false delegates to the codec and automatic completion. + CompletionHandler CompletionHandler // CodecFactory supplies a codec at Create when Codec is nil. It configures // schema only; it must not acquire invocation resources. CodecFactory func() (codec.Codec[V, R], error) @@ -59,15 +62,16 @@ func (this ArgumentRequirement[V, R]) createRaw() (*Argument[any, any], error) { return nil, fmt.Errorf("argument %q: %w", this.Name, err) } return &Argument[any, any]{ - name: this.Name, - profiles: slices.Clone(this.Profiles), - description: this.Description, - required: this.Required, - placeholder: this.Placeholder, - codec: snapshot, - valueType: reflect.TypeFor[V](), - fieldPath: slices.Clone(this.Field), - defaultValue: rawDefault(this.defaultValue), + name: this.Name, + profiles: slices.Clone(this.Profiles), + description: this.Description, + required: this.Required, + placeholder: this.Placeholder, + codec: snapshot, + completionHandler: this.CompletionHandler, + valueType: reflect.TypeFor[V](), + fieldPath: slices.Clone(this.Field), + defaultValue: rawDefault(this.defaultValue), }, nil } @@ -156,6 +160,20 @@ func (this *ArgumentRequirement[V, R]) SetPlaceholder(v string) { this.Placeholder = v } +// WithCompletionHandler sets a context-aware value handler for this argument +// without changing its parsing codec or CLI syntax. It takes precedence over the +// codec completer; Handled=false delegates. A nil handler restores codec/automatic +// completion. Create captures the callback; it must be concurrent-safe. +func (this *ArgumentRequirement[V, R]) WithCompletionHandler(v CompletionHandler) *ArgumentRequirement[V, R] { + this.SetCompletionHandler(v) + return this +} + +// SetCompletionHandler is the setter counterpart of WithCompletionHandler. +func (this *ArgumentRequirement[V, R]) SetCompletionHandler(v CompletionHandler) { + this.CompletionHandler = v +} + // WithCodec replaces the decoder and clears [ArgumentRequirement.WithCodecFactory]. func (this *ArgumentRequirement[V, R]) WithCodec(v codec.Codec[V, R]) *ArgumentRequirement[V, R] { this.SetCodec(v) diff --git a/argument.go b/argument.go index 45dcb09..931fb21 100644 --- a/argument.go +++ b/argument.go @@ -11,18 +11,19 @@ import ( // Argument describes a positional input. Use [Invocation.Argument] for parsed V // and [Argument.Default] for its resource-free default R. type Argument[V, R any] struct { - name string - profiles []string - description string - required bool - placeholder string - codec codec.Codec[V, R] - valueType reflect.Type - fieldPath []string - binding *fieldBinding - arity valueArity - defaultValue *valueDefault[R] - index int // position in the compiled container's argument definitions + name string + profiles []string + description string + required bool + placeholder string + codec codec.Codec[V, R] + completionHandler CompletionHandler + valueType reflect.Type + fieldPath []string + binding *fieldBinding + arity valueArity + defaultValue *valueDefault[R] + index int // position in the compiled container's argument definitions } // Profiles returns a copy of the explicit profile assignments; empty means always active. @@ -50,6 +51,11 @@ func (this *Argument[V, R]) Codec() codec.Codec[V, R] { return this.codec } +// CompletionHandler returns the explicitly configured argument handler, or nil +// when value completion uses only the codec and standard handler. Codec returns +// the effective codec, including the captured argument override. +func (this *Argument[V, R]) CompletionHandler() CompletionHandler { return this.completionHandler } + func (this *Argument[V, R]) HasDefault() bool { return this.defaultValue != nil } // Default returns an independent resource-free requirement R, never a runtime V. diff --git a/codec/completion.go b/codec/completion.go new file mode 100644 index 0000000..02bbbfa --- /dev/null +++ b/codec/completion.go @@ -0,0 +1,260 @@ +package codec + +import ( + "context" + "reflect" + "slices" + "strings" +) + +// Completer optionally supplies context-dependent input suggestions for a codec. +// Unlike Enum, its suggestions need not be exhaustive, stable, or independent of +// other inputs. Implementations may read configuration or query external systems. +// They must honor ctx, close resources acquired for the request, be safe for +// concurrent calls, and leave input unchanged. Completion never calls Resolve or +// application initialization. Keep expensive work out of codec constructors and metadata +// methods: Complete is called only when this codec's value is being completed. +// +// Return Handled=true even for an authoritative empty answer. Handled=false asks +// the framework to try enum metadata and then its ordinary fallback. Errors are +// returned to API callers; shell adapters suppress them to keep editing usable. +// Custom codecs implementing ToRaw must use Raw(ctx, wrapper), so this capability +// survives type erasure. See Complete for enum and collection behavior. +type Completer interface { + Complete(ctx context.Context, input CompletionInput) (CompletionResult, error) +} + +// CompletionFunc adapts a function to Completer. +type CompletionFunc func(context.Context, CompletionInput) (CompletionResult, error) + +// Complete invokes f. +func (f CompletionFunc) Complete(ctx context.Context, input CompletionInput) (CompletionResult, error) { + return f(ctx, input) +} + +// CompletionKind identifies the syntactic site being completed. +type CompletionKind string + +const ( + CompletionCommand CompletionKind = "command" // A command name or positional input. + CompletionFlag CompletionKind = "flag" // A flag name, not its value. + CompletionFlagValue CompletionKind = "flag-value" // A value belonging to a flag. + CompletionArgument CompletionKind = "argument" // A positional argument's value. +) + +// CompletionTarget identifies an input independently of aliases used on the +// command line. Scope indexes CompletionInput.Scopes. Occurrence is zero-based +// for repeated flags; ValueIndex is zero-based within that occurrence or named +// argument. Name is canonical and omits leading dashes. +type CompletionTarget struct { + Kind CompletionKind // Syntactic completion site. + Name string // Canonical flag/argument name, empty for command/unknown input. + Scope int // Owner's index in CompletionInput.Scopes. + Occurrence int + ValueIndex int +} + +// CompletionOccurrence preserves one explicit flag occurrence. Values are +// shell-tokenized but not codec-decoded. Missing means the occurrence has fewer +// than its required number of tokens. A present bare bool has no Values and is +// not Missing; --name="" has one empty token. Word indexes CompletionInput.Words. +type CompletionOccurrence struct { + Values []string + Missing bool + Word int +} + +// CompletionScope is the input snapshot of one selected container. CommandPath +// includes the application and canonical command names. Maps use canonical names +// and contain explicit inputs only. Positionals preserves all positional tokens, +// including excess input; Arguments groups tokens assigned to known definitions. +type CompletionScope struct { + CommandPath []string + Flags map[string][]CompletionOccurrence + Arguments map[string][]string + Positionals []string +} + +// CompletionSource identifies the provenance of an effective input. +type CompletionSource string + +const ( + CompletionCLI CompletionSource = "cli" + CompletionEnvironment CompletionSource = "environment" + CompletionDefault CompletionSource = "default" +) + +// CompletionValue contains effective, resource-free input tokens. CLI and +// environment tokens use their ordinary input usage; default tokens are encoded +// for UsageDefault, including structured typed defaults. Environment names the +// selected environment variable, if any. No runtime value is resolved. +type CompletionValue struct { + Source CompletionSource // CLI, environment, or declared default. + Values []string // Unresolved tokens in source order. + Environment string // Selected environment variable, otherwise empty. +} + +// CompletionValues lazily reads effective dependencies of a completion. Flag +// follows CLI > environment > declared default, accepting canonical names and +// long aliases without leading dashes. Argument follows CLI > declared default +// in the deepest selected container. +// An explicit but incomplete CLI occurrence still wins. Factory and runtime +// values are unavailable because completion does not construct options. Returned +// tokens are independent copies. Lookup errors affect only the requested value. +type CompletionValues interface { + Flag(context.Context, string) (CompletionValue, bool, error) + Argument(context.Context, string) (CompletionValue, bool, error) +} + +// CompletionInput is an invocation-local, best-effort input snapshot. Other +// inputs may be missing, invalid, repeated, or positioned after the cursor. +// Words excludes the executable, includes the current word (possibly empty), and +// preserves otherwise unassigned tokens. Word identifies that word. Prefix and +// Suffix are the codec's portion before/after the cursor, without --flag= or a +// short-flag prefix. Values holds codec-local tokens, including the incomplete +// token at ValueIndex. Collection adapters rebase Values/ValueIndex for their +// element codec while preserving Target and all original scope data. CommandPath +// includes the application; Profiles is sorted. +// +// Flags is the visible canonical-name projection of Scopes. Arguments and +// Positionals refer to the deepest scope. Effective is optional for standalone +// codec callers and provides lazy environment/default access. Snapshot data is +// read-only for the callback and never stored on a shared codec instance. +type CompletionInput struct { + Words []string // All supplied, shell-tokenized words without the executable. + Word int // Current word's index in Words. + Prefix string // Codec-local text before the cursor. + Suffix string // Codec-local text after the cursor. + Values []string // Codec-local value tokens, including the current token. + ValueIndex int // Current index in Values; may differ from Target.ValueIndex for elements. + Target CompletionTarget // Canonical identity and value index. + CommandPath []string // Application followed by canonical commands. + Profiles []string // Active, sorted and deduplicated schema profiles. + Scopes []CompletionScope // Application scope first, selected leaf last. + Flags map[string][]CompletionOccurrence // Visible, explicitly supplied flags. + Arguments map[string][]string // Explicit named arguments in the leaf scope. + Positionals []string // Leaf positional tokens in input order, including excess input. + Effective CompletionValues // Lazy dependency lookup; nil in standalone calls if omitted. +} + +// CompletionCandidate is one unquoted input token and optional menu description. +// Value is inserted literally: do not shell-escape it. Description is display +// text only. Adapters handle shell quoting; the CLI core prefix-filters values. +type CompletionCandidate struct { + Value string `json:"value"` + Description string `json:"description,omitempty"` +} + +// CompletionResult describes suggestions and shell fallback behavior. The zero +// value is unhandled. Handled=true with no candidates suppresses fallbacks. +// Files requests native path completion; Directories restricts it to directories. +// FilePrefix is prepended to path suggestions (for example "key="). NoSpace +// suppresses the usual trailing space; KeepOrder preserves provider order. +// FileInput is filled by the CLI core with the unquoted path search prefix. +// Candidates and file fallback may be combined. Results contain data only. +type CompletionResult struct { + Candidates []CompletionCandidate `json:"candidates"` + Handled bool `json:"handled"` + Files bool `json:"files,omitempty"` + Directories bool `json:"directories,omitempty"` + FilePrefix string `json:"filePrefix,omitempty"` + FileInput string `json:"fileInput,omitempty"` + NoSpace bool `json:"noSpace,omitempty"` + KeepOrder bool `json:"keepOrder,omitempty"` +} + +// Complete asks a codec for suggestions without parsing or resolving other +// inputs. An authoritative Completer answer wins. Otherwise actual Enum variants +// are formatted as input tokens, never as potentially redacted display text. +// Multi-token enums match preceding Values and complete ValueIndex. +// Raw, NewSlice, and NewMap preserve this operation; maps delegate only the value +// after the first '='. No EnumChoices display projection is inserted as input. +func Complete[V, R any](ctx context.Context, source Codec[V, R], input CompletionInput) (CompletionResult, error) { + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return CompletionResult{}, err + } + if provider, ok := source.(Completer); ok { + result, err := provider.Complete(ctx, input) + if err != nil { + return CompletionResult{}, SanitizeError(err, "completion could not be generated") + } + if err := ctx.Err(); err != nil { + return CompletionResult{}, err + } + if result.Handled { + result.Candidates = slices.Clone(result.Candidates) + return result, nil + } + } + enumeration, ok := source.(Enum[R]) + if !ok { + if reflect.TypeFor[V]().Kind() == reflect.String { + return CompletionResult{Handled: true, Files: true}, nil + } + return CompletionResult{}, nil + } + variants, err := enumeration.EnumVariants(ctx) + if err != nil { + return CompletionResult{}, SanitizeError(err, "enum completion could not be read") + } + result := CompletionResult{Handled: true, KeepOrder: true} + index := input.ValueIndex + for _, variant := range variants { + if err := ctx.Err(); err != nil { + return CompletionResult{}, err + } + values, err := source.FormatRequirement(ctx, variant.Value) + if err != nil { + return CompletionResult{}, SanitizeError(err, "enum completion could not be formatted") + } + if index < 0 || index >= len(values) || len(input.Values) < index || !slices.Equal(input.Values[:index], values[:index]) { + continue + } + result.Candidates = append(result.Candidates, CompletionCandidate{Value: values[index], Description: variant.Description}) + } + return result, ctx.Err() +} + +func (this raw[V, R]) Complete(ctx context.Context, input CompletionInput) (CompletionResult, error) { + return Complete(ctx, this.source, input) +} + +func (this slice[V, R]) Complete(ctx context.Context, input CompletionInput) (CompletionResult, error) { + input.ValueIndex = 0 + input.Values = []string{input.Prefix + input.Suffix} + return Complete(ctx, this.element, input) +} + +func (this mapCodec[K, V, R]) Complete(ctx context.Context, input CompletionInput) (CompletionResult, error) { + key, value, found := strings.Cut(input.Prefix, "=") + if !found { + return CompletionResult{Handled: true}, ctx.Err() + } + input.Prefix = value + input.ValueIndex = 0 + input.Values = []string{value + input.Suffix} + result, err := Complete(ctx, this.element, input) + if err != nil { + return CompletionResult{}, err + } + if !result.Handled { + result.Handled = true + } + for i := range result.Candidates { + result.Candidates[i].Value = key + "=" + result.Candidates[i].Value + } + result.FilePrefix = key + "=" + result.FilePrefix + return result, nil +} + +func (boolean) Complete(ctx context.Context, _ CompletionInput) (CompletionResult, error) { + return CompletionResult{Handled: true, Candidates: []CompletionCandidate{{Value: "true"}, {Value: "false"}}}, ctx.Err() +} + +// Complete requests native path completion without opening any files. +func (FileCodec) Complete(ctx context.Context, _ CompletionInput) (CompletionResult, error) { + return CompletionResult{Handled: true, Files: true}, ctx.Err() +} diff --git a/codec/completion_handler.go b/codec/completion_handler.go new file mode 100644 index 0000000..3a90e56 --- /dev/null +++ b/codec/completion_handler.go @@ -0,0 +1,141 @@ +package codec + +import ( + "context" + "fmt" + "reflect" + "slices" +) + +// WithCompletionHandler attaches a context-aware value handler to an existing +// codec without requiring a new codec type or ToRaw method. For example: +// +// codec.WithCompletionHandler(codec.NewString(), completeNamespaces) +// +// Parsing, validation, defaults and resource resolution retain the source codec's +// behavior. Enum metadata, collection accumulation, bool flags and type binding +// remain available. The handler runs only for value completion, never while +// creating the definition, rendering help or parsing an ordinary invocation. +// +// Handled=true is authoritative, including an empty answer. Handled=false delegates +// to the source codec's completer and automatic enum/bool/path suggestions. Return +// literal, unquoted tokens; the framework filters the prefix and the shell quotes +// inserted values. The handler must honor cancellation and be concurrent-safe. +// A nil handler returns source unchanged. A nil source panics, like NewSlice. +func WithCompletionHandler[V, R any](source Codec[V, R], handler CompletionFunc) Codec[V, R] { + if isNil(source) { + panic("completion handler requires a codec") + } + if handler == nil { + return source + } + result := completingCodec[V, R]{Codec: source, handler: handler} + if enumeration, ok := source.(Enum[R]); ok { + return completingEnum[V, R]{completingCodec: result, enumeration: enumeration} + } + return result +} + +type completingCodec[V, R any] struct { + Codec[V, R] + handler CompletionFunc +} + +func (this completingCodec[V, R]) ToRaw(ctx context.Context) Codec[any, any] { + return Raw(ctx, this) +} + +func (this completingCodec[V, R]) Complete(ctx context.Context, input CompletionInput) (CompletionResult, error) { + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return CompletionResult{}, err + } + result, err := this.handler(ctx, input) + if err != nil { + return CompletionResult{}, SanitizeError(err, "completion handler failed") + } + if err := ctx.Err(); err != nil { + return CompletionResult{}, err + } + if result.Handled { + result.Candidates = slices.Clone(result.Candidates) + return result, nil + } + return Complete(ctx, this.Codec, input) +} + +func (this completingCodec[V, R]) CloneRequirement(ctx context.Context, value R) (R, error) { + return CloneRequirement(ctx, this.Codec, value) +} + +func (this completingCodec[V, R]) RequirementType() reflect.Type { + typ := reflect.TypeFor[R]() + if typ.Kind() == reflect.Interface { + if source, ok := this.Codec.(interface{ RequirementType() reflect.Type }); ok { + return source.RequirementType() + } + } + return typ +} + +func (this completingCodec[V, R]) EnumChoices(ctx context.Context) ([]EnumChoice, error) { + return DisplayEnum(ctx, this.Codec) +} + +func (this completingCodec[V, R]) IsCumulative() bool { return IsCumulative(this.Codec) } + +func (this completingCodec[V, R]) ParseRequirementInto(ctx context.Context, usage Usage, previous R, args ...string) (R, error) { + if source, ok := this.Codec.(Cumulative[R]); ok { + return source.ParseRequirementInto(ctx, usage, previous, args...) + } + return previous, fmt.Errorf("%w: codec has no cumulative requirement parser", ErrInvalid) +} + +func (this completingCodec[V, R]) IsBoolFlag() bool { + if reflect.TypeFor[V]().Kind() == reflect.Bool { + return true + } + source, ok := this.Codec.(interface{ IsBoolFlag() bool }) + return ok && source.IsBoolFlag() +} + +func (this completingCodec[V, R]) BindType(ctx context.Context, typ reflect.Type) (Codec[any, any], bool, error) { + if source, ok := this.Codec.(TypeBinder); ok { + bound, matched, err := source.BindType(ctx, typ) + if err == nil { + err = ctx.Err() + } + if err != nil || !matched { + return nil, matched, err + } + if isNil(bound) { + return nil, false, fmt.Errorf("%w: type binder returned a nil codec", ErrInvalid) + } + result, err := Snapshot(ctx, WithCompletionHandler(bound, this.handler)) + return result, err == nil, err + } + matched, err := this.Codec.CanHandleType(ctx, typ) + if err == nil { + err = ctx.Err() + } + if err != nil || !matched { + return nil, matched, err + } + result, err := Snapshot(ctx, WithCompletionHandler(this.Codec, this.handler)) + return result, err == nil, err +} + +type completingEnum[V, R any] struct { + completingCodec[V, R] + enumeration Enum[R] +} + +func (this completingEnum[V, R]) ToRaw(ctx context.Context) Codec[any, any] { + return Raw(ctx, this) +} + +func (this completingEnum[V, R]) EnumVariants(ctx context.Context) ([]EnumVariant[R], error) { + return this.enumeration.EnumVariants(ctx) +} diff --git a/codec/completion_handler_test.go b/codec/completion_handler_test.go new file mode 100644 index 0000000..ece3e5b --- /dev/null +++ b/codec/completion_handler_test.go @@ -0,0 +1,79 @@ +package codec_test + +import ( + "context" + "os" + "reflect" + "testing" + + "github.com/echocat/cli4g/codec" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCompletionHandlerWrapperPreservesEnumMetadataAndFallback(t *testing.T) { + source, err := codec.NewEnumVariants(codec.NewString(), + codec.EnumVariant[string]{Value: "json", Description: "JSON encoding."}, + codec.EnumVariant[string]{Value: "yaml", Description: "YAML encoding."}) + require.NoError(t, err) + calls := 0 + wrapped := codec.WithCompletionHandler(source, func(context.Context, codec.CompletionInput) (codec.CompletionResult, error) { + calls++ + return codec.CompletionResult{}, nil + }) + _, isEnum := wrapped.(codec.Enum[string]) + assert.True(t, isEnum) + raw, err := codec.Snapshot(context.Background(), wrapped) + require.NoError(t, err) + _, isRawEnum := raw.(codec.Enum[any]) + assert.True(t, isRawEnum) + choices, err := codec.DisplayEnum(context.Background(), raw) + require.NoError(t, err) + assert.Equal(t, []codec.EnumChoice{{Values: []string{"json"}, Description: "JSON encoding."}, {Values: []string{"yaml"}, Description: "YAML encoding."}}, choices) + assert.Zero(t, calls) + result, err := codec.Complete(nil, raw, codec.CompletionInput{}) + require.NoError(t, err) + assert.Equal(t, []codec.CompletionCandidate{{Value: "json", Description: "JSON encoding."}, {Value: "yaml", Description: "YAML encoding."}}, result.Candidates) + assert.Equal(t, 1, calls) +} + +type resourceCompletionCodec struct{ codec.Codec[string, *os.File] } + +func (source resourceCompletionCodec) ToRaw(ctx context.Context) codec.Codec[any, any] { + return codec.Raw(ctx, source) +} + +func TestCompletionHandlerPreservesRequirementSchemaThroughTypeErasure(t *testing.T) { + ctx := context.Background() + wrapped := codec.WithCompletionHandler(resourceCompletionCodec{}.ToRaw(ctx), + func(context.Context, codec.CompletionInput) (codec.CompletionResult, error) { + t.Fatal("metadata validation must not invoke completion") + return codec.CompletionResult{}, nil + }) + for _, source := range []codec.Codec[any, any]{wrapped, wrapped.ToRaw(ctx)} { + _, err := codec.Snapshot(ctx, source) + require.ErrorIs(t, err, codec.ErrResourceRequirement) + } +} + +func TestCompletionHandlerWrapperRetainsTypeBinding(t *testing.T) { + calls := 0 + registry, err := codec.NewRegistry(context.Background(), []codec.Registration{ + codec.NewRegistration(codec.WithCompletionHandler(codec.NewCombinedDefaults(), func(context.Context, codec.CompletionInput) (codec.CompletionResult, error) { + calls++ + return codec.CompletionResult{Handled: true, Candidates: []codec.CompletionCandidate{{Value: "42"}}}, nil + })), + }) + require.NoError(t, err) + bound, found, err := registry.Find(context.Background(), reflect.TypeFor[int32](), "") + require.NoError(t, err) + require.True(t, found) + assert.Zero(t, calls) + value, err := bound.ParseRequirement(context.Background(), codec.UsageFlag, "13") + require.NoError(t, err) + assert.Equal(t, int32(13), value) + result, err := codec.Complete(nil, bound, codec.CompletionInput{}) + require.NoError(t, err) + assert.Equal(t, []codec.CompletionCandidate{{Value: "42"}}, result.Candidates) + assert.Equal(t, 1, calls) +} diff --git a/codec/completion_test.go b/codec/completion_test.go new file mode 100644 index 0000000..e2ee7b8 --- /dev/null +++ b/codec/completion_test.go @@ -0,0 +1,71 @@ +package codec_test + +import ( + "context" + "testing" + + "github.com/echocat/cli4g/codec" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type contextCompleter struct { + codec.Codec[string, string] + seen *codec.CompletionInput +} + +func (source contextCompleter) ToRaw(ctx context.Context) codec.Codec[any, any] { + return codec.Raw(ctx, source) +} + +func (source contextCompleter) Complete(ctx context.Context, input codec.CompletionInput) (codec.CompletionResult, error) { + *source.seen = input + return codec.CompletionResult{Handled: true, Candidates: []codec.CompletionCandidate{{Value: input.Flags["cluster"][0].Values[0] + "-namespace"}}}, ctx.Err() +} + +func TestCompletionRetainsContextThroughRawAndCollections(t *testing.T) { + var seen codec.CompletionInput + source := contextCompleter{Codec: codec.NewString(), seen: &seen} + input := codec.CompletionInput{Prefix: "prod", Target: codec.CompletionTarget{ValueIndex: 3}, + Flags: map[string][]codec.CompletionOccurrence{"cluster": {{Values: []string{"production"}}}}} + for _, adapted := range []codec.Codec[any, any]{ + codec.Raw(context.Background(), source), + codec.Raw(context.Background(), codec.NewSlice(source)), + } { + result, err := codec.Complete(context.Background(), adapted, input) + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + assert.Equal(t, "production-namespace", result.Candidates[0].Value) + assert.Equal(t, "production", seen.Flags["cluster"][0].Values[0]) + } + input.Prefix = "destination=prod" + result, err := codec.Complete(context.Background(), codec.NewMap[string](source), input) + require.NoError(t, err) + assert.Equal(t, "destination=production-namespace", result.Candidates[0].Value) + assert.Equal(t, "prod", seen.Prefix) + assert.Equal(t, 3, seen.Target.ValueIndex) + assert.Zero(t, seen.ValueIndex) +} + +type unhandledEnum struct { + codec.Codec[string, string] +} + +func (source unhandledEnum) Complete(context.Context, codec.CompletionInput) (codec.CompletionResult, error) { + return codec.CompletionResult{}, nil +} + +func (source unhandledEnum) EnumVariants(context.Context) ([]codec.EnumVariant[string], error) { + return []codec.EnumVariant[string]{{Value: "actual-input", Description: "Visible label."}}, nil +} + +func (source unhandledEnum) DisplayRequirement(context.Context, string) ([]string, error) { + return []string{"redacted-display"}, nil +} + +func TestUnhandledCompletionFallsBackToActualEnumInputs(t *testing.T) { + result, err := codec.Complete[string, string](nil, unhandledEnum{codec.NewString()}, codec.CompletionInput{}) + require.NoError(t, err) + assert.True(t, result.Handled) + assert.Equal(t, []codec.CompletionCandidate{{Value: "actual-input", Description: "Visible label."}}, result.Candidates) +} diff --git a/codec/doc.go b/codec/doc.go index 9c9d14c..0f45b41 100644 --- a/codec/doc.go +++ b/codec/doc.go @@ -87,6 +87,63 @@ // enums require String output to be accepted by Set. Undeclared input tokens are // rejected before UnmarshalText/Set; Resolve still creates fresh runtime targets. // +// # Context-aware completion +// +// [WithCompletionHandler] attaches a callback to an existing codec: +// +// codec.WithCompletionHandler(codec.NewString(), completeNamespaces) +// +// This keeps the codec's ordinary parsing, defaults, enum metadata, accumulation +// and type binding. No additional codec type or ToRaw method is required. The +// callback receives the same [CompletionInput] as a custom [Completer]. Return +// Handled=false to delegate to the original codec's completer and defaults. +// A flag/argument handler configured by the CLI builder runs before this callback. +// +// A codec may implement [Completer] in addition to Codec. Its Complete method is +// called only when that codec's value is being completed, never during creation, +// ordinary parsing, help, or flag-name completion. It may query external systems; +// unlike Enum, its suggestions need not describe a finite valid set. Keep Enum +// metadata context-independent even when Complete performs dynamic work. +// +// [CompletionInput] exposes the current prefix/suffix, target and value-token +// index, selected command path/profiles, explicit flag occurrence maps, positional +// arguments by name and order, and per-command scopes. Input is a best-effort, +// read-only snapshot, not a validated invocation. Tokens may be empty, missing, +// invalid, repeated or after the cursor. An absent flag, a bare bool, a missing +// flag value and an explicitly empty value remain distinguishable. Map keys use +// canonical flag names; Effective.Flag also accepts aliases. Scopes preserve +// ownership, while Flags projects visible canonical names and Arguments refers +// to the deepest selected container. +// +// Effective implements [CompletionValues] for lazy dependency lookup. It reports +// [CompletionCLI], [CompletionEnvironment], or [CompletionDefault] and returns +// independent resource-free tokens, not resolved runtime objects. Defaults use +// UsageDefault formatting; CLI and environment inputs retain their ordinary +// usage. No options factory or unrelated default/environment callback is run. +// This lets a namespace completer read --context and --kubeconfig without opening +// every file or initializing the rest of the application. +// +// Return a [CompletionResult] with Handled=true for an authoritative answer, +// including no matches. Handled=false falls back to actual enum variants. Files +// or Directories requests shell-native path completion; FilePrefix supports +// compound values such as key=path. Candidates contain literal, unquoted tokens +// and optional descriptions. Do not return help-safe/redacted display labels as +// executable input. [Complete] formats actual enum requirements and handles +// multi-token variants by matching earlier value tokens. +// +// Raw forwards this capability, NewSlice delegates each element, and NewMap +// delegates the portion after the first '=' while preserving surrounding context. +// Custom wrappers must implement ToRaw as Raw(ctx, wrapper) instead of delegating +// to an embedded codec, or the wrapper's optional methods would be lost. +// +// Completion receives a deadline/cancellation context. Providers must honor it, +// close request-owned resources and remain concurrent-safe. The caller's context +// can carry application-specific clients/services without changing the input +// snapshot. Shell queries generally run in a new process; a codec-local memory +// cache alone does not cache across queries. A persistent cache must distinguish +// dependencies such as configuration files, selected cluster and working directory. +// No additional dependencies, including Kubernetes clients, are required by codec. +// // # Context streams // // [ContextWithStdio] supplies borrowed stdin, stdout and stderr streams. Read them diff --git a/codec/raw.go b/codec/raw.go index b6ba8fc..24e7ecc 100644 --- a/codec/raw.go +++ b/codec/raw.go @@ -172,7 +172,15 @@ func (this raw[V, R]) ParseRequirementInto(ctx context.Context, usage Usage, pre return result, SanitizeError(err, "value could not be decoded") } -func (this raw[V, R]) RequirementType() reflect.Type { return reflect.TypeFor[R]() } +func (this raw[V, R]) RequirementType() reflect.Type { + typ := reflect.TypeFor[R]() + if typ.Kind() == reflect.Interface { + if source, ok := this.source.(interface{ RequirementType() reflect.Type }); ok { + return source.RequirementType() + } + } + return typ +} func (this raw[V, R]) IsBoolFlag() bool { trait, ok := this.source.(interface{ IsBoolFlag() bool }) diff --git a/compile-node_create.go b/compile-node_create.go index 305cc57..b01e277 100644 --- a/compile-node_create.go +++ b/compile-node_create.go @@ -111,6 +111,12 @@ func resolveSchema(draft schemaDraft, stack []*compileNode, owner *definitionLif if err != nil { return result, fmt.Errorf("flag --%s: %w", source.Name(), err) } + if source.completionHandler != nil { + resolved, err = codec.Snapshot(context.Background(), codec.WithCompletionHandler(resolved, source.completionHandler)) + if err != nil { + return result, fmt.Errorf("flag --%s completion: %w", source.Name(), err) + } + } flag.codec = resolved flag.cumulative = codec.IsCumulative(resolved) flag.defaultValue, err = snapshotDefault(source.defaultValue, resolved, source.valueType, owner) @@ -143,6 +149,12 @@ func resolveSchema(draft schemaDraft, stack []*compileNode, owner *definitionLif if err != nil { return result, fmt.Errorf("argument %s: %w", source.Name(), err) } + if source.completionHandler != nil { + resolved, err = codec.Snapshot(context.Background(), codec.WithCompletionHandler(resolved, source.completionHandler)) + if err != nil { + return result, fmt.Errorf("argument %s completion: %w", source.Name(), err) + } + } argument.codec = resolved argument.defaultValue, err = snapshotDefault(source.defaultValue, resolved, source.valueType, owner) if err != nil { diff --git a/completion.go b/completion.go new file mode 100644 index 0000000..9fbdff5 --- /dev/null +++ b/completion.go @@ -0,0 +1,126 @@ +package cli + +import ( + "context" + "fmt" + "reflect" + "slices" + "strings" + + "github.com/echocat/cli4g/codec" +) + +// CompletionRequest identifies a cursor in shell-tokenized input, excluding the +// executable. Word is a zero-based index into Words; Offset is a byte offset in +// that word, not a terminal column or UTF-16 offset. Word=len(Words), Offset=0 +// inserts an empty current word at the end. Words may include input after the +// cursor. Shell quoting has already been removed, but codecs have not run. +// Invalid cursor coordinates are errors; incomplete CLI syntax is not. +type CompletionRequest struct { + Words []string `json:"words"` + Word int `json:"word"` + Offset int `json:"offset"` +} + +// Complete provides context-aware suggestions without creating, parsing, +// validating, initializing, or executing application options. It uses the active +// schema selected by opts, including inherited flags and path-specific codecs. +// Unknown or incomplete input is retained best-effort, not reported as an input +// error. Schema/profile errors, invalid coordinates and provider errors return +// directly, without invoking the application's error handler. +// +// Codecs may implement codec.Completer. They receive explicit input maps, named +// and ordered arguments, command scopes, the target, and lazy environment/default +// access through codec.CompletionInput.Effective. Enum, bool, file and collection +// codecs work automatically. Name completion never invokes value completers. +// An explicit flag/argument CompletionHandler takes precedence over its codec; +// Handled=false delegates to the next completion source. +// Suggestions are case-sensitive prefix matches, deduplicated and sorted unless +// KeepOrder is requested. Candidates are whole, unquoted replacement words. +// The caller owns ctx's deadline; provider callbacks must honor cancellation. +// Calls are independent and concurrent-safe when user callbacks are safe. +func (this *Application[O]) Complete(ctx context.Context, opts *ParseOpts, request CompletionRequest) (codec.CompletionResult, error) { + ctx, err := this.prepareParseContext(ctx) + if err != nil { + return codec.CompletionResult{}, err + } + request.Words = slices.Clone(request.Words) + if request.Word == len(request.Words) && request.Offset == 0 { + request.Words = append(request.Words, "") + } + if request.Word < 0 || request.Word >= len(request.Words) || request.Offset < 0 || request.Offset > len(request.Words[request.Word]) { + return codec.CompletionResult{}, fmt.Errorf("invalid completion cursor") + } + var profiles selectedProfiles + if opts != nil { + profiles = slices.Clone(opts.Profiles) + slices.Sort(profiles) + profiles = slices.Compact(profiles) + } + for _, profile := range profiles { + if !this.ContainsProfile(profile) { + return codec.CompletionResult{}, NewValidationErrorf("unknown completion profile %q", profile) + } + } + ctx = withProfilesContext(ctx, profiles) + scan := completionScan{request: request, profiles: profiles} + if err := scan.scan(ctx, this); err != nil { + return codec.CompletionResult{}, err + } + input := scan.input() + input.Effective = &completionValues{scan: &scan, opts: opts} + ctx = withContainerContext(ctx, scan.path[len(scan.path)-1].container) + result := codec.CompletionResult{Handled: true} + if input.Target.Kind == codec.CompletionFlag { + result.Candidates = scan.flagCandidates() + } else { + if scan.commandSite { + for _, command := range scan.path[input.Target.Scope].commands { + if command.IsVisible() { + for _, name := range command.Names() { + result.Candidates = append(result.Candidates, codec.CompletionCandidate{Value: name, Description: command.Description()}) + } + } + } + } + if source := scan.targetCodec(); source != nil { + values, err := codec.Complete(ctx, source, input) + if err != nil { + return codec.CompletionResult{}, err + } + if !values.Handled { + values.Handled = true + values.Files = scan.targetType() != nil && scan.targetType().Kind() == reflect.String + } + values.Candidates = append(result.Candidates, values.Candidates...) + result = values + } else if !scan.commandSite { + result.Files = true + } + } + if err := ctx.Err(); err != nil { + return codec.CompletionResult{}, err + } + return finishCompletion(result, input.Prefix, scan.valuePrefix), nil +} + +func finishCompletion(result codec.CompletionResult, prefix, valuePrefix string) codec.CompletionResult { + seen := make(map[string]bool) + filtered := make([]codec.CompletionCandidate, 0, len(result.Candidates)) + for _, candidate := range result.Candidates { + if strings.HasPrefix(candidate.Value, prefix) && !seen[candidate.Value] { + seen[candidate.Value] = true + candidate.Value = valuePrefix + candidate.Value + filtered = append(filtered, candidate) + } + } + if !result.KeepOrder { + slices.SortStableFunc(filtered, func(a, b codec.CompletionCandidate) int { return strings.Compare(a.Value, b.Value) }) + } + result.Candidates = filtered + if result.Files || result.Directories { + result.FileInput = strings.TrimPrefix(prefix, result.FilePrefix) + } + result.FilePrefix = valuePrefix + result.FilePrefix + return result +} diff --git a/completion_bash_input.go b/completion_bash_input.go new file mode 100644 index 0000000..adcbd7f --- /dev/null +++ b/completion_bash_input.go @@ -0,0 +1,74 @@ +package cli + +import ( + "fmt" + "strings" +) + +// Bash owns expansion and execution. This lexer only removes simple quoting and +// identifies the simple command surrounding the cursor; it never evaluates code. +func bashCompletionRequest(line string, point int) (CompletionRequest, error) { + if point < 0 || point > len(line) { + return CompletionRequest{}, fmt.Errorf("invalid Bash completion cursor") + } + var words []string + current, offset := -1, 0 + for i := 0; i < len(line); { + if strings.ContainsRune(";|&\n", rune(line[i])) { + if i >= point { + break + } + words, current = nil, -1 + i++ + continue + } + if line[i] == ' ' || line[i] == '\t' || line[i] == '\r' { + if i >= point && current < 0 { + current, offset = len(words), 0 + words = append(words, "") + } + i++ + continue + } + start := i + var word strings.Builder + var quote byte + prefix := 0 + for i < len(line) { + char := line[i] + if quote == 0 && strings.ContainsRune(" \t\r\n;|&", rune(char)) { + break + } + if char == '\\' && quote != '\'' && i+1 < len(line) && (quote == 0 || strings.ContainsRune("\\\"$`\n", rune(line[i+1]))) { + i++ + if line[i] != '\n' { + word.WriteByte(line[i]) + } + } else if (char == '\'' || char == '"') && (quote == 0 || quote == char) { + if quote == 0 { + quote = char + } else { + quote = 0 + } + } else { + word.WriteByte(char) + } + i++ + if i <= point { + prefix = word.Len() + } + } + if point >= start && point <= i { + current, offset = len(words), prefix + } + words = append(words, word.String()) + } + if current < 0 { + current, offset = len(words), 0 + words = append(words, "") + } + if current == 0 || len(words) == 0 { + return CompletionRequest{}, fmt.Errorf("bash completion has no argument target") + } + return CompletionRequest{Words: words[1:], Word: current - 1, Offset: offset}, nil +} diff --git a/completion_command.go b/completion_command.go new file mode 100644 index 0000000..7f54108 --- /dev/null +++ b/completion_command.go @@ -0,0 +1,151 @@ +package cli + +import ( + "context" + "fmt" + "strings" + "unicode" + + "github.com/echocat/cli4g/codec" +) + +// DefaultCompletionCommand is the ordinary command requirement selected by +// ApplicationRequirement.WithCompletion(true). Its initial value is a ready-to-use +// NewCompletionCommand. Replace this variable before configuring applications, +// without concurrent modification. The shortcut captures its current value; +// Create compiles an independent definition and never mutates the requirement. +// For per-application changes, use a fresh NewCompletionCommand or your own +// CommandRequirement through WithCompletionCommand instead of modifying the +// shared default builder. +var DefaultCompletionCommand CommandRequirementLike = NewCompletionCommand() + +// DefaultCompletionRequestCommandName is the special protocol entry selected by +// WithCompletion(true). Replace it before configuring applications, without +// concurrent modification. Empty disables the request entry. Installed adapters +// depend on the captured name, independently of the public command. +var DefaultCompletionRequestCommandName = "__complete" + +// CompletionInstallOptions configures the standard completion install command. +// Shell is bash or nu. Directory overrides the shell's default installation path. +// These are ordinary command options, parsed and validated through the usual CLI +// lifecycle together with the selected application and command scopes. +type CompletionInstallOptions struct { + Shell string `argument:"shell" enum:"bash|nu" required:"true" description:"Shell to configure."` + Directory string `argument:"directory" type:"completion-directory" description:"Installation directory; omitted uses the shell's default."` +} + +// NewCompletionCommand builds a fresh, ordinary command tree named "completion". +// It supports "completion bash", "completion nu", and "completion install +// [directory]". WithName, WithAliases, handlers and all other ordinary +// CommandRequirement configuration are available. Parent requirements, parsing, +// profiles, initialization, execution, cleanup and depth limits apply normally. +// Only the independently configured request entry bypasses that lifecycle. +// +// The command accesses the application's completion API through +// CompletionApplicationFromContext, which custom management commands may also use. +func NewCompletionCommand() *CommandRequirement[NoOptions] { + command := NewSimpleCommand("completion", "Manage shell completion.").WithHandler(completionGroupHelp) + for _, shell := range []CompletionShell{CompletionBash, CompletionNushell} { + command.WithCommand(NewSimpleCommand(string(shell), "Print the "+string(shell)+" adapter."). + WithHandler(func(ctx context.Context, _ NoOptions) error { + application, found := CompletionApplicationFromContext(ctx) + if !found { + return fmt.Errorf("completion application is unavailable") + } + script, err := application.CompletionScript(shell) + if err != nil { + return err + } + return Print(ctx, script) + })) + } + command.WithCommand(NewCommand[CompletionInstallOptions]("install", "Install a shell adapter."). + WithCodec(codec.WithCompletionHandler(codec.NewString(), func(ctx context.Context, _ codec.CompletionInput) (codec.CompletionResult, error) { + return codec.CompletionResult{Handled: true, Directories: true}, ctx.Err() + }), "completion-directory"). + WithHandler(func(ctx context.Context, options CompletionInstallOptions) error { + application, found := CompletionApplicationFromContext(ctx) + if !found { + return fmt.Errorf("completion application is unavailable") + } + path, err := application.InstallCompletion(ctx, CompletionShell(options.Shell), options.Directory) + if err != nil { + return err + } + return Printf(ctx, "Installed completion: %s\n", path) + })) + return command +} + +// CompletionApplication exposes a typed application's completion operations to +// ordinary command handlers that do not know the root options type. It is +// implemented by Application, not by callers. Use CompletionApplicationFromContext +// to write custom management commands using the standard renderer/installer. +type CompletionApplication interface { + Container + Complete(context.Context, *ParseOpts, CompletionRequest) (codec.CompletionResult, error) + CompletionScript(CompletionShell) (string, error) + InstallCompletion(context.Context, CompletionShell, string) (string, error) + CompletionRequestCommandName() string +} + +// CompletionApplicationFromContext returns the current invocation's application +// completion API. It neither constructs options nor creates a new invocation. +// It is available in ordinary lifecycle/command handlers, including a custom +// completion command. Missing invocation data returns nil, false. +func CompletionApplicationFromContext(ctx context.Context) (CompletionApplication, bool) { + invocation, found := InvocationFromContext(ctx) + if !found { + return nil, false + } + application, found := invocation.Root().(CompletionApplication) + return application, found +} + +// CompletionCommand returns the ordinary compiled command configured through +// ApplicationRequirement.CompletionCommand, or nil when none was configured. +// It is the same definition returned by Commands/Command and remains readable +// after Close, like all other command metadata. +func (this *Application[O]) CompletionCommand() CommandLike { return this.definition.completionCommand } + +// CompletionCommandName returns the configured command's primary name, or empty +// when absent. The name belongs to the command definition, not a separate setting. +func (this *Application[O]) CompletionCommandName() string { + if command := this.CompletionCommand(); command != nil { + return command.Name() + } + return "" +} + +// CompletionRequestCommandName returns the captured special request name. Empty +// means disabled. No implicit default is substituted by Create. +func (this *Application[O]) CompletionRequestCommandName() string { + return this.definition.completionRequestCommandName +} + +func validateCompletionRequestName(name string) error { + if strings.HasPrefix(name, "-") || strings.ContainsAny(name, "=\x00/\\") || strings.ContainsFunc(name, func(r rune) bool { + return unicode.IsSpace(r) || unicode.IsControl(r) + }) { + return fmt.Errorf("invalid completion request name %q: expected a single command word", name) + } + return nil +} + +func completionGroupHelp(ctx context.Context, _ NoOptions) error { + invocation, found := InvocationFromContext(ctx) + if !found { + return fmt.Errorf("completion invocation is unavailable") + } + current, found := ContainerFromContext(ctx) + if !found { + return fmt.Errorf("completion command is unavailable") + } + if invocation.HelpInfo().Target != current { + return nil // Ordinary ancestor handlers also run before selected children. + } + if handler := invocation.HelpHandler(); handler != nil { + return handler(ctx, invocation) + } + return fmt.Errorf("help handler is unavailable") +} diff --git a/completion_command_test.go b/completion_command_test.go new file mode 100644 index 0000000..9e4f721 --- /dev/null +++ b/completion_command_test.go @@ -0,0 +1,204 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/echocat/cli4g/codec" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWithCompletionOnlyAssignsAndClearsConfiguration(t *testing.T) { + previousCommand, previousRequest := DefaultCompletionCommand, DefaultCompletionRequestCommandName + t.Cleanup(func() { + DefaultCompletionCommand, DefaultCompletionRequestCommandName = previousCommand, previousRequest + }) + defaultCommand := NewCompletionCommand().WithName("global-shells") + DefaultCompletionCommand, DefaultCompletionRequestCommandName = defaultCommand, "__global_suggest" + build := NewSimpleApplication("tool") + assert.Nil(t, build.CompletionCommand) + assert.Empty(t, build.CompletionRequestCommandName) + build.WithCompletion(true) + assert.Same(t, defaultCommand, build.CompletionCommand) + assert.Equal(t, "__global_suggest", build.CompletionRequestCommandName) + assert.Empty(t, build.Commands, "shortcut must not append permanently to Commands") + // The shortcut captures the defaults at call time, not during Create. + DefaultCompletionCommand, DefaultCompletionRequestCommandName = nil, "" + app := build.MustCreate() + defer func() { assert.NoError(t, app.Close()) }() + assert.Equal(t, "global-shells", app.CompletionCommandName()) + assert.Equal(t, "__global_suggest", app.CompletionRequestCommandName()) + build.WithCompletionCommand(NewSimpleCommand("custom")).WithCompletionRequestCommandName("__custom").WithCompletion(false) + assert.Nil(t, build.CompletionCommand) + assert.Empty(t, build.CompletionRequestCommandName) + off := build.MustCreate() + defer func() { assert.NoError(t, off.Close()) }() + assert.Empty(t, slices.Collect(off.Commands())) + assert.Nil(t, off.CompletionCommand()) + assert.Empty(t, off.CompletionRequestCommandName()) + // True also assigns deliberately empty defaults without inventing fallbacks. + build.WithCompletion(true) + assert.Nil(t, build.CompletionCommand) + assert.Empty(t, build.CompletionRequestCommandName) +} + +func TestDefaultCompletionCommandIsAnOrdinaryCompiledCommand(t *testing.T) { + var events []string + definition := NewCompletionCommand().WithName("shell-setup").WithAliases("shells") + build := NewSimpleApplication("tool").WithCompletionCommand(definition). + WithCompletionRequestCommandName("__suggest").WithErrorHandlerEnabled(false). + WithInitializeHandler(func(context.Context, NoOptions) error { events = append(events, "initialize"); return nil }). + WithCloseHandler(func(NoOptions) error { events = append(events, "close"); return nil }) + app := build.MustCreate() + defer func() { assert.NoError(t, app.Close()) }() + assert.Empty(t, build.Commands) + command, found := app.Command("shells") + require.True(t, found) + assert.Same(t, command, app.CompletionCommand()) + typed, found := FindCommand[NoOptions](app, "shell-setup") + require.True(t, found) + assert.Same(t, command, typed) + assert.Len(t, slices.Collect(app.Commands()), 1) + _, found = app.Command("__suggest") + assert.False(t, found, "request entry is not registered as a command") + definition.WithName("changed") + assert.Equal(t, "shell-setup", app.CompletionCommandName()) + for _, shell := range []CompletionShell{CompletionBash, CompletionNushell} { + var output, diagnostics bytes.Buffer + require.NoError(t, app.ParseAndExecute(ContextWithStdio(nil, nil, &output, &diagnostics), nil, "shells", string(shell))) + assert.Contains(t, output.String(), "__suggest") + assert.Empty(t, diagnostics.String(), "ancestor group handler must not print help for a selected child") + } + assert.Equal(t, []string{"initialize", "close", "initialize", "close"}, events) + var help bytes.Buffer + require.NoError(t, app.ParseAndExecute(ContextWithStdio(nil, nil, &help, &help), nil, "shell-setup", "--help")) + assert.Contains(t, help.String(), "tool shell-setup") + assert.Contains(t, help.String(), "install") + assert.Len(t, events, 4, "ordinary --help skips initialization") + var output bytes.Buffer + directory := t.TempDir() + require.NoError(t, app.ParseAndExecute(ContextWithStdio(nil, nil, &output, &output), nil, "shell-setup", "install", "bash", directory)) + installed, err := os.ReadFile(filepath.Join(directory, "tool.bash")) + require.NoError(t, err) + assert.Contains(t, string(installed), "__suggest") +} + +func TestCompletionCommandsObeyOrdinaryRequirements(t *testing.T) { + type options struct { + Token string `flag:"token" required:"true"` + } + created := 0 + app := NewApplication[options]("tool").WithCompletion(true).WithErrorHandlerEnabled(false). + WithCreateOptionsHandler(func(context.Context) (options, error) { created++; return options{}, nil }).MustCreate() + defer func() { assert.NoError(t, app.Close()) }() + ctx := ContextWithStdio(nil, nil, nil, nil) + require.ErrorIs(t, app.ParseAndExecute(ctx, nil, "completion", "bash"), ErrValidation) + var output bytes.Buffer + require.NoError(t, app.ParseAndExecute(ContextWithStdio(nil, nil, &output, &output), nil, "--token", "provided", "completion", "bash")) + assert.Contains(t, output.String(), "__complete") + assert.Equal(t, 2, created) + output.Reset() + request := strings.NewReader(`{"words":["--to"],"word":0,"offset":4}`) + require.NoError(t, app.ParseAndExecute(ContextWithStdio(nil, request, &output, &output), nil, "__complete", "1", "nu")) + assert.Equal(t, 2, created, "request entry alone bypasses options and required input") + var result codec.CompletionResult + require.NoError(t, json.Unmarshal(output.Bytes(), &result)) + assert.Equal(t, []string{"--token"}, completionTexts(result)) + _, err := NewApplication[struct { + Name string `argument:"name" required:"true"` + }]("tool").WithCompletion(true).Create() + require.ErrorContains(t, err, "required positional") + _, err = NewSimpleApplication("tool").WithCompletion(true).WithMaxCommandDepth(0).Create() + require.ErrorContains(t, err, "maximum command depth") +} + +func TestCompletionCommandAndRequestAreIndependentAndReplaceable(t *testing.T) { + type customOptions struct { + Value string `argument:"value" required:"true"` + } + var selected string + custom := NewCommand[customOptions]("custom-shells").WithHandler(func(_ context.Context, options customOptions) error { + selected = options.Value + return nil + }) + app := NewSimpleApplication("tool").WithCompletionCommand(custom).WithErrorHandlerEnabled(false).MustCreate() + defer func() { assert.NoError(t, app.Close()) }() + require.NoError(t, app.ParseAndExecute(nil, nil, "custom-shells", "ordinary")) + assert.Equal(t, "ordinary", selected) + _, found := FindCommand[customOptions](app, "custom-shells") + assert.True(t, found) + assert.Empty(t, app.CompletionRequestCommandName()) + _, err := app.CompletionScript(CompletionBash) + require.ErrorContains(t, err, "request entry is not configured") + type rootOptions struct { + Name string `argument:"name" required:"true"` + } + requestOnly := NewApplication[rootOptions]("tool").WithCompletionRequestCommandName("__suggest"). + WithMaxCommandDepth(0).WithErrorHandlerEnabled(false).MustCreate() + defer func() { assert.NoError(t, requestOnly.Close()) }() + assert.Empty(t, slices.Collect(requestOnly.Commands())) + assert.Nil(t, requestOnly.CompletionCommand()) + var output bytes.Buffer + require.NoError(t, requestOnly.ParseAndExecute(ContextWithStdio(nil, strings.NewReader(`{}`), &output, &output), nil, "__suggest", "1", "nu")) + assert.Contains(t, output.String(), `"handled":true`) + plain := NewSimpleApplication("tool").WithCommand(NewSimpleCommand("__complete")).MustCreate() + defer func() { assert.NoError(t, plain.Close()) }() + _, found = plain.Command("__complete") + assert.True(t, found, "an empty request name does not reserve any name") +} + +func TestCompletionRequestNameAndNormalDuplicateChecks(t *testing.T) { + for _, invalid := range []string{"--complete", "shell request", "a\nb", "a/b", "a\\b", "a=b", "a\x00b"} { + _, err := NewSimpleApplication("tool").WithCompletionRequestCommandName(invalid).Create() + require.Error(t, err) + } + for _, command := range []*CommandRequirement[NoOptions]{ + NewSimpleCommand("__complete"), NewSimpleCommand("other").WithAliases("__complete"), NewSimpleCommand("__complete").WithProfiles("inactive"), + } { + _, err := NewSimpleApplication("tool").WithCompletion(true).WithCommand(command).Create() + require.ErrorContains(t, err, "reserved for completion requests") + } + _, err := NewSimpleApplication("tool").WithCompletion(true).WithCommand(NewSimpleCommand("completion")).Create() + require.ErrorContains(t, err, "duplicate or invalid command name") + _, err = NewSimpleApplication("tool").WithCompletionCommand(NewSimpleCommand("__suggest")).WithCompletionRequestCommandName("__suggest").Create() + require.ErrorContains(t, err, "reserved for completion requests") +} + +func TestNormalCompletionCommandCompletesItsOwnSchema(t *testing.T) { + app := NewSimpleApplication("tool").WithCompletion(true). + WithCompletionCommand(NewCompletionCommand().WithName("shell-setup")).WithCompletionRequestCommandName("__suggest").MustCreate() + defer func() { assert.NoError(t, app.Close()) }() + for _, test := range []struct { + words []string + want []string + dirs bool + }{ + {[]string{"shell-s"}, []string{"shell-setup"}, false}, + {[]string{"__s"}, []string{}, false}, + {[]string{"shell-setup", ""}, []string{"bash", "install", "nu"}, false}, + {[]string{"shell-setup", "install", "n"}, []string{"nu"}, false}, + {[]string{"shell-setup", "install", "bash", "dir"}, []string{}, true}, + } { + result, err := app.Complete(nil, nil, completionAtEnd(test.words...)) + require.NoError(t, err) + assert.Equal(t, test.want, completionTexts(result)) + assert.Equal(t, test.dirs, result.Directories) + } + renamed := NewSimpleApplication("tool").WithCompletion(true). + WithCompletionCommand(NewCompletionCommand().WithName("shell-admin")).WithCompletionRequestCommandName("__suggest").MustCreate() + defer func() { assert.NoError(t, renamed.Close()) }() + for _, shell := range []CompletionShell{CompletionBash, CompletionNushell} { + before, err := app.CompletionScript(shell) + require.NoError(t, err) + after, err := renamed.CompletionScript(shell) + require.NoError(t, err) + assert.Equal(t, before, after) + } +} diff --git a/completion_example_test.go b/completion_example_test.go new file mode 100644 index 0000000..688b314 --- /dev/null +++ b/completion_example_test.go @@ -0,0 +1,54 @@ +package cli_test + +import ( + "context" + "fmt" + + cli "github.com/echocat/cli4g" + "github.com/echocat/cli4g/codec" +) + +// A real implementation could ask a Kubernetes client using the same ctx. +func namespaceSuggestions(ctx context.Context, input codec.CompletionInput) (codec.CompletionResult, error) { + cluster, _, err := input.Effective.Flag(ctx, "context") + if err != nil { + return codec.CompletionResult{}, err + } + name := "default" + if len(cluster.Values) > 0 { + name = cluster.Values[len(cluster.Values)-1] + } + // Explicit raw occurrences are also available as input.Flags["context"]. + // input.Arguments and input.Positionals describe the selected command's args. + return codec.CompletionResult{ + Handled: true, + Candidates: []codec.CompletionCandidate{ + {Value: name + "-apps", Description: "Application namespace."}, + {Value: name + "-system", Description: "System namespace."}, + }, + }, ctx.Err() +} + +func ExampleApplication_Complete() { + app := cli.NewApplication[struct { + Context string `flag:"context" default:"development"` + Namespace string `flag:"namespace" type:"namespace"` + }]("tool").WithCompletion(true). + WithCodec(codec.WithCompletionHandler(codec.NewString(), namespaceSuggestions), "namespace").MustCreate() + defer func() { _ = app.Close() }() + + // A cursor can precede other dependency flags. No options are initialized. + result, err := app.Complete(context.Background(), nil, cli.CompletionRequest{ + Words: []string{"--namespace", "pro", "--context", "production"}, + Word: 1, Offset: 3, + }) + if err != nil { + panic(err) + } + for _, candidate := range result.Candidates { + fmt.Println(candidate.Value, "—", candidate.Description) + } + // Output: + // production-apps — Application namespace. + // production-system — System namespace. +} diff --git a/completion_handler.go b/completion_handler.go new file mode 100644 index 0000000..b16ef23 --- /dev/null +++ b/completion_handler.go @@ -0,0 +1,13 @@ +package cli + +import "github.com/echocat/cli4g/codec" + +// CompletionHandler supplies value suggestions using the automatically built +// completion context. It is the same callback accepted by +// codec.WithCompletionHandler; no duplicated CLI options model is required. +// +// Handlers on a flag or argument run before that input's codec completer. Return +// Handled=false to delegate, or Handled=true for an authoritative answer, including +// no candidates. Commands, flag names, prefix filtering and shell quoting remain +// automatic. Callbacks must honor cancellation and be concurrent-safe. +type CompletionHandler = codec.CompletionFunc diff --git a/completion_handler_test.go b/completion_handler_test.go new file mode 100644 index 0000000..5ebb25b --- /dev/null +++ b/completion_handler_test.go @@ -0,0 +1,119 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "testing" + + "github.com/echocat/cli4g/codec" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCompletionHandlersOverrideDelegateAndCaptureDefinitions(t *testing.T) { + codecCalls, flagCalls, argumentCalls := 0, 0, 0 + source := codec.WithCompletionHandler(codec.NewString(), func(context.Context, codec.CompletionInput) (codec.CompletionResult, error) { + codecCalls++ + return codec.CompletionResult{Handled: true, Candidates: []codec.CompletionCandidate{{Value: "codec-choice"}}}, nil + }) + flag := NewFlag(source, "region").WithCompletionHandler(func(_ context.Context, input codec.CompletionInput) (codec.CompletionResult, error) { + flagCalls++ + assert.Equal(t, "region", input.Target.Name) + return codec.CompletionResult{Handled: true, Candidates: []codec.CompletionCandidate{{Value: "flag-choice"}}}, nil + }) + argument := NewArgument(source, "target").WithCompletionHandler(func(_ context.Context, input codec.CompletionInput) (codec.CompletionResult, error) { + argumentCalls++ + assert.Equal(t, "target", input.Target.Name) + return codec.CompletionResult{}, nil + }) + app := NewSimpleApplication("tool").WithCompletion(true).WithErrorHandlerEnabled(false). + WithHandler(func(context.Context, NoOptions) error { return nil }). + WithFlag(flag).WithArgument(argument).MustCreate() + defer func() { assert.NoError(t, app.Close()) }() + assert.Zero(t, codecCalls+flagCalls+argumentCalls) + // Mutating builders does not change the compiled callbacks. + flag.SetCompletionHandler(nil) + argument.SetCompletionHandler(nil) + definition, found := app.Flag("region") + require.True(t, found) + assert.NotNil(t, definition.CompletionHandler()) + argumentDefinition, found := app.Argument(0) + require.True(t, found) + assert.NotNil(t, argumentDefinition.CompletionHandler()) + result, err := app.Complete(nil, nil, completionAtEnd("--region", "")) + require.NoError(t, err) + assert.Equal(t, []string{"flag-choice"}, completionTexts(result)) + assert.Equal(t, 1, flagCalls) + assert.Zero(t, codecCalls) + result, err = app.Complete(nil, nil, completionAtEnd("codec")) + require.NoError(t, err) + assert.Equal(t, []string{"codec-choice"}, completionTexts(result)) + assert.Equal(t, 1, argumentCalls) + assert.Equal(t, 1, codecCalls) + _, err = app.Complete(nil, nil, completionAtEnd("--reg")) + require.NoError(t, err) + var output bytes.Buffer + require.NoError(t, app.ParseAndExecute(ContextWithStdio(nil, nil, &output, &output), nil, "--help")) + invocation, err := app.Parse(nil, nil, "--region", "actual-region", "actual-target") + require.NoError(t, err) + defer func() { assert.NoError(t, invocation.Close()) }() + _, value := invocation.Flag("region") + assert.Equal(t, "actual-region", value) + assert.Equal(t, 1, flagCalls) + assert.Equal(t, 1, argumentCalls) + assert.Equal(t, 1, codecCalls) +} + +func TestCompletionHandlersFallThroughToEnumsAndPreserveParsing(t *testing.T) { + formats, err := codec.NewEnum(codec.NewString(), "json", "yaml") + require.NoError(t, err) + delegations := 0 + delegate := func(context.Context, codec.CompletionInput) (codec.CompletionResult, error) { + delegations++ + return codec.CompletionResult{}, nil + } + app := NewSimpleApplication("tool").WithErrorHandlerEnabled(false). + WithFlag(NewFlag(codec.WithCompletionHandler(formats, delegate), "format").WithCompletionHandler(delegate)). + WithFlag(NewFlag(codec.WithCompletionHandler(codec.NewBool(), delegate), "verbose")). + WithFlag(NewFlag(codec.WithCompletionHandler(codec.NewStrings(), delegate), "label")).MustCreate() + defer func() { assert.NoError(t, app.Close()) }() + assert.Zero(t, delegations) + result, err := app.Complete(nil, nil, completionAtEnd("--format", "j")) + require.NoError(t, err) + assert.Equal(t, []string{"json"}, completionTexts(result)) + assert.Equal(t, 2, delegations) + invocation, err := app.Parse(nil, nil, "--verbose", "--label", "a", "--label", "b", "--format", "yaml") + require.NoError(t, err) + defer func() { assert.NoError(t, invocation.Close()) }() + _, verbose := invocation.Flag("verbose") + assert.Equal(t, true, verbose) + _, labels := invocation.Flag("label") + assert.Equal(t, []string{"a", "b"}, labels) + assert.Equal(t, 2, delegations) +} + +func TestCompletionHandlerErrorsAndAuthoritativeEmptyAnswers(t *testing.T) { + failure := errors.New("service unavailable") + codecCalled := false + source := codec.WithCompletionHandler(codec.NewString(), func(context.Context, codec.CompletionInput) (codec.CompletionResult, error) { + codecCalled = true + return codec.CompletionResult{Handled: true, Files: true}, nil + }) + app := NewSimpleApplication("tool").WithFlag(NewFlag(source, "value").WithCompletionHandler( + func(_ context.Context, input codec.CompletionInput) (codec.CompletionResult, error) { + if input.Prefix == "error" { + return codec.CompletionResult{}, failure + } + return codec.CompletionResult{Handled: true}, nil + })).MustCreate() + defer func() { assert.NoError(t, app.Close()) }() + result, err := app.Complete(nil, nil, completionAtEnd("--value", "")) + require.NoError(t, err) + assert.True(t, result.Handled) + assert.False(t, result.Files) + assert.Empty(t, result.Candidates) + _, err = app.Complete(nil, nil, completionAtEnd("--value", "error")) + require.ErrorIs(t, err, failure) + assert.False(t, codecCalled) +} diff --git a/completion_protocol.go b/completion_protocol.go new file mode 100644 index 0000000..77536e4 --- /dev/null +++ b/completion_protocol.go @@ -0,0 +1,164 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "time" + + "github.com/echocat/cli4g/codec" +) + +// CompletionProtocolVersion identifies the stable adapter protocol. Its decimal +// representation follows the configured hidden request command (by default +// "__complete"). Version 1 has these transports: +// +// - __complete 1 bash POINT LINE CURRENT: POINT is a byte offset into the Bash input +// LINE. CURRENT is Bash's current word after COMP_WORDBREAKS. Simple shell +// quotes/escapes and command separators are recognized without evaluating +// expansions, substitutions or shell code. Output is NUL-delimited: "1", path +// mode (0 none, 1 files, 2 directories), NoSpace (0/1), KeepOrder (0/1), path +// insertion prefix, path search prefix, Readline replacement prefix to remove +// from composed path suggestions, then candidate values. All strings +// are unquoted; the adapter handles Readline word breaks and native paths. +// - __complete 1 nu: stdin contains exactly one JSON CompletionRequest, limited to +// 1 MiB. An optional raw=true additionally removes simple Nu literal quoting +// from span tokens; it never evaluates expressions. stdout is a JSON +// codec.CompletionResult. Input excludes the executable. +// +// New transports may be added without changing existing transports. Query errors +// become authoritative empty responses with no diagnostics; direct Complete +// callers retain errors. Adapters should invoke the currently selected binary, +// never a path/version captured at installation, and only on completion requests. +const CompletionProtocolVersion = 1 + +// CompletionQueryTimeout is the cooperative deadline applied to shell queries. +// Direct Complete callers choose their own deadline. Network clients and custom +// completers must honor cancellation; Go cannot forcibly stop an arbitrary user +// callback. Startup and code run before cli4g are outside this budget. +const CompletionQueryTimeout = 500 * time.Millisecond + +func (this *Application[O]) queryCompletion(ctx context.Context, opts *ParseOpts, args []string) error { + ctx, cancel := context.WithTimeout(ctx, CompletionQueryTimeout) + defer cancel() + result := codec.CompletionResult{Handled: true, Candidates: []codec.CompletionCandidate{}} + if len(args) < 2 || args[0] != strconv.Itoa(CompletionProtocolVersion) { + return nil + } + output := codec.StdoutOfContext(ctx) + if output == nil { + output = io.Discard + } + switch CompletionShell(args[1]) { + case CompletionBash: + var request CompletionRequest + var err error + if len(args) == 5 { + var point int + point, err = strconv.Atoi(args[2]) + if err == nil { + request, err = bashCompletionRequest(args[3], point) + } + if err == nil { + result, err = this.Complete(ctx, opts, request) + } + } + if err != nil { + result = codec.CompletionResult{Handled: true} + } + pathInput := result.FileInput + replacementPrefix := "" + if err == nil && len(request.Words) > request.Word && request.Word >= 0 { + prefix := request.Words[request.Word][:request.Offset] + if len(args) == 5 && strings.HasSuffix(prefix, args[4]) { + trim := prefix[:len(prefix)-len(args[4])] + replacementPrefix = trim + for i := range result.Candidates { + result.Candidates[i].Value = strings.TrimPrefix(result.Candidates[i].Value, trim) + } + } + } + mode := 0 + if result.Files { + mode = 1 + } + if result.Directories { + mode = 2 + } + if _, err := fmt.Fprintf(output, "1%c%d%c%d%c%d%c%s%c%s%c%s%c", 0, mode, 0, boolInt(result.NoSpace), 0, boolInt(result.KeepOrder), 0, result.FilePrefix, 0, pathInput, 0, replacementPrefix, 0); err != nil { + return err + } + for _, candidate := range result.Candidates { + if strings.ContainsRune(candidate.Value, 0) { + continue + } + if _, err := fmt.Fprintf(output, "%s%c", candidate.Value, 0); err != nil { + return err + } + } + return nil + case CompletionNushell: + var request struct { + CompletionRequest + Raw bool `json:"raw,omitempty"` + } + if input := codec.StdinOfContext(ctx); input != nil { + decoder := json.NewDecoder(io.LimitReader(input, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err == nil { + var trailing any + if decoder.Decode(&trailing) == io.EOF { + if request.Raw { + unquoteNuCompletion(&request.CompletionRequest) + } + if completed, err := this.Complete(ctx, opts, request.CompletionRequest); err == nil { + result = completed + } + } + } + } + return json.NewEncoder(output).Encode(result) + } + return nil +} + +func unquoteNuCompletion(request *CompletionRequest) { + for i, word := range request.Words { + if i == request.Word && request.Offset >= 0 && request.Offset <= len(word) { + request.Offset = len(unquoteNuWord(word[:request.Offset])) + } + request.Words[i] = unquoteNuWord(word) + } +} + +func unquoteNuWord(word string) string { + prefix := "" + if strings.HasPrefix(word, "-") { + if name, value, found := strings.Cut(word, "="); found { + prefix, word = name+"=", value + } + } + if len(word) == 0 || (word[0] != '\'' && word[0] != '"' && word[0] != '`') { + return prefix + word + } + quote := word[0] + if len(word) == 1 || word[len(word)-1] != quote { + word += string(quote) + } + if quote == '"' { + if value, err := strconv.Unquote(word); err == nil { + return prefix + value + } + } + return prefix + word[1:len(word)-1] +} + +func boolInt(value bool) int { + if value { + return 1 + } + return 0 +} diff --git a/completion_protocol_test.go b/completion_protocol_test.go new file mode 100644 index 0000000..b4589cf --- /dev/null +++ b/completion_protocol_test.go @@ -0,0 +1,116 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/echocat/cli4g/codec" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCompletionProtocolSkipsLifecycleAndUsesContextStreams(t *testing.T) { + app := NewSimpleApplication("tool").WithCompletion(true).WithErrorHandlerEnabled(false). + WithFlag(NewFlag(codec.NewBool(), "verbose")). + WithHandler(func(context.Context, NoOptions) error { panic("not a normal invocation") }).MustCreate() + defer func() { assert.NoError(t, app.Close()) }() + var output bytes.Buffer + ctx := ContextWithStdio(nil, strings.NewReader(`{"words":["--ver"],"word":0,"offset":5}`), &output, nil) + invocation, err := app.Parse(ctx, nil, DefaultCompletionRequestCommandName, "1", "nu") + require.NoError(t, err) + require.Empty(t, output.String()) + defer func() { assert.NoError(t, invocation.Close()) }() + require.NoError(t, invocation.Execute(ctx)) + var result codec.CompletionResult + require.NoError(t, json.Unmarshal(output.Bytes(), &result)) + assert.Equal(t, []string{"--verbose"}, completionTexts(result)) + output.Reset() + require.NoError(t, app.ParseAndExecute(ctx, nil, DefaultCompletionRequestCommandName, "1", "bash", "10", "tool --ver", "--ver")) + assert.Equal(t, []string{"1", "0", "0", "0", "", "", "", "--verbose", ""}, strings.Split(output.String(), "\x00")) +} + +func TestCompletionProtocolRejectsMalformedRequestsQuietly(t *testing.T) { + app := NewSimpleApplication("tool").WithCompletion(true).MustCreate() + defer func() { assert.NoError(t, app.Close()) }() + for _, input := range []string{``, `{`, `{"words":null,"word":-1}`, `{} {}`, `{"unexpected":true}`} { + var output, diagnostics bytes.Buffer + ctx := ContextWithStdio(nil, strings.NewReader(input), &output, &diagnostics) + require.NoError(t, app.ParseAndExecute(ctx, nil, DefaultCompletionRequestCommandName, "1", "nu")) + var result codec.CompletionResult + require.NoError(t, json.Unmarshal(output.Bytes(), &result)) + assert.True(t, result.Handled) + assert.Empty(t, result.Candidates) + assert.Empty(t, diagnostics.String()) + } +} + +func TestCompletionInstallationIsIdempotentAndProtectsExistingFiles(t *testing.T) { + app := NewSimpleApplication("tool").WithCompletionRequestCommandName(DefaultCompletionRequestCommandName).MustCreate() + defer func() { assert.NoError(t, app.Close()) }() + for _, shell := range []CompletionShell{CompletionBash, CompletionNushell} { + directory := t.TempDir() + path, err := app.InstallCompletion(nil, shell, directory) + require.NoError(t, err) + contents, err := os.ReadFile(path) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(string(contents), completionScriptHeader)) + again, err := app.InstallCompletion(nil, shell, directory) + require.NoError(t, err) + assert.Equal(t, path, again) + require.NoError(t, os.WriteFile(path, []byte("user-managed"), 0600)) + _, err = app.InstallCompletion(nil, shell, directory) + require.ErrorContains(t, err, "not managed by cli4g") + contents, err = os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, "user-managed", string(contents)) + } + t.Setenv("BASH_COMPLETION_USER_DIR", t.TempDir()) + directory, err := completionDirectory(context.Background(), CompletionBash) + require.NoError(t, err) + assert.Equal(t, filepath.Join(os.Getenv("BASH_COMPLETION_USER_DIR"), "completions"), directory) + _, err = app.CompletionScript("fish") + require.Error(t, err) + _, err = NewSimpleApplication("../bad").MustCreate().InstallCompletion(nil, CompletionBash, t.TempDir()) + require.Error(t, err) +} + +func TestCompletionDoesNotReserveTheFormerProtocolFlag(t *testing.T) { + app := NewSimpleApplication("tool").WithCompletion(true). + WithHandler(func(context.Context, NoOptions) error { return nil }). + WithErrorHandlerEnabled(false).WithStringFlag("cli4g-completion").MustCreate() + defer func() { assert.NoError(t, app.Close()) }() + invocation, err := app.Parse(nil, nil, "--cli4g-completion", "ordinary-value") + require.NoError(t, err) + defer func() { assert.NoError(t, invocation.Close()) }() + _, value := invocation.Flag("cli4g-completion") + assert.Equal(t, "ordinary-value", value) +} + +func TestBashCompletionInputQuotesSeparatorsUnicodeAndCursor(t *testing.T) { + for _, test := range []struct { + line string + point int + want CompletionRequest + }{ + {"tool --format=ya", len("tool --format=ya"), completionAtEnd("--format=ya")}, + {"tool --format 'two wo", len("tool --format 'two wo"), completionAtEnd("--format", "two wo")}, + {"tool --format two\\ wo", len("tool --format two\\ wo"), completionAtEnd("--format", "two wo")}, + {"tool --format über", len("tool --format über"), completionAtEnd("--format", "über")}, + {"other | tool --format ya", len("other | tool --format ya"), completionAtEnd("--format", "ya")}, + {"tool --format ya | other", len("tool --format ya"), completionAtEnd("--format", "ya")}, + {"tool --format yaml --context production", len("tool --format ya"), CompletionRequest{Words: []string{"--format", "yaml", "--context", "production"}, Word: 1, Offset: 2}}, + {"tool ", len("tool "), completionAtEnd("")}, + {"tool --flag", len("tool "), CompletionRequest{Words: []string{"", "--flag"}, Word: 0}}, + } { + t.Run(test.line, func(t *testing.T) { + request, err := bashCompletionRequest(test.line, test.point) + require.NoError(t, err) + assert.Equal(t, test.want, request) + }) + } +} diff --git a/completion_scan.go b/completion_scan.go new file mode 100644 index 0000000..388d5ac --- /dev/null +++ b/completion_scan.go @@ -0,0 +1,293 @@ +package cli + +import ( + "context" + "reflect" + "slices" + "strings" + "unicode/utf8" + + "github.com/echocat/cli4g/codec" +) + +type completionScan struct { + request CompletionRequest + profiles selectedProfiles + path []selectedContainer + scopes []codec.CompletionScope + positions [][]int + target codec.CompletionTarget + valuePrefix string + commandSite bool +} + +func (scan *completionScan) addScope(selected selectedContainer) { + scan.path = append(scan.path, selected) + var names []string + if len(scan.scopes) > 0 { + names = slices.Clone(scan.scopes[len(scan.scopes)-1].CommandPath) + } + names = append(names, selected.container.Name()) + scan.scopes = append(scan.scopes, codec.CompletionScope{CommandPath: names, + Flags: make(map[string][]codec.CompletionOccurrence), Arguments: make(map[string][]string)}) + scan.positions = append(scan.positions, nil) +} + +func (scan *completionScan) scan(ctx context.Context, root Container) error { + selected, err := selectContainer(root, nil, scan.profiles) + if err != nil { + return err + } + scan.addScope(selected) + end := false + for i := 0; i < len(scan.request.Words); i++ { + if err := ctx.Err(); err != nil { + return err + } + word := scan.request.Words[i] + active := len(scan.path) - 1 + if !end && i == scan.request.Word && strings.HasPrefix(word, "-") { + scan.target = codec.CompletionTarget{Kind: codec.CompletionFlag, Scope: active} + if word == "-" { + continue + } + } + if !end && word == "--" && i != scan.request.Word { + end = true + continue + } + if !end && strings.HasPrefix(word, "--") { + name, inline, hasInline := strings.Cut(word[2:], "=") + owner, flag := findInputFlag(scan.path, name, 0) + if flag != nil { + var values []string + start := len(word) + if hasInline { + values, start = []string{inline}, len(name)+3 + } + scan.flag(&i, owner, flag, values, start, hasInline) + } + continue + } + if !end && strings.HasPrefix(word, "-") && word != "-" { + scan.shorts(&i) + continue + } + if !end && len(scan.scopes[active].Positionals) == 0 && i != scan.request.Word { + if command, ok := scan.path[active].commandByName[word]; ok { + selected, err := selectContainer(command, scan.path, scan.profiles) + if err != nil { + return err + } + scan.addScope(selected) + continue + } + } + if i == scan.request.Word { + scan.commandSite = !end && len(scan.scopes[active].Positionals) == 0 && len(scan.path[active].commands) > 0 + scan.target = codec.CompletionTarget{Kind: codec.CompletionArgument, Scope: active} + } + scan.scopes[active].Positionals = append(scan.scopes[active].Positionals, word) + scan.positions[active] = append(scan.positions[active], i) + } + // The final path determines inherited codec overrides, just as in parsing. + plan := scan.path[len(scan.path)-1].container.base().plan + for i := range scan.path { + schema, err := selectSchema(&plan.schemas[i], scan.profiles) + if err != nil { + return err + } + scan.path[i].schema = schema + scan.assignArguments(i) + } + if scan.commandSite && scan.target.Name == "" { + scan.target.Kind = codec.CompletionCommand + } + return ctx.Err() +} + +func (scan *completionScan) shorts(index *int) { + word := scan.request.Words[*index] + for start := 1; start < len(word); { + short, size := utf8.DecodeRuneInString(word[start:]) + start += size + owner, flag := findInputFlag(scan.path, "", short) + if flag == nil { + return + } + rest := word[start:] + hasInline := strings.HasPrefix(rest, "=") + consumes := hasInline || flag.arity.min > 0 || flag.arity.max > 1 || flag.arity.max == -1 + var values []string + if consumes && rest != "" { + if hasInline { + start++ + } + values = []string{word[start:]} + } + scan.flag(index, owner, flag, values, start, consumes && rest != "") + if consumes { + return + } + } +} + +func (scan *completionScan) flag(index *int, owner int, flag *Flag[any, any], values []string, start int, inline bool) { + word := *index + occurrence := len(scan.scopes[owner].Flags[flag.Name()]) + if word == scan.request.Word && inline && scan.request.Offset >= start { + scan.valueTarget(owner, flag, occurrence, 0, scan.request.Words[word][:start]) + } + if flag.arity.max != 0 && flag.arity != (valueArity{min: 0, max: 1}) { + active := len(scan.path) - 1 + for *index+1 < len(scan.request.Words) && (flag.arity.max == -1 || len(values) < flag.arity.max) { + next := scan.request.Words[*index+1] + if next == "--" { + break + } + // Recover at a known option instead of swallowing it as a missing + // required value. Literal option-looking values remain expressible inline. + if len(values) < flag.arity.min && scan.optionBoundary(next, *index+1) { + break + } + if len(values) >= flag.arity.min { + if strings.HasPrefix(next, "-") && next != "-" { + break + } + if len(scan.scopes[active].Positionals) == 0 { + if _, command := scan.path[active].commandByName[next]; command && *index+1 != scan.request.Word { + break + } + } + } + *index++ + if *index == scan.request.Word { + scan.valueTarget(owner, flag, occurrence, len(values), "") + } + values = append(values, next) + } + } + scan.scopes[owner].Flags[flag.Name()] = append(scan.scopes[owner].Flags[flag.Name()], codec.CompletionOccurrence{ + Values: slices.Clone(values), Missing: len(values) < flag.arity.min, Word: word, + }) +} + +func (scan *completionScan) optionBoundary(word string, index int) bool { + if strings.HasPrefix(word, "--") { + name, _, _ := strings.Cut(word[2:], "=") + _, flag := findInputFlag(scan.path, name, 0) + return flag != nil || index == scan.request.Word + } + if strings.HasPrefix(word, "-") && len(word) > 1 { + short, _ := utf8.DecodeRuneInString(word[1:]) + _, flag := findInputFlag(scan.path, "", short) + return flag != nil + } + return false +} + +func (scan *completionScan) valueTarget(owner int, flag *Flag[any, any], occurrence, value int, prefix string) { + scan.target = codec.CompletionTarget{Kind: codec.CompletionFlagValue, Name: flag.Name(), Scope: owner, Occurrence: occurrence, ValueIndex: value} + scan.valuePrefix = prefix +} + +func (scan *completionScan) assignArguments(scope int) { + position := 0 + for _, argument := range scan.path[scope].schema.arguments { + count := len(scan.scopes[scope].Positionals) - position + if argument.arity.max >= 0 { + count = min(count, argument.arity.max) + } + if count == 0 { + continue + } + scan.scopes[scope].Arguments[argument.Name()] = slices.Clone(scan.scopes[scope].Positionals[position : position+count]) + for i := 0; i < count; i++ { + if scan.positions[scope][position+i] == scan.request.Word { + scan.target = codec.CompletionTarget{Kind: codec.CompletionArgument, Name: argument.Name(), Scope: scope, ValueIndex: i} + } + } + position += count + } +} + +func (scan *completionScan) input() codec.CompletionInput { + word := scan.request.Words[scan.request.Word] + leaf := scan.scopes[len(scan.scopes)-1] + input := codec.CompletionInput{ + Words: scan.request.Words, Word: scan.request.Word, Prefix: strings.TrimPrefix(word[:scan.request.Offset], scan.valuePrefix), + Suffix: word[scan.request.Offset:], Target: scan.target, ValueIndex: scan.target.ValueIndex, Profiles: slices.Clone(scan.profiles), + CommandPath: leaf.CommandPath, Scopes: scan.scopes, Flags: make(map[string][]codec.CompletionOccurrence), + Arguments: leaf.Arguments, Positionals: leaf.Positionals, + } + // Include only the deepest visible definition for each canonical name. + for i := range scan.path { + for _, flag := range scan.path[i].schema.flags { + delete(input.Flags, flag.Name()) + if occurrences, ok := scan.scopes[i].Flags[flag.Name()]; ok { + input.Flags[flag.Name()] = occurrences + } + } + } + if scan.target.Kind == codec.CompletionFlagValue { + input.Values = scan.scopes[scan.target.Scope].Flags[scan.target.Name][scan.target.Occurrence].Values + } else if scan.target.Kind == codec.CompletionArgument { + input.Values = scan.scopes[scan.target.Scope].Arguments[scan.target.Name] + } + return input +} + +func (scan *completionScan) targetCodec() codec.Codec[any, any] { + schema := scan.path[scan.target.Scope].schema + if scan.target.Kind == codec.CompletionFlagValue { + if flag := schema.flagByName[scan.target.Name]; flag != nil { + return flag.codec + } + } + if scan.target.Kind == codec.CompletionArgument { + for _, argument := range schema.arguments { + if argument.Name() == scan.target.Name { + return argument.codec + } + } + } + return nil +} + +func (scan *completionScan) targetType() reflect.Type { + schema := scan.path[scan.target.Scope].schema + if scan.target.Kind == codec.CompletionFlagValue { + return schema.flagByName[scan.target.Name].ValueType() + } + for _, argument := range schema.arguments { + if argument.Name() == scan.target.Name { + return argument.ValueType() + } + } + return nil +} + +func (scan *completionScan) flagCandidates() []codec.CompletionCandidate { + var result []codec.CompletionCandidate + for i, selected := range scan.path { + for _, flag := range selected.schema.flags { + if !flag.IsVisible() { + continue + } + used := false + for _, occurrence := range scan.scopes[i].Flags[flag.Name()] { + used = used || occurrence.Word != scan.request.Word + } + if used && !flag.IsCumulative() { + continue + } + for _, name := range flag.Names() { + result = append(result, codec.CompletionCandidate{Value: "--" + name, Description: flag.Description()}) + } + for _, short := range flag.Shorts() { + result = append(result, codec.CompletionCandidate{Value: "-" + string(short), Description: flag.Description()}) + } + } + } + return result +} diff --git a/completion_shell.go b/completion_shell.go new file mode 100644 index 0000000..37ed797 --- /dev/null +++ b/completion_shell.go @@ -0,0 +1,245 @@ +package cli + +import ( + "context" + "crypto/sha256" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +// CompletionShell selects a native adapter, not a shell to start for each query. +type CompletionShell string + +const ( + CompletionBash CompletionShell = "bash" // Bash 4.4+; bash-completion enables on-demand loading. + CompletionNushell CompletionShell = "nu" // Nushell 0.114+ with command-wide completers. +) + +const completionScriptHeader = "# Generated by cli4g shell completion protocol v1.\n" + +// CompletionScript generates a small, schema-independent adapter for Name. +// It does not run the application at shell startup. Completion requests consult +// the current executable using the captured CompletionRequestCommandName. +// A binary update needs no adapter regeneration while protocol v1 is supported. +// Configure that entry directly or use WithCompletion(true). An empty request +// name returns an error rather than producing an unusable adapter. +// +// Bash users can source the script; bash-completion can load an installed script +// on demand. Nushell users can source it or use its exported extern definition; +// InstallCompletion writes an autoload script. The Nu adapter declares only a +// free-form external signature, so changing flags/commands never leaves a stale +// signature. It preserves native execution and does not replace the user's global +// external completer. Its parameter accepts both legacy span lists and newer +// named place records. It quotes literal candidates and preserves native Nu path +// quoting; Nu controls whitespace insertion. Bash honors NoSpace and KeepOrder. +// +// Bash recognizes simple quotes, escapes, and separators but deliberately does +// not evaluate shell substitutions or expansions while collecting dependency +// values. Applications requiring already-expanded values may call Complete with +// their own tokenized CompletionRequest. Names containing NUL/newlines are rejected. +func (this *Application[O]) CompletionScript(shell CompletionShell) (string, error) { + if this == nil || this.Name() == "" || strings.ContainsAny(this.Name(), "\x00\r\n") { + return "", fmt.Errorf("invalid completion command name") + } + if this.CompletionRequestCommandName() == "" { + return "", fmt.Errorf("completion request entry is not configured") + } + hash := sha256.Sum256([]byte(this.Name())) + function := fmt.Sprintf("cli4g_%x", hash[:8]) + var template string + switch shell { + case CompletionBash: + template = bashCompletionScript + case CompletionNushell: + template = nushellCompletionScript + default: + return "", NewValidationError("supported completion shells are bash and nu") + } + return completionScriptHeader + strings.NewReplacer( + "@FUNCTION@", function, + "@BASHCOMMAND@", "'"+strings.ReplaceAll(this.Name(), "'", "'\\''")+"'", + "@NUCOMMAND@", strconv.Quote(this.Name()), + "@BASHREQUEST@", "'"+strings.ReplaceAll(this.CompletionRequestCommandName(), "'", "'\\''")+"'", + "@NUREQUEST@", strconv.Quote(this.CompletionRequestCommandName()), + ).Replace(template), nil +} + +// InstallCompletion installs an adapter and returns its path. Empty directory +// chooses Bash's first BASH_COMPLETION_USER_DIR (otherwise XDG_DATA_HOME or +// ~/.local/share, then bash-completion/completions), or asks the installed nu for +// its first user-autoload directory. The Nu discovery process runs only during +// installation. Supply directory explicitly for packaging or custom shell homes. +// +// The operation is idempotent, uses an atomic replacement where supported by the +// OS, and refuses to overwrite a file without cli4g's generated header. It creates +// directories with 0755 subject to the umask and sets scripts to 0644. +// It does not modify .bashrc/config.nu or install bash-completion itself. An +// existing bash-completion loader can discover the file at the next completion; +// Nu autoloads it on its next interactive startup. Source the returned file for +// the current session. Files contain no application schema or user input values. +// Call this again to update the adapter implementation, not for ordinary binary +// updates. The ordinary standard install command uses its invocation's context streams. +func (this *Application[O]) InstallCompletion(ctx context.Context, shell CompletionShell, directory string) (string, error) { + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return "", err + } + script, err := this.CompletionScript(shell) + if err != nil { + return "", err + } + if filepath.Base(this.Name()) != this.Name() || this.Name() == "." || this.Name() == ".." || strings.ContainsAny(this.Name(), "/\\") { + return "", fmt.Errorf("completion installation requires a base command name") + } + if directory == "" { + directory, err = completionDirectory(ctx, shell) + if err != nil { + return "", err + } + } + name := this.Name() + ".bash" + if shell == CompletionNushell { + name = "cli4g-" + this.Name() + ".nu" + } + path := filepath.Join(directory, name) + previous, err := os.ReadFile(path) + if err == nil { + if string(previous) == script { + return path, nil + } + if !strings.HasPrefix(string(previous), completionScriptHeader) { + return "", fmt.Errorf("completion file already exists and is not managed by cli4g") + } + } else if !os.IsNotExist(err) { + return "", err + } + if err := os.MkdirAll(directory, 0755); err != nil { + return "", err + } + file, err := os.CreateTemp(directory, ".cli4g-completion-*") + if err != nil { + return "", err + } + defer func() { _ = os.Remove(file.Name()) }() + _, writeErr := file.WriteString(script) + closeErr := file.Close() + if writeErr != nil { + return "", writeErr + } + if closeErr != nil { + return "", closeErr + } + if err := os.Chmod(file.Name(), 0644); err != nil { + return "", err + } + if err := ctx.Err(); err != nil { + return "", err + } + if err := os.Rename(file.Name(), path); err != nil { + return "", err + } + return path, nil +} + +func completionDirectory(ctx context.Context, shell CompletionShell) (string, error) { + if shell == CompletionNushell { + output, err := exec.CommandContext(ctx, "nu", "-n", "-c", "$nu.user-autoload-dirs | first").Output() + if err != nil { + return "", fmt.Errorf("could not discover Nushell autoload directory: %w", err) + } + path := strings.TrimSpace(string(output)) + if path == "" { + return "", fmt.Errorf("nushell returned no autoload directory") + } + return path, nil + } + for _, directory := range filepath.SplitList(os.Getenv("BASH_COMPLETION_USER_DIR")) { + if directory != "" { + return filepath.Join(directory, "completions"), nil + } + } + data := os.Getenv("XDG_DATA_HOME") + if data == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + data = filepath.Join(home, ".local", "share") + } + return filepath.Join(data, "bash-completion", "completions"), nil +} + +const bashCompletionScript = ` +_@FUNCTION@() { + local command="$1" field mode path candidate i + local -a response=() + while IFS= read -r -d '' field; do + response+=("$field") + done < <("$command" @BASHREQUEST@ 1 bash "${COMP_POINT:-0}" "${COMP_LINE:-}" "${2:-}" 2>/dev/null) + COMPREPLY=() + ((${#response[@]} >= 7)) && [[ "${response[0]}" == 1 ]] || return 0 + [[ "${response[2]}" == 1 ]] && compopt -o nospace + [[ "${response[3]}" == 1 ]] && compopt -o nosort + for ((i=7; i<${#response[@]}; i++)); do + COMPREPLY+=("${response[i]}") + done + mode="${response[1]}" + if [[ "$mode" != 0 ]]; then + local action=-f + [[ "$mode" == 2 ]] && action=-d + while IFS= read -r path; do + candidate="${response[4]}$path" + candidate="${candidate#"${response[6]}"}" + if [[ -d "$path" ]]; then + candidate="${candidate%/}/" + compopt -o nospace + fi + COMPREPLY+=("$candidate") + done < <(compgen "$action" -- "${response[5]}") + fi + return 0 +} +complete -o filenames -F _@FUNCTION@ -- @BASHCOMMAND@ +` + +const nushellCompletionScript = ` +module @FUNCTION@ { + def suggest [place] { + let words = if (($place | describe) | str starts-with "record") { $place.command } else { $place } + if ($words | length) < 2 { return [] } + let args = ($words | skip 1) + let current = ($args | last) + let request = {words: $args, word: (($args | length) - 1), offset: ($current | str length --utf-8-bytes), raw: true} + let response = try { + $request | to json --raw | ^($words.0) @NUREQUEST@ 1 nu | complete + } catch { return [] } + if $response.exit_code != 0 { return [] } + let result = try { $response.stdout | from json } catch { return [] } + mut candidates = ($result.candidates? | default [] | each {|candidate| + if $candidate.value =~ '^[-\p{L}\p{N}_./:=+]+$' { + $candidate + } else { + $candidate | upsert display_override $candidate.value | update value ($candidate.value | to nuon --raw) + } + }) + if (($result.files? | default false) or ($result.directories? | default false)) { + let prefix = ($result.filePrefix? | default "") + let search = ($result.fileInput? | default "") + let kind = if ($result.directories? | default false) { "directory" } else { "path" } + let paths = ($search | commandline complete --type $kind | each {|value| {value: ($prefix + $value)} }) + $candidates = ($candidates | append $paths) + } + $candidates + } + + @complete suggest + export extern @NUCOMMAND@ [...args: string] +} +use @FUNCTION@ * +` diff --git a/completion_shell_test.go b/completion_shell_test.go new file mode 100644 index 0000000..9544538 --- /dev/null +++ b/completion_shell_test.go @@ -0,0 +1,144 @@ +package cli + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + + "github.com/echocat/cli4g/codec" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNativeShellCompletion(t *testing.T) { + if testing.Short() { + t.Skip("builds the completion fixture and runs available native shells") + } + directory := t.TempDir() + name := "cli4g-completion-test" + binary := filepath.Join(directory, name) + if runtime.GOOS == "windows" { + binary += ".exe" + } + build := func(variant, publicCommand string) { + flags := "-X main.variant=" + variant + " -X main.completionCommand=" + publicCommand + " -X main.completionRequestCommand=__suggest" + command := exec.Command("go", "build", "-ldflags", flags, "-o", binary, "./internal/testdata/completion") + output, err := command.CombinedOutput() + require.NoError(t, err, "%s", output) + } + build("initial", "shell-setup") + require.NoError(t, os.WriteFile(filepath.Join(directory, "config.yaml"), nil, 0600)) + require.NoError(t, os.WriteFile(filepath.Join(directory, "space file.yaml"), nil, 0600)) + if runtime.GOOS != "windows" { + require.NoError(t, os.WriteFile(filepath.Join(directory, "colon:file.yaml"), nil, 0600)) + } + app := NewSimpleApplication(name).WithCompletion(true). + WithCompletionCommand(NewCompletionCommand().WithName("shell-setup")).WithCompletionRequestCommandName("__suggest").MustCreate() + defer func() { assert.NoError(t, app.Close()) }() + paths := make(map[CompletionShell]string) + for _, shell := range []CompletionShell{CompletionBash, CompletionNushell} { + path, err := app.InstallCompletion(nil, shell, directory) + require.NoError(t, err) + paths[shell] = path + } + assertBash := func(t *testing.T, line, current string, want []string) { + t.Helper() + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("Bash is not installed") + } + if err := exec.Command("bash", "-c", "((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4)))").Run(); err != nil { + t.Skip("Bash 4.4 or newer is required") + } + command := exec.Command("bash", "--noprofile", "--norc", "-c", ` +source "$1" +compopt() { :; } +COMP_LINE="$2" +COMP_POINT="$5" +[[ $(complete -p cli4g-completion-test) =~ -F\ ([^\ ]+) ]] +"${BASH_REMATCH[1]}" "$3" "$4" "" +if ((${#COMPREPLY[@]})); then printf '%s\0' "${COMPREPLY[@]}"; fi +`, "--", paths[CompletionBash], line, binary, current, strconv.Itoa(len(line))) + command.Dir = directory + output, err := command.CombinedOutput() + require.NoError(t, err, "%s", output) + values := []string{} + if len(output) > 0 { + values = strings.Split(strings.TrimSuffix(string(output), "\x00"), "\x00") + } + assert.Equal(t, want, values) + } + assertNu := func(t *testing.T, line string, want []string) { + t.Helper() + if _, err := exec.LookPath("nu"); err != nil { + t.Skip("Nushell is not installed") + } + command := exec.Command("nu", "-n", "-c", "source "+strconv.Quote(paths[CompletionNushell])+"; "+strconv.Quote(line)+" | commandline complete --detailed | to json --raw") + command.Dir = directory + command.Env = append(os.Environ(), "PATH="+directory+string(os.PathListSeparator)+os.Getenv("PATH")) + output, err := command.CombinedOutput() + require.NoError(t, err, "%s", output) + var candidates []codec.CompletionCandidate + require.NoError(t, json.Unmarshal(output, &candidates), "%s", output) + assert.Equal(t, want, completionTexts(codec.CompletionResult{Candidates: candidates})) + } + t.Run("bash", func(t *testing.T) { + assertBash(t, name+" --format=ya", "ya", []string{"yaml"}) + assertBash(t, name+" --format ü", "ü", []string{"über"}) + assertBash(t, name+" --format 'two wo", "two wo", []string{"two words"}) + assertBash(t, name+" --context cluster-b --namespace cl", "cl", []string{"cluster-b-namespace"}) + assertBash(t, name+" get po", "po", []string{"pods"}) + assertBash(t, name+" --initial", "--initial", []string{"--initial"}) + assertBash(t, name+" --format nope", "nope", []string{}) + assertBash(t, name+" --input=config", "config", []string{"config.yaml"}) + assertBash(t, name+` --input="space fi`, "space fi", []string{"space file.yaml"}) + if runtime.GOOS != "windows" { + assertBash(t, name+" --input=colon:fi", "fi", []string{"file.yaml"}) + } + }) + t.Run("nushell", func(t *testing.T) { + assertNu(t, name+" --format=ya", []string{"--format=yaml"}) + assertNu(t, name+" --format ü", []string{"über"}) + assertNu(t, name+" --format 'two wo", []string{`"two words"`}) + // The inserted representation must still be one literal native argument. + command := exec.Command("nu", "-n", "-c", "^"+strconv.Quote(binary)+` __echo-args "two words"`) + output, err := command.CombinedOutput() + require.NoError(t, err, "%s", output) + var arguments []string + require.NoError(t, json.Unmarshal(output, &arguments)) + assert.Equal(t, []string{"two words"}, arguments) + assertNu(t, name+" --context cluster-b --namespace cl", []string{"cluster-b-namespace"}) + assertNu(t, name+" get po", []string{"pods"}) + assertNu(t, name+" --initial", []string{"--initial"}) + assertNu(t, name+" --input=config", []string{"--input=config.yaml"}) + assertNu(t, name+` --input="space fi`, []string{"--input=`space file.yaml`"}) + }) + // Reuse exactly the installed adapters against a replaced executable. + build("updated", "shell-admin") + t.Run("bash_after_binary_update", func(t *testing.T) { + assertBash(t, name+" --up", "--up", []string{"--updated"}) + }) + t.Run("nushell_after_binary_update", func(t *testing.T) { + assertNu(t, name+" --up", []string{"--updated"}) + }) +} + +func BenchmarkCompletionProcess(b *testing.B) { + path := filepath.Join(b.TempDir(), "completion-test") + if runtime.GOOS == "windows" { + path += ".exe" + } + output, err := exec.Command("go", "build", "-o", path, "./internal/testdata/completion").CombinedOutput() + require.NoError(b, err, "%s", output) + for b.Loop() { + command := exec.Command(path, DefaultCompletionRequestCommandName, "1", "nu") + command.Stdin = strings.NewReader(`{"words":["--format","j"],"word":1,"offset":1}`) + if output, err := command.CombinedOutput(); err != nil { + b.Fatalf("%v: %s", err, output) + } + } +} diff --git a/completion_test.go b/completion_test.go new file mode 100644 index 0000000..2d730c0 --- /dev/null +++ b/completion_test.go @@ -0,0 +1,262 @@ +package cli + +import ( + "context" + "errors" + "os" + "sync" + "testing" + + "github.com/echocat/cli4g/codec" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type completingString struct { + codec.Codec[string, string] + complete codec.CompletionFunc +} + +func TestCompletionCancelsAnActiveProvider(t *testing.T) { + started := make(chan struct{}) + provider := completingString{Codec: codec.NewString(), complete: func(ctx context.Context, _ codec.CompletionInput) (codec.CompletionResult, error) { + close(started) + <-ctx.Done() + return codec.CompletionResult{}, ctx.Err() + }} + app := NewSimpleApplication("tool").WithFlag(NewFlag(provider, "value")).MustCreate() + defer func() { assert.NoError(t, app.Close()) }() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + finished := make(chan error, 1) + go func() { + _, err := app.Complete(ctx, nil, completionAtEnd("--value", "")) + finished <- err + }() + <-started + cancel() + require.ErrorIs(t, <-finished, context.Canceled) +} + +func (source completingString) ToRaw(ctx context.Context) codec.Codec[any, any] { + return codec.Raw(ctx, source) +} + +func (source completingString) Complete(ctx context.Context, input codec.CompletionInput) (codec.CompletionResult, error) { + return source.complete(ctx, input) +} + +func completionAtEnd(words ...string) CompletionRequest { + if len(words) == 0 { + return CompletionRequest{} + } + return CompletionRequest{Words: words, Word: len(words) - 1, Offset: len(words[len(words)-1])} +} + +func completionTexts(result codec.CompletionResult) []string { + values := make([]string, len(result.Candidates)) + for i, candidate := range result.Candidates { + values[i] = candidate.Value + } + return values +} + +func TestCompletionNamesEnumsCollectionsAndFiles(t *testing.T) { + choice, err := codec.NewEnum(codec.NewString(), "production", "preview", "development") + require.NoError(t, err) + app := NewApplication[struct { + Namespace string `flag:"namespace|ns" short:"n" enum:"production|preview|development"` + Verbose bool `flag:"verbose" short:"v"` + Hidden bool `flag:"secret" visible:"false"` + Labels []string `flag:"label"` + Choices []string `flag:"choice" type:"choices"` + Mapping map[string]string `flag:"mapping" type:"mapping"` + Input *os.File `flag:"input"` + }]("tool").WithErrorHandlerEnabled(false). + WithCodec(codec.NewSlice(choice), "choices"). + WithCodec(codec.NewMap[string](choice), "mapping"). + WithCommand(NewSimpleCommand("deploy").WithAliases("push"), NewSimpleCommand("internal").WithVisible(false)). + MustCreate() + defer func() { assert.NoError(t, app.Close()) }() + for _, test := range []struct { + words []string + want []string + files bool + }{ + {[]string{"dep"}, []string{"deploy"}, false}, + {[]string{"pu"}, []string{"push"}, false}, + {[]string{"--na"}, []string{"--namespace"}, false}, + {[]string{"--sec"}, []string{}, false}, + {[]string{"--namespace", "pr"}, []string{"production", "preview"}, false}, + {[]string{"--ns=pr"}, []string{"--ns=production", "--ns=preview"}, false}, + {[]string{"-npr"}, []string{"-nproduction", "-npreview"}, false}, + {[]string{"-vnpr"}, []string{"-vnproduction", "-vnpreview"}, false}, + {[]string{"--verbose=f"}, []string{"--verbose=false"}, false}, + {[]string{"--namespace", "production", "--na"}, []string{}, false}, + {[]string{"--label", "a", "--lab"}, []string{"--label"}, false}, + {[]string{"--choice", "pr"}, []string{"production", "preview"}, false}, + {[]string{"--mapping", "env=pr"}, []string{"env=production", "env=preview"}, false}, + {[]string{"--mapping=env=pr"}, []string{"--mapping=env=production", "--mapping=env=preview"}, false}, + {[]string{"--input", "missing"}, []string{}, true}, + {[]string{"--", "--na"}, []string{}, true}, + } { + t.Run(test.words[len(test.words)-1], func(t *testing.T) { + result, err := app.Complete(nil, nil, completionAtEnd(test.words...)) + require.NoError(t, err) + assert.Equal(t, test.want, completionTexts(result)) + assert.Equal(t, test.files, result.Files) + }) + } +} + +func TestCompletionMultiTokenEnumsMatchEarlierValues(t *testing.T) { + locations, err := codec.NewEnum(codec.NewStrings(), + []string{"eu", "production"}, []string{"eu", "preview"}, []string{"us", "sandbox"}) + require.NoError(t, err) + app := NewSimpleApplication("tool").WithArgument(NewArgument(locations, "location")).MustCreate() + defer func() { assert.NoError(t, app.Close()) }() + result, err := app.Complete(nil, nil, completionAtEnd("eu", "pr")) + require.NoError(t, err) + assert.Equal(t, []string{"production", "preview"}, completionTexts(result)) + result, err = app.Complete(nil, nil, completionAtEnd("us", "")) + require.NoError(t, err) + assert.Equal(t, []string{"sandbox"}, completionTexts(result)) +} + +func TestCompletionCodecSeesDependenciesScopesAndInputAfterCursor(t *testing.T) { + var captured codec.CompletionInput + var contextValue codec.CompletionValue + provider := completingString{Codec: codec.NewString(), complete: func(ctx context.Context, input codec.CompletionInput) (codec.CompletionResult, error) { + captured = input + var err error + contextValue, _, err = input.Effective.Flag(ctx, "ctx") + return codec.CompletionResult{Handled: true, Candidates: []codec.CompletionCandidate{{Value: "production"}}}, err + }} + app := NewApplication[struct { + Context string `flag:"context|ctx" short:"c" env:"CLUSTER_CONTEXT" default:"default-cluster"` + }]("tool").WithErrorHandlerEnabled(false). + WithCreateOptionsHandler(func(context.Context) (struct { + Context string `flag:"context|ctx" short:"c" env:"CLUSTER_CONTEXT" default:"default-cluster"` + }, error) { + panic("completion must not construct options") + }). + WithCommand(NewCommand[struct { + Namespace string `flag:"namespace" short:"n" type:"namespace"` + Labels []string `flag:"label"` + Resource string `argument:"resource" required:"true"` + }]("get").WithCodec(provider, "namespace")).MustCreate() + defer func() { assert.NoError(t, app.Close()) }() + words := []string{"get", "pods", "--namespace", "pro", "-c", "cluster-b", "--label", "", "--label", "web", "--unknown"} + result, err := app.Complete(nil, nil, CompletionRequest{Words: words, Word: 3, Offset: 3}) + require.NoError(t, err) + assert.Equal(t, []string{"production"}, completionTexts(result)) + assert.Equal(t, []string{"tool", "get"}, captured.CommandPath) + assert.Equal(t, codec.CompletionTarget{Kind: codec.CompletionFlagValue, Name: "namespace", Scope: 1}, captured.Target) + assert.Equal(t, []string{"cluster-b"}, captured.Flags["context"][0].Values) + assert.Equal(t, []string{""}, captured.Flags["label"][0].Values) + assert.Equal(t, []string{"web"}, captured.Flags["label"][1].Values) + assert.Equal(t, []string{"pods"}, captured.Arguments["resource"]) + assert.Equal(t, []string{"pods"}, captured.Positionals) + assert.Equal(t, words, captured.Words) + assert.Equal(t, codec.CompletionCLI, contextValue.Source) + assert.Equal(t, []string{"cluster-b"}, contextValue.Values) + assert.Contains(t, captured.Scopes[0].Flags, "context") + assert.NotContains(t, captured.Scopes[1].Flags, "context") + _, err = app.Complete(nil, nil, completionAtEnd("get", "pods", "--context", "--namespace", "pro")) + require.NoError(t, err) + assert.True(t, captured.Flags["context"][0].Missing) + assert.Empty(t, contextValue.Values) +} + +func BenchmarkCompletion(b *testing.B) { + app := NewApplication[struct { + Namespace string `flag:"namespace" enum:"production|preview|development"` + }]("tool").MustCreate() + b.Cleanup(func() { require.NoError(b, app.Close()) }) + request := completionAtEnd("--namespace", "pr") + b.ReportAllocs() + for b.Loop() { + if _, err := app.Complete(context.Background(), nil, request); err != nil { + b.Fatal(err) + } + } +} + +func TestCompletionEffectivePrecedenceAndLazyDefaults(t *testing.T) { + var value codec.CompletionValue + lookups := 0 + provider := completingString{Codec: codec.NewString(), complete: func(ctx context.Context, input codec.CompletionInput) (codec.CompletionResult, error) { + var err error + value, _, err = input.Effective.Flag(ctx, "context") + return codec.CompletionResult{Handled: true}, err + }} + app := NewApplication[struct { + Context string `flag:"context" env:"CONTEXT" default:"default-cluster"` + Namespace string `flag:"namespace" type:"namespace"` + Invalid int `flag:"invalid" default:"not-an-int"` + }]("tool").WithCodec(provider, "namespace").MustCreate() + defer func() { assert.NoError(t, app.Close()) }() + opts := &ParseOpts{LookupEnv: func(context.Context, string) (string, bool) { lookups++; return "env-cluster", true }} + _, err := app.Complete(nil, opts, completionAtEnd("--namespace", "")) + require.NoError(t, err) + assert.Equal(t, codec.CompletionEnvironment, value.Source) + assert.Equal(t, []string{"env-cluster"}, value.Values) + assert.Equal(t, "CONTEXT", value.Environment) + assert.Equal(t, 1, lookups) + _, err = app.Complete(nil, opts, completionAtEnd("--context=", "--namespace", "")) + require.NoError(t, err) + assert.Equal(t, codec.CompletionCLI, value.Source) + assert.Equal(t, []string{""}, value.Values) + assert.Equal(t, 1, lookups) + opts.LookupEnv = func(context.Context, string) (string, bool) { return "", false } + _, err = app.Complete(nil, opts, completionAtEnd("--namespace", "")) + require.NoError(t, err) + assert.Equal(t, codec.CompletionDefault, value.Source) + assert.Equal(t, []string{"default-cluster"}, value.Values) +} + +func TestCompletionProfilesErrorsCancellationAndReuse(t *testing.T) { + failure := errors.New("provider unavailable") + provider := completingString{Codec: codec.NewString(), complete: func(ctx context.Context, input codec.CompletionInput) (codec.CompletionResult, error) { + if input.Prefix == "fail" { + return codec.CompletionResult{}, failure + } + if input.Prefix == "cancel" { + <-ctx.Done() + return codec.CompletionResult{}, ctx.Err() + } + return codec.CompletionResult{Handled: true, Candidates: []codec.CompletionCandidate{{Value: input.Prefix + "-ok"}}}, nil + }} + app := NewSimpleApplication("tool").WithCodec(provider, "custom"). + WithFlag(NewFlag(provider, "value")). + WithFlag(NewFlag(codec.NewBool(), "profiled").WithProfiles("test")).MustCreate() + defer func() { assert.NoError(t, app.Close()) }() + _, err := app.Complete(nil, nil, CompletionRequest{Word: -1}) + require.Error(t, err) + _, err = app.Complete(nil, &ParseOpts{Profiles: []string{"missing"}}, CompletionRequest{}) + require.Error(t, err) + result, err := app.Complete(nil, nil, completionAtEnd("--prof")) + require.NoError(t, err) + assert.Empty(t, result.Candidates) + result, err = app.Complete(nil, &ParseOpts{Profiles: []string{"test"}}, completionAtEnd("--prof")) + require.NoError(t, err) + assert.Equal(t, []string{"--profiled"}, completionTexts(result)) + _, err = app.Complete(nil, nil, completionAtEnd("--value", "fail")) + require.ErrorIs(t, err, failure) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = app.Complete(ctx, nil, completionAtEnd("--value", "cancel")) + require.ErrorIs(t, err, context.Canceled) + var workers sync.WaitGroup + for range 8 { + workers.Go(func() { + result, err := app.Complete(nil, nil, completionAtEnd("--value", "parallel")) + assert.NoError(t, err) + assert.Equal(t, []string{"parallel-ok"}, completionTexts(result)) + }) + } + workers.Wait() + require.NoError(t, app.Close()) + _, err = app.Complete(nil, nil, CompletionRequest{}) + require.ErrorIs(t, err, ErrApplicationClosed) +} diff --git a/completion_values.go b/completion_values.go new file mode 100644 index 0000000..9d82f11 --- /dev/null +++ b/completion_values.go @@ -0,0 +1,84 @@ +package cli + +import ( + "context" + "slices" + + "github.com/echocat/cli4g/codec" +) + +type completionValues struct { + scan *completionScan + opts *ParseOpts +} + +func (values *completionValues) Flag(ctx context.Context, name string) (codec.CompletionValue, bool, error) { + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return codec.CompletionValue{}, false, err + } + owner, flag := findInputFlag(values.scan.path, name, 0) + if flag == nil { + return codec.CompletionValue{}, false, nil + } + if occurrences, ok := values.scan.scopes[owner].Flags[flag.Name()]; ok { + result := codec.CompletionValue{Source: codec.CompletionCLI} + for _, occurrence := range occurrences { + result.Values = append(result.Values, occurrence.Values...) + } + return result, true, nil + } + for _, variable := range flag.envVars { + value, exists := values.opts.lookupEnv(ctx, variable) + if err := ctx.Err(); err != nil { + return codec.CompletionValue{}, false, err + } + if exists { + return codec.CompletionValue{Source: codec.CompletionEnvironment, Values: []string{value}, Environment: variable}, true, nil + } + } + return completionDefault(ctx, flag.defaultValue, flag.codec) +} + +func (values *completionValues) Argument(ctx context.Context, name string) (codec.CompletionValue, bool, error) { + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return codec.CompletionValue{}, false, err + } + leaf := len(values.scan.path) - 1 + for _, argument := range values.scan.path[leaf].schema.arguments { + if argument.Name() != name { + continue + } + if tokens, found := values.scan.scopes[leaf].Arguments[name]; found { + return codec.CompletionValue{Source: codec.CompletionCLI, Values: slices.Clone(tokens)}, true, nil + } + return completionDefault(ctx, argument.defaultValue, argument.codec) + } + return codec.CompletionValue{}, false, nil +} + +func completionDefault(ctx context.Context, source *valueDefault[any], selected codec.Codec[any, any]) (codec.CompletionValue, bool, error) { + if source == nil { + return codec.CompletionValue{}, false, nil + } + result := codec.CompletionValue{Source: codec.CompletionDefault} + if source.isString { + result.Values = slices.Clone(source.strings) + return result, true, ctx.Err() + } + requirement, _, err := resolveDefault(ctx, source, selected, nil) + if err != nil { + return result, true, err + } + result.Values, err = selected.FormatRequirement(ctx, requirement) + result.Values = slices.Clone(result.Values) + if err != nil { + return result, true, codec.SanitizeError(err, "completion default could not be formatted") + } + return result, true, ctx.Err() +} diff --git a/definition-lifecycle.go b/definition-lifecycle.go index ab7933d..55bbc47 100644 --- a/definition-lifecycle.go +++ b/definition-lifecycle.go @@ -13,12 +13,14 @@ var ErrApplicationClosed = errors.New("application is closed") // Definitions contain immutable resource-free requirements. Closing invalidates // new operations; it needs no resource leases, release callbacks or reader locks. type definitionLifecycle struct { - closed atomic.Bool - errorHandler ErrorHandler - fallbackToStdio bool - profiles []string - maxCommandDepth uint8 - commandDepth uint8 // deepest compiled subcommand level, application excluded + closed atomic.Bool + errorHandler ErrorHandler + fallbackToStdio bool + completionRequestCommandName string + completionCommand CommandLike + profiles []string + maxCommandDepth uint8 + commandDepth uint8 // deepest compiled subcommand level, application excluded } func (this *definitionLifecycle) prepareContext(ctx context.Context) context.Context { diff --git a/doc.go b/doc.go index 41b228d..664cfb7 100644 --- a/doc.go +++ b/doc.go @@ -1,5 +1,5 @@ // Package cli builds command-line applications with typed options, subcommands, -// flags, arguments, environment values, and automatic help. +// flags, arguments, environment values, automatic help, and shell completion. // // Start with [NewApplication], add a handler with [ApplicationRequirement.WithHandler], // and run [ApplicationRequirement.MustCreateParseAndExecute] from main. @@ -128,6 +128,110 @@ // this selection available throughout parsing and execution, including factories, // decoding, help, and error callbacks. // +// # Shell completion +// +// [ApplicationRequirement.WithCompletion] is an opt-in shortcut. True assigns the +// current [DefaultCompletionCommand] and [DefaultCompletionRequestCommandName]; +// false clears both configuration fields, including custom values. There is no +// additional enabled state and Create never fills empty values from defaults. +// The standard engine derives suggestions from the existing CLI definition. +// Users install a native adapter once, without editing their shell configuration: +// +// tool completion install bash +// tool completion install nu +// +// Bash 4.4+ uses the bash-completion package's on-demand loader. Nushell 0.114+ +// uses its user autoload directory on the next interactive startup. Source the +// installed file to activate it in an existing session. For packaging, supply a +// directory after the shell name or use [Application.CompletionScript] to obtain +// an adapter without installing it. A shell subcommand also prints it to stdout: +// +// tool completion bash +// +// ApplicationRequirement.CompletionCommand is an ordinary CommandRequirementLike. +// Nil means absent. Create compiles it alongside Commands without modifying the +// builder list or shared default. It uses the same parsing, inherited inputs, +// validation, initialization, execution, cleanup, profiles, help and depth limits +// as every other command. Parent required flags also apply; required positional +// parent arguments cannot coexist with subcommands. Commands/Command and typed +// FindCommand expose the actual compiled command, not a metadata substitute. +// +// [DefaultCompletionCommand] is a replaceable, preconfigured command requirement. +// [NewCompletionCommand] creates a fresh version for per-application changes: +// +// WithCompletion(true). +// WithCompletionCommand(NewCompletionCommand().WithName("shells")) +// +// Supply any custom CommandRequirement through WithCompletionCommand instead. +// [CompletionApplicationFromContext] gives ordinary custom handlers access to the +// renderer, installer and direct completion API without knowing the root options +// type. Configure global defaults before use, without concurrent modification; +// already compiled applications keep independent definitions. +// +// ApplicationRequirement.CompletionRequestCommandName independently configures +// the special adapter entry. Empty disables it. WithCompletion(true) assigns the +// current replaceable DefaultCompletionRequestCommandName (initially "__complete"). +// This name is not registered as a Command and never appears in Commands(), help +// or suggestions. It must not collide with any root command/alias, including +// inactive profiles. Keep it stable across upgrades, or regenerate adapters/provide +// a compatibility entry point when changing it. Public command changes do not +// affect installed adapters; the wire protocol is independent of either name. +// +// Adapters contain no flags, commands, enum snapshots, or version-specific binary +// paths. At completion time they query the currently selected executable through +// [CompletionProtocolVersion]; replacing the binary therefore updates suggestions +// automatically. Sourcing an adapter starts no application process. Each query +// starts one process; startup code and Go init functions remain the application's +// responsibility. Query callbacks receive [CompletionQueryTimeout] as a cooperative +// deadline. cli4g does not maintain a background service or persistent result cache. +// +// Commands, aliases, visible flags, enum values, bools, and file inputs are +// completed automatically. Noncumulative flags already used are omitted from name +// suggestions. Slice codecs delegate to their element codec, and map codecs +// delegate the value after key=. codec.Completer adds context-dependent values, +// such as namespaces selected by --kubeconfig and --context. Its input contains +// canonical flag occurrence maps, arguments by name and position, command scopes, +// active profiles, and the current word/cursor target. Neither name completion nor +// metadata inspection invokes value completers. Providers return unquoted tokens +// and optional descriptions; shell adapters own quoting and display. +// The regular options structs and command definitions are the single CLI schema; +// the built-in shell entry point constructs the completion context automatically. +// To attach a function to an existing codec, use codec.WithCompletionHandler: +// +// WithCodec(codec.WithCompletionHandler(codec.NewString(), completeNamespaces), "namespace") +// +// A type:"namespace" tag selects that codec for the regular field. No custom +// codec wrapper type or ToRaw implementation is needed. For builder-defined +// inputs, [FlagRequirement.WithCompletionHandler] and +// [ArgumentRequirement.WithCompletionHandler] customize one flag/argument without +// changing its parsing codec. Their handler runs first; Handled=false delegates +// to the codec, then enum/bool/path defaults. See [CompletionHandler]. +// +// [Application.Complete] is the direct Go API. It accepts a [CompletionRequest] +// and [ParseOpts], tolerates unknown and incomplete input, and never constructs +// options or runs their lifecycle. Raw CLI tokens remain available even if they +// would not decode. Known flags delimit missing required values for recovery; +// literal option-looking values can be supplied inline. Other complete arguments +// after the cursor remain visible when supplied by the adapter/caller. +// codec.CompletionInput.Effective lazily reads dependencies with CLI > environment +// > declared-default precedence and identifies their source. Factory-only values +// are unavailable. Defaults and other inputs are not resolved to runtime resources. +// Shell expressions/substitutions are not executed to discover dependency values. +// +// Direct API errors are returned without invoking the application's error handler. +// Adapter queries turn failures into empty answers so terminal editing remains +// usable. Providers doing I/O must honor cancellation and close their own request +// resources. A provider-side in-memory cache does not span per-query processes; +// persistent caches must account for all configuration/context dependencies. +// +// Only a nonempty configured request name is recognized specially in the first +// token by Parse and ParseAndExecute. Parse returns a metadata-only invocation; +// Execute serves the query using context streams, without constructing, validating +// or initializing ordinary options or running business handlers. Use -- before a +// literal request-name root argument. Standard --help retains its usual bypass +// behavior for the ordinary public command. The direct Complete API remains +// available regardless of either configuration field. +// // # Lifecycle and errors // // [Application.Parse] resolves inputs, then validates and initializes options. diff --git a/docs/development.md b/docs/development.md index 631e5e0..4dd49af 100644 --- a/docs/development.md +++ b/docs/development.md @@ -19,6 +19,7 @@ mise run check:full | `mise run test --coverage` | Go and example coverage | | `mise run test:matrix` | Minimum and stable Go versions | | `mise run test:module` | Test the distributable module archive without nested modules or Git metadata | +| `mise run test:shells` | Mandatory interactive Bash/Nushell E2E tests in a Linux container | | `mise run test:fuzz` | Bounded parser fuzzing | | `mise run test:benchmarks` | Check comparison adapters and their module | | `mise run bench` | Measure CLI lifecycle comparisons and update the results page | @@ -28,6 +29,40 @@ mise run check:full List all tasks with `mise tasks`. Race detection needs a C toolchain: GCC on Linux, Xcode Command Line Tools on macOS, or MinGW-w64 on Windows. +## Shell E2E tests + +`check:full` includes a mandatory container suite. Install Podman (recommended: +rootless) or Docker; the runner prefers Podman when both are present. On macOS or +Windows, start the runtime's Linux VM first. + +```shell +mise run test:shells +mise run test:shells --engine podman +mise run test:shells --engine docker +``` + +The runner cross-builds static Linux fixture binaries with the project's Go +toolchain. Its temporary build context contains only those binaries and the test +assets, not the repository, Git metadata, or local configuration. No bind mounts, +daemon socket mounts, privileged containers, or host shell installations are needed. +The test container has no network access; image building needs registry/package +access. Containers and temporary files are cleaned up; image layers remain cached. + +The Containerfile pins Bash **5.2.37** and Nushell **0.114.1** images by manifest +digest. The runner cross-compiles a Go test binary with the `shell_e2e` build tag. +Its Go PTY driver uses Linux terminal ioctls from the existing `golang.org/x/sys` +dependency and embedded Bash/Nu setup scripts. It installs adapters through the +real CLI command, starts interactive Bash/Readline and Nu/Reedline, +presses Tab, and reads the line-editor buffer. It verifies on-demand Bash loading, +Nu autoload, quotes, Unicode, paths, context-dependent values, real `compopt` +NoSpace/KeepOrder behavior, cursor edits, and binary replacement without adapter +reinstallation. Initialization markers prove that requests bypass normal startup. +Missing/wrong-version shells and missing runtimes are errors, never skips. + +The lighter `TestNativeShellCompletion` Go test still uses locally available +shells and can skip unavailable ones. The separate container suite is the required +CI guarantee and also runs in the dependency-update quality gate. + ## Examples and coverage Examples are independent modules under [`_examples`](../_examples). @@ -56,6 +91,7 @@ complete repository. - Go linting: Linux, macOS, Windows. - Runtime tests: minimum/stable Go on each OS, including example binaries. +- Shell E2E: a required Linux job using rootless Podman and pinned Bash/Nushell images. - Shared checks: once on Linux; formats, module archives, workflows, example code, benchmark-adapter correctness, fuzzing, and vulnerabilities. Timing measurements are run separately on an idle machine, not in CI. diff --git a/flag-requirement.go b/flag-requirement.go index 5a0cb79..d222076 100644 --- a/flag-requirement.go +++ b/flag-requirement.go @@ -51,6 +51,9 @@ type FlagRequirement[V, R any] struct { Visible bool Placeholder string Codec codec.Codec[V, R] + // CompletionHandler optionally overrides value suggestions for this flag. + // Returning Handled=false delegates to the codec and automatic completion. + CompletionHandler CompletionHandler // CodecFactory configures a codec during Create, without acquiring resources. CodecFactory func() (codec.Codec[V, R], error) Field []string @@ -71,18 +74,19 @@ func (this FlagRequirement[V, R]) createRaw() (*Flag[any, any], error) { return nil, fmt.Errorf("flag %q: %w", this.Names, err) } return &Flag[any, any]{ - names: slices.Clone(this.Names), - profiles: slices.Clone(this.Profiles), - shorts: slices.Clone(this.Shorts), - envVars: slices.Clone(this.EnvVars), - description: this.Description, - required: this.Required, - visible: this.Visible, - placeholder: this.Placeholder, - codec: snapshot, - valueType: reflect.TypeFor[V](), - fieldPath: slices.Clone(this.Field), - defaultValue: rawDefault(this.defaultValue), + names: slices.Clone(this.Names), + profiles: slices.Clone(this.Profiles), + shorts: slices.Clone(this.Shorts), + envVars: slices.Clone(this.EnvVars), + description: this.Description, + required: this.Required, + visible: this.Visible, + placeholder: this.Placeholder, + codec: snapshot, + completionHandler: this.CompletionHandler, + valueType: reflect.TypeFor[V](), + fieldPath: slices.Clone(this.Field), + defaultValue: rawDefault(this.defaultValue), }, nil } @@ -205,6 +209,21 @@ func (this *FlagRequirement[V, R]) SetPlaceholder(v string) { this.Placeholder = v } +// WithCompletionHandler sets a context-aware value handler for this flag without +// changing its parsing codec or CLI syntax. It runs before the codec completer; +// Handled=false delegates, and Handled=true with no candidates suppresses further +// suggestions. A nil handler restores codec/automatic completion. Name completion +// never invokes it. Create captures the callback; it must be concurrent-safe. +func (this *FlagRequirement[V, R]) WithCompletionHandler(v CompletionHandler) *FlagRequirement[V, R] { + this.SetCompletionHandler(v) + return this +} + +// SetCompletionHandler is the setter counterpart of WithCompletionHandler. +func (this *FlagRequirement[V, R]) SetCompletionHandler(v CompletionHandler) { + this.CompletionHandler = v +} + // WithCodec replaces the decoder and clears any [FlagRequirement.WithCodecFactory]. func (this *FlagRequirement[V, R]) WithCodec(v codec.Codec[V, R]) *FlagRequirement[V, R] { this.SetCodec(v) diff --git a/flag.go b/flag.go index 4503bc7..9b9d854 100644 --- a/flag.go +++ b/flag.go @@ -11,22 +11,23 @@ import ( // Flag describes a CLI flag. Metadata and defaults remain separate from parsed // values; use [Invocation.Flag] to read V and [Flag.Default] to read a default R. type Flag[V, R any] struct { - names []string - profiles []string - shorts []rune - envVars []string - description string - required bool - visible bool - placeholder string - codec codec.Codec[V, R] - valueType reflect.Type - fieldPath []string - binding *fieldBinding - arity valueArity - defaultValue *valueDefault[R] - isHelp bool // framework-owned flag, never set by user requirements - cumulative bool + names []string + profiles []string + shorts []rune + envVars []string + description string + required bool + visible bool + placeholder string + codec codec.Codec[V, R] + completionHandler CompletionHandler + valueType reflect.Type + fieldPath []string + binding *fieldBinding + arity valueArity + defaultValue *valueDefault[R] + isHelp bool // framework-owned flag, never set by user requirements + cumulative bool } // Profiles returns a copy of the explicit profile assignments; empty means always active. @@ -81,6 +82,11 @@ func (this *Flag[V, R]) Codec() codec.Codec[V, R] { return this.codec } +// CompletionHandler returns the explicitly configured flag handler, or nil when +// value completion uses only the codec and standard handler. Codec returns the +// effective codec, including the captured flag override. +func (this *Flag[V, R]) CompletionHandler() CompletionHandler { return this.completionHandler } + func (this *Flag[V, R]) HasDefault() bool { return this.defaultValue != nil } // Default returns an independent default R, or found=false if none is declared. diff --git a/go.mod b/go.mod index 8893332..6ca05f0 100644 --- a/go.mod +++ b/go.mod @@ -5,13 +5,11 @@ go 1.27.0 require ( github.com/stretchr/testify v1.12.1 golang.org/x/mod v0.42.0 + golang.org/x/sys v0.49.0 golang.org/x/term v0.47.0 ) -require ( - go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/sys v0.49.0 // indirect -) +require go.yaml.in/yaml/v3 v3.0.5 // indirect ignore ( ./.github diff --git a/internal/integration/cases_test.go b/internal/integration/cases_test.go index 60b8f51..6e49b97 100644 --- a/internal/integration/cases_test.go +++ b/internal/integration/cases_test.go @@ -1,5 +1,10 @@ package integration +import ( + "strconv" + "strings" +) + func success(name string, args []string, stdout string) smokeCase { return smokeCase{name: name, args: args, want: expected{stdout: text(stdout)}} } @@ -8,6 +13,17 @@ func failure(name string, args []string, code int, diagnostic string, help bool) return smokeCase{name: name, args: args, want: expected{code: code, stdout: text(""), stderrContains: diagnostic, help: boolean(help)}} } +// Exercise the same entry point as the Bash adapter, without requiring an +// interactive shell or adding completion-preview commands to the example CLI. +func completionCase(name, line string, env []string, candidates ...string) smokeCase { + current := line[strings.LastIndexByte(line, ' ')+1:] + args := []string{"__complete", "1", "bash", strconv.Itoa(len(line)), line, current} + fields := append([]string{"1", "0", "0", "0", "", "", ""}, candidates...) + result := success(name, args, strings.Join(fields, "\x00")+"\x00") + result.env = env + return result +} + func exampleCases() map[string][]smokeCase { return map[string][]smokeCase{ "simple": {success("greeting", []string{"World"}, "Hello, World!\n")}, @@ -30,6 +46,20 @@ func exampleCases() map[string][]smokeCase { }, "commands-inheritance": {success("parent options", []string{"message", "greet", "Carl", "--greeting", "Hey", "--punctuation", "?"}, "Hey, Carl?\n")}, "commands-introspection": {{name: "canonical alias", args: []string{"-v", "math", "add", "2", "3", "4"}, want: expected{stdout: text("Sum: 9\n"), stderr: text("Executing: math sum\n")}}}, + "completion": { + {name: "defaults", args: []string{"deploy", "api"}, env: []string{"CLI4G_SHIP_CONTEXT=development"}, want: expected{stdout: text("Plan: api -> development/default (replicas=1, wait=false)\n")}}, + success("deployment plan", []string{"deploy", "checkout", "--context", "production", "--namespace", "payments", "--replicas", "3", "--wait"}, "Plan: checkout -> production/payments (replicas=3, wait=true)\n"), + failure("invalid replicas", []string{"deploy", "api", "--replicas", "0"}, 2, "replicas must be greater than zero", true), + completionCase("automatic command names", "ship dep", nil, "deploy"), + completionCase("automatic flag names", "ship deploy api --rep", nil, "--replicas"), + completionCase("development inventory", "ship --context development deploy worker --namespace ", nil, "default"), + completionCase("production inventory", "ship --context production deploy worker --namespace ", nil, "platform"), + completionCase("service dependent namespaces", "ship --context production deploy checkout --namespace pa", nil, "payments"), + completionCase("namespace dependent services", "ship --context production --namespace payments deploy ch", nil, "checkout"), + completionCase("authoritative empty answer", "ship --context production deploy checkout --namespace pl", nil), + completionCase("environment dependency", "ship deploy worker --namespace pl", []string{"CLI4G_SHIP_CONTEXT=production"}, "platform"), + completionCase("CLI overrides environment", "ship -c development deploy worker -n d", []string{"CLI4G_SHIP_CONTEXT=production"}, "default"), + }, "custom-codec": { success("RGB", []string{"--color", "#FF8000"}, "red=255 green=128 blue=0\n"), failure("invalid color", []string{"--color", "invalid"}, 2, "color must be #RRGGBB", true), diff --git a/internal/integration/module_archive_test.go b/internal/integration/module_archive_test.go index 1cc7bec..112befb 100644 --- a/internal/integration/module_archive_test.go +++ b/internal/integration/module_archive_test.go @@ -32,6 +32,9 @@ func TestModuleArchive(t *testing.T) { for _, name := range strings.Split(strings.TrimSuffix(listed.Stdout, "\x00"), "\x00") { path := filepath.Join(root, filepath.FromSlash(name)) info, err := os.Lstat(path) + if os.IsNotExist(err) { + continue // A working-tree deletion or rename may still be in the index. + } if err != nil { t.Fatal(err) } diff --git a/internal/shelltestcmd/Containerfile b/internal/shelltestcmd/Containerfile new file mode 100644 index 0000000..e1cd792 --- /dev/null +++ b/internal/shelltestcmd/Containerfile @@ -0,0 +1,11 @@ +# Bash 5.2.37 and Nushell 0.114.1 / Alpine 3.22; tests are static Go binaries. +# Pin image manifests so rebuilding cannot silently change the tested shells. +FROM ghcr.io/nushell/nushell:0.114.1-alpine@sha256:278ba1af47dfba2754be3f86d395f45a7c850f8a011f8ed921fa4093642257f3 AS nu +FROM docker.io/library/bash:5.2.37@sha256:a54fb4422b18f05dd3107c36f39d67b26334fda7ec89f4126052b45e228e2f15 + +RUN apk add --no-cache bash-completion ncurses-terminfo-base +COPY --from=nu /usr/bin/nu /usr/local/bin/nu +COPY fixture-initial fixture-updated /opt/fixtures/ +COPY shell-e2e /opt/tests/shell-e2e +RUN bash --version && nu --version +ENTRYPOINT ["/opt/tests/shell-e2e", "-test.v", "-test.run=^TestContainerShellCompletion$", "-test.timeout=2m"] diff --git a/internal/shelltestcmd/bash.sh b/internal/shelltestcmd/bash.sh new file mode 100644 index 0000000..0c86017 --- /dev/null +++ b/internal/shelltestcmd/bash.sh @@ -0,0 +1,13 @@ +PS1='E2E> ' +source /usr/share/bash-completion/bash_completion +bind 'set enable-bracketed-paste off' +bind 'set show-all-if-ambiguous off' +bind '"\C-x\C-n": menu-complete' +_e2e_dump() { + local value="$READLINE_LINE" + printf '\n__CLI4G_BUFFER__' + printf '%s' "$value" | cli4g-completion-test __quote-stdin + printf '__CLI4G_END__\n' +} +bind -x '"\C-o": _e2e_dump' +printf '\n__CLI4G_READY__\n' diff --git a/internal/shelltestcmd/config.nu b/internal/shelltestcmd/config.nu new file mode 100644 index 0000000..7e73fc3 --- /dev/null +++ b/internal/shelltestcmd/config.nu @@ -0,0 +1,14 @@ +$env.config.show_banner = false +$env.config.edit_mode = "emacs" +$env.config.completions.quick = true +$env.config.completions.partial = true +$env.PROMPT_COMMAND = {|| "E2E> "} +$env.PROMPT_COMMAND_RIGHT = "" +$env.PROMPT_INDICATOR = "" +$env.config.keybindings ++= [{ + name: e2e_dump + modifier: control + keycode: char_o + mode: [emacs] + event: {send: executehostcommand, cmd: 'print ("\n__CLI4G_BUFFER__" + (commandline | to json --raw) + "__CLI4G_END__")'} +}] diff --git a/internal/shelltestcmd/container_linux_test.go b/internal/shelltestcmd/container_linux_test.go new file mode 100644 index 0000000..364f95d --- /dev/null +++ b/internal/shelltestcmd/container_linux_test.go @@ -0,0 +1,186 @@ +//go:build linux && shell_e2e + +package main + +import ( + "embed" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" +) + +//go:embed bash.sh config.nu +var shellAssets embed.FS + +type shellEnvironment struct { + t *testing.T + root string + program string + marker string + env []string +} + +func newShellEnvironment(t *testing.T) *shellEnvironment { + t.Helper() + root := t.TempDir() + bin := filepath.Join(root, "bin") + must(t, os.MkdirAll(bin, 0700)) + program := filepath.Join(bin, "cli4g-completion-test") + copyExecutable(t, "/opt/fixtures/fixture-initial", program) + marker := filepath.Join(root, "initialized") + env := append(os.Environ(), + "HOME="+filepath.Join(root, "home"), "XDG_CONFIG_HOME="+filepath.Join(root, "config"), + "XDG_DATA_HOME="+filepath.Join(root, "data"), "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), + "TERM=xterm-256color", "LANG=C.UTF-8", "LC_ALL=C.UTF-8", "INPUTRC=/dev/null", "CLI4G_COMPLETION_INIT_MARKER="+marker) + must(t, os.MkdirAll(filepath.Join(root, "home"), 0700)) + for _, name := range []string{"config.yaml", "space file.yaml", "colon:file.yaml"} { + must(t, os.WriteFile(filepath.Join(root, name), nil, 0600)) + } + return &shellEnvironment{t: t, root: root, program: program, marker: marker, env: env} +} + +func copyExecutable(t *testing.T, source, destination string) { + t.Helper() + data, err := os.ReadFile(source) + must(t, err) + must(t, os.WriteFile(destination, data, 0755)) +} + +func (environment *shellEnvironment) output(name string, args ...string) []byte { + environment.t.Helper() + command := exec.Command(name, args...) + command.Dir, command.Env = environment.root, environment.env + output, err := command.CombinedOutput() + if err != nil { + environment.t.Fatalf("%s %q: %v\n%s", name, args, err, output) + } + return output +} + +func (environment *shellEnvironment) assertRequestsSkipInitialization() { + environment.t.Helper() + data, err := os.ReadFile(environment.marker) + must(environment.t, err) + if string(data) != "initialize\n" { + environment.t.Fatalf("completion ran normal initialization: %q", data) + } +} + +func (environment *shellEnvironment) start(shell string) *terminal { + environment.t.Helper() + environment.output(environment.program, "shell-setup", "install", shell) + environment.assertRequestsSkipInitialization() + if shell == "bash" { + terminal := startTerminal(environment.t, shell, []string{"--noprofile", "--norc", "-i"}, environment.env, environment.root) + setup, err := shellAssets.ReadFile("bash.sh") + must(environment.t, err) + terminal.send(string(setup)) + terminal.expect("\r?\n__CLI4G_READY__\r?\n") + terminal.prompt() + return terminal + } + path := filepath.Join(environment.root, "config", "nushell", "config.nu") + must(environment.t, os.MkdirAll(filepath.Dir(path), 0700)) + config, err := shellAssets.ReadFile("config.nu") + must(environment.t, err) + must(environment.t, os.WriteFile(path, config, 0600)) + terminal := startTerminal(environment.t, shell, []string{"--no-history", "--execute", `print "__CLI4G_READY__"`}, environment.env, environment.root) + terminal.expect("\r?\n__CLI4G_READY__\r?\n|^__CLI4G_READY__\r?\n") + terminal.prompt() + return terminal +} + +func (environment *shellEnvironment) updateBinary() { + environment.t.Helper() + replacement := filepath.Join(environment.root, "replacement") + copyExecutable(environment.t, "/opt/fixtures/fixture-updated", replacement) + must(environment.t, os.Rename(replacement, environment.program)) +} + +func TestContainerShellCompletion(t *testing.T) { + bash, err := exec.Command("bash", "--version").CombinedOutput() + must(t, err) + nu, err := exec.Command("nu", "--version").CombinedOutput() + must(t, err) + if !strings.Contains(string(bash), "version 5.2.37") || strings.TrimSpace(string(nu)) != "0.114.1" { + t.Fatalf("unexpected shell versions: %q, %q", bash, nu) + } + t.Log("Testing Bash 5.2.37 and Nushell 0.114.1 in actual terminals") + t.Run("bash_readline", func(t *testing.T) { + environment := newShellEnvironment(t) + terminal := environment.start("bash") + name := filepath.Base(environment.program) + for _, value := range [][2]string{ + {" --format ya", " --format yaml "}, {" --format=ya", " --format=yaml "}, {" --format ü", " --format über "}, + {` --format "two wo`, ` --format "two words" `}, {" get po", " get pods "}, + {" --context cluster-b --namespace cl", " --context cluster-b --namespace cluster-b-namespace "}, + {" --input=config", " --input=config.yaml "}, {` --input="space fi`, ` --input="space file.yaml" `}, + {" --input=colon:fi", " --input=colon:file.yaml "}, {" --selector en", ` --selector env\=`}, + } { + // Keep one terminal alive across all requests: this also checks that + // the line editor and loaded completion options reset correctly. + got := terminal.complete(name + value[0]) + if want := name + value[1]; got != want { + t.Errorf("complete %q: got %q, want %q", value[0], got, want) + } + } + completed := terminal.complete(name + " --selector en") + echoed := strings.Replace(completed, name, name+" __echo-args", 1) + var argv []string + must(t, json.Unmarshal(environment.output("bash", "-c", echoed), &argv)) + if !slices.Equal(argv, []string{"--selector", "env="}) { + t.Errorf("quoted NoSpace completion changed arguments: %q", argv) + } + environment.assertRequestsSkipInitialization() + }) + t.Run("bash_order_cursor_and_binary_update", func(t *testing.T) { + environment := newShellEnvironment(t) + terminal := environment.start("bash") + name := filepath.Base(environment.program) + for _, value := range []struct{ suffix, keys, want string }{ + {" --ordered ", "\x18\x0e", " --ordered zulu "}, + {" --ordered ", "\x18\x0e\x18\x0e", " --ordered alpha "}, + } { + if got := terminal.buffer(name+value.suffix, value.keys, -1); got != name+value.want { + t.Errorf("KeepOrder: got %q, want %q", got, name+value.want) + } + } + line := name + " --namespace cl --context cluster-b" + want := name + " --namespace cluster-b-namespace --context cluster-b" + if got := terminal.buffer(line, "\t", len(name+" --namespace cl")); got != want { + t.Errorf("cursor completion: got %q, want %q", got, want) + } + if got := terminal.complete(name + " --ini"); got != name+" --initial " { + t.Errorf("initial binary completion: %q", got) + } + environment.updateBinary() + if got := terminal.complete(name + " --up"); got != name+" --updated " { + t.Errorf("updated binary completion: %q", got) + } + environment.assertRequestsSkipInitialization() + }) + t.Run("nushell_reedline_and_binary_update", func(t *testing.T) { + environment := newShellEnvironment(t) + terminal := environment.start("nu") + name := filepath.Base(environment.program) + for _, value := range [][2]string{ + {" --format ya", " --format yaml"}, {" --format=ya", " --format=yaml"}, {" --format ü", " --format über"}, + {` --format "two wo`, ` --format "two words"`}, {" get po", " get pods"}, + {" --context cluster-b --namespace cl", " --context cluster-b --namespace cluster-b-namespace"}, + {" --input=config.y", " --input=config.yaml"}, {` --input="space fi`, " --input=`space file.yaml`"}, + } { + if got := strings.TrimRight(terminal.complete(name+value[0]), " "); got != name+value[1] { + t.Errorf("complete %q: got %q, want %q", value[0], got, name+value[1]) + } + } + environment.updateBinary() + if got := strings.TrimRight(terminal.complete(name+" --up"), " "); got != name+" --updated" { + t.Errorf("updated binary completion: %q", got) + } + environment.assertRequestsSkipInitialization() + }) +} diff --git a/internal/shelltestcmd/main.go b/internal/shelltestcmd/main.go new file mode 100644 index 0000000..1921a6c --- /dev/null +++ b/internal/shelltestcmd/main.go @@ -0,0 +1,139 @@ +// Command shelltestcmd runs completion E2E tests in an isolated Linux container. +// It prefers rootless Podman; --engine docker also supports Docker Desktop/rootless +// Docker. No repository mounts, daemon sockets, privileged containers or local +// shell installations are needed. Only static test binaries and embedded test +// assets are sent to the container builder. +package main + +import ( + "context" + "embed" + "flag" + "fmt" + "io" + "os" + "os/exec" + "os/signal" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/echocat/cli4g/internal/exampleutil" +) + +//go:embed Containerfile +var assets embed.FS + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + if err := run(ctx, os.Args[1:], os.Stdout, os.Stderr); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(parent context.Context, args []string, output, diagnostics io.Writer) error { + flags := flag.NewFlagSet("shell-e2e", flag.ContinueOnError) + flags.SetOutput(diagnostics) + engine := flags.String("engine", "auto", "container engine: auto (Podman first), podman or docker") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("unexpected shell-e2e arguments") + } + selected, err := chooseEngine(*engine, exec.LookPath) + if err != nil { + return err + } + if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" { + return fmt.Errorf("shell-e2e supports amd64 and arm64 hosts") + } + ctx, cancel := context.WithTimeout(parent, 15*time.Minute) + defer cancel() + if err := command(ctx, "", nil, io.Discard, diagnostics, selected, "info"); err != nil { + return fmt.Errorf("container engine is unavailable: %w", err) + } + root, err := exampleutil.Root(".") + if err != nil { + return err + } + directory, err := os.MkdirTemp("", "cli4g-shell-e2e-") + if err != nil { + return err + } + defer func() { _ = os.RemoveAll(directory) }() + for _, name := range []string{"Containerfile"} { + data, err := assets.ReadFile(name) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(directory, name), data, 0600); err != nil { + return err + } + } + for _, variant := range []string{"initial", "updated"} { + public := "shell-setup" + if variant == "updated" { + public = "shell-admin" + } + ldflags := "-X main.variant=" + variant + " -X main.completionCommand=" + public + " -X main.completionRequestCommand=__suggest" + env := append(exampleutil.NativeEnv(), "GOOS=linux", "GOARCH="+runtime.GOARCH, "CGO_ENABLED=0") + if err := command(ctx, root, env, output, diagnostics, "go", "build", "-mod=readonly", "-ldflags", ldflags, + "-o", filepath.Join(directory, "fixture-"+variant), "./internal/testdata/completion"); err != nil { + return err + } + } + env := append(exampleutil.NativeEnv(), "GOOS=linux", "GOARCH="+runtime.GOARCH, "CGO_ENABLED=0") + if err := command(ctx, root, env, output, diagnostics, "go", "test", "-c", "-mod=readonly", "-tags=shell_e2e", + "-o", filepath.Join(directory, "shell-e2e"), "./internal/shelltestcmd"); err != nil { + return err + } + idFile := filepath.Join(directory, "image-id") + if err := command(ctx, directory, nil, output, diagnostics, selected, "build", "--platform", "linux/"+runtime.GOARCH, + "--file", filepath.Join(directory, "Containerfile"), "--iidfile", idFile, "--tag", "localhost/cli4g-shell-e2e:local", directory); err != nil { + return err + } + image, err := os.ReadFile(idFile) + if err != nil { + return err + } + name := filepath.Base(directory) + defer func() { + cleanup, stop := context.WithTimeout(context.Background(), 15*time.Second) + defer stop() + _ = command(cleanup, "", nil, io.Discard, io.Discard, selected, "rm", "--force", name) + }() + // Run by image ID rather than a mutable tag, so concurrent checkouts cannot + // accidentally execute each other's test binaries. Runtime tests need no network. + return command(ctx, "", nil, output, diagnostics, selected, "run", "--rm", "--network=none", "--name", name, strings.TrimSpace(string(image))) +} + +func chooseEngine(value string, lookup func(string) (string, error)) (string, error) { + if value != "auto" && value != "podman" && value != "docker" { + return "", fmt.Errorf("unsupported container engine %q", value) + } + choices := []string{value} + if value == "auto" { + choices = []string{"podman", "docker"} + } + for _, choice := range choices { + if path, err := lookup(choice); err == nil { + return path, nil + } + } + return "", fmt.Errorf("install Podman (recommended: rootless) or Docker to run shell E2E tests") +} + +func command(ctx context.Context, directory string, env []string, output, diagnostics io.Writer, name string, args ...string) error { + process := exec.CommandContext(ctx, name, args...) + process.Dir = directory + process.Env = append(process.Environ(), env...) + process.Stdout, process.Stderr = output, diagnostics + if err := process.Run(); err != nil { + return fmt.Errorf("%s %q: %w", name, args, err) + } + return nil +} diff --git a/internal/shelltestcmd/main_test.go b/internal/shelltestcmd/main_test.go new file mode 100644 index 0000000..08b66d8 --- /dev/null +++ b/internal/shelltestcmd/main_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "fmt" + "testing" +) + +func TestChooseEngine(t *testing.T) { + for _, test := range []struct { + value string + available map[string]string + want string + }{ + {"auto", map[string]string{"podman": "/bin/podman", "docker": "/bin/docker"}, "/bin/podman"}, + {"auto", map[string]string{"docker": "/bin/docker"}, "/bin/docker"}, + {"docker", map[string]string{"podman": "/bin/podman", "docker": "/bin/docker"}, "/bin/docker"}, + {"podman", map[string]string{"docker": "/bin/docker"}, ""}, + {"auto", nil, ""}, + {"unknown", map[string]string{"unknown": "/bin/unknown"}, ""}, + } { + got, err := chooseEngine(test.value, func(name string) (string, error) { + if path := test.available[name]; path != "" { + return path, nil + } + return "", fmt.Errorf("not installed") + }) + if got != test.want || (err != nil) != (test.want == "") { + t.Fatalf("chooseEngine(%q): %q, %v; want %q", test.value, got, err, test.want) + } + } +} diff --git a/internal/shelltestcmd/terminal_linux_test.go b/internal/shelltestcmd/terminal_linux_test.go new file mode 100644 index 0000000..8643b94 --- /dev/null +++ b/internal/shelltestcmd/terminal_linux_test.go @@ -0,0 +1,136 @@ +//go:build linux && shell_e2e + +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "regexp" + "syscall" + "testing" + "time" + + "golang.org/x/sys/unix" +) + +// terminal connects a real interactive shell to Linux's PTY subsystem. The host +// cross-builds these tests for the container; normal go test runs exclude them. +type terminal struct { + t *testing.T + shell string + process *exec.Cmd + master *os.File + pending []byte + transcript []byte +} + +func startTerminal(t *testing.T, shell string, args, env []string, directory string) *terminal { + t.Helper() + fd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC|unix.O_NONBLOCK, 0) + must(t, err) + master := os.NewFile(uintptr(fd), "pty-master") + t.Cleanup(func() { _ = master.Close() }) + must(t, unix.IoctlSetPointerInt(fd, unix.TIOCSPTLCK, 0)) + number, err := unix.IoctlGetInt(fd, unix.TIOCGPTN) + must(t, err) + slave, err := os.OpenFile(fmt.Sprintf("/dev/pts/%d", number), os.O_RDWR|unix.O_NOCTTY, 0) + must(t, err) + defer func() { _ = slave.Close() }() + must(t, unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{Row: 40, Col: 160})) + process := exec.Command(shell, args...) + process.Env, process.Dir = env, directory + process.Stdin, process.Stdout, process.Stderr = slave, slave, slave + process.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 0} + must(t, process.Start()) + terminal := &terminal{t: t, shell: shell, process: process, master: master} + t.Cleanup(terminal.close) + return terminal +} + +func (terminal *terminal) send(value string) { + terminal.t.Helper() + _, err := terminal.master.WriteString(value) + must(terminal.t, err) +} + +func (terminal *terminal) expect(pattern string) []string { + terminal.t.Helper() + matcher := regexp.MustCompile("(?s)" + pattern) + deadline := time.Now().Add(10 * time.Second) + for { + if indexes := matcher.FindSubmatchIndex(terminal.pending); indexes != nil { + result := make([]string, len(indexes)/2) + for index := range result { + start, end := indexes[index*2], indexes[index*2+1] + if start >= 0 { + result[index] = string(terminal.pending[start:end]) + } + } + terminal.pending = terminal.pending[indexes[1]:] + return result + } + must(terminal.t, terminal.master.SetReadDeadline(deadline)) + var data [65536]byte + count, err := terminal.master.Read(data[:]) + if count > 0 { + terminal.transcript = append(terminal.transcript, data[:count]...) + terminal.pending = append(terminal.pending, data[:count]...) + // Reedline waits for a cursor-position reply before drawing its prompt. + queries := bytes.Count(terminal.pending, []byte("\x1b[6n")) + if queries > 0 { + terminal.pending = bytes.ReplaceAll(terminal.pending, []byte("\x1b[6n"), nil) + terminal.send(string(bytes.Repeat([]byte("\x1b[1;1R"), queries))) + } + } + if err != nil { + terminal.t.Fatalf("terminal did not produce %q: %v\ntranscript: %q", pattern, err, terminal.transcript) + } + } +} + +func (terminal *terminal) prompt() { + terminal.t.Helper() + terminal.expect("E2E> ") + if terminal.shell == "nu" { + terminal.expect(regexp.QuoteMeta("\x1b[?25h")) + } +} + +func (terminal *terminal) complete(line string) string { return terminal.buffer(line, "\t", -1) } + +func (terminal *terminal) buffer(line, keys string, cursor int) string { + terminal.t.Helper() + terminal.send("\x01\x0b" + line) + if cursor >= 0 { + terminal.send("\x01" + string(bytes.Repeat([]byte{0x06}, cursor))) + } + terminal.send(keys + "\x0f") + matched := terminal.expect("\r?\n__CLI4G_BUFFER__(.*?)__CLI4G_END__") + var value string + must(terminal.t, json.Unmarshal([]byte(matched[1]), &value)) + terminal.prompt() + return value +} + +func (terminal *terminal) close() { + _ = unix.Kill(-terminal.process.Process.Pid, unix.SIGTERM) + _ = terminal.master.Close() + done := make(chan error, 1) + go func() { done <- terminal.process.Wait() }() + select { + case <-done: + case <-time.After(2 * time.Second): + _ = unix.Kill(-terminal.process.Process.Pid, unix.SIGKILL) + <-done + } +} + +func must(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/testdata/completion/main.go b/internal/testdata/completion/main.go new file mode 100644 index 0000000..c1901df --- /dev/null +++ b/internal/testdata/completion/main.go @@ -0,0 +1,98 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "os" + + cli "github.com/echocat/cli4g" + "github.com/echocat/cli4g/codec" +) + +var variant = "initial" + +var completionCommand = "completion" +var completionRequestCommand = "__complete" + +type namespaceCodec struct{ codec.Codec[string, string] } + +func (source namespaceCodec) ToRaw(ctx context.Context) codec.Codec[any, any] { + return codec.Raw(ctx, source) +} + +func (source namespaceCodec) Complete(ctx context.Context, input codec.CompletionInput) (codec.CompletionResult, error) { + cluster, _, err := input.Effective.Flag(ctx, "context") + if err != nil { + return codec.CompletionResult{}, err + } + name := "production" + if len(cluster.Values) > 0 { + name = cluster.Values[0] + "-namespace" + } + return codec.CompletionResult{Handled: true, Candidates: []codec.CompletionCandidate{{Value: name, Description: "Namespace in the selected cluster."}}}, nil +} + +func main() { + if len(os.Args) > 1 && os.Args[1] == "__quote-stdin" { + data, err := io.ReadAll(os.Stdin) + if err != nil { + panic(err) + } + if err := json.NewEncoder(os.Stdout).Encode(string(data)); err != nil { + panic(err) + } + return + } + if len(os.Args) > 1 && os.Args[1] == "__echo-args" { + if err := json.NewEncoder(os.Stdout).Encode(os.Args[2:]); err != nil { + panic(err) + } + return + } + app := cli.NewApplication[struct { + Context string `flag:"context" short:"c"` + Namespace string `flag:"namespace" short:"n" type:"namespace"` + Format string `flag:"format" enum:"json|yaml|über|two words"` + Input *os.File `flag:"input"` + Selector string `flag:"selector" type:"selector"` + Ordered string `flag:"ordered" type:"ordered"` + }]("cli4g-completion-test"). + WithCompletion(true). + WithCompletionCommand(cli.NewCompletionCommand().WithName(completionCommand)). + WithCompletionRequestCommandName(completionRequestCommand). + WithCodec(namespaceCodec{codec.NewString()}, "namespace"). + WithCodec(codec.WithCompletionHandler(codec.NewString(), func(ctx context.Context, _ codec.CompletionInput) (codec.CompletionResult, error) { + return codec.CompletionResult{Handled: true, NoSpace: true, Candidates: []codec.CompletionCandidate{{Value: "env="}}}, ctx.Err() + }), "selector"). + WithCodec(codec.WithCompletionHandler(codec.NewString(), func(ctx context.Context, _ codec.CompletionInput) (codec.CompletionResult, error) { + return codec.CompletionResult{Handled: true, KeepOrder: true, Candidates: []codec.CompletionCandidate{{Value: "zulu"}, {Value: "alpha"}}}, ctx.Err() + }), "ordered"). + WithFlag(cli.NewFlag(codec.NewBool(), variant)). + WithCommand(cli.NewCommand[struct { + Resource string `argument:"resource" enum:"pods|services"` + }]("get")). + WithInitializeHandler(func(context.Context, struct { + Context string `flag:"context" short:"c"` + Namespace string `flag:"namespace" short:"n" type:"namespace"` + Format string `flag:"format" enum:"json|yaml|über|two words"` + Input *os.File `flag:"input"` + Selector string `flag:"selector" type:"selector"` + Ordered string `flag:"ordered" type:"ordered"` + }) error { + if path := os.Getenv("CLI4G_COMPLETION_INIT_MARKER"); path != "" { + file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return err + } + _, writeErr := file.WriteString("initialize\n") + closeErr := file.Close() + if writeErr != nil { + return writeErr + } + return closeErr + } + panic("completion must skip initialization") + }) + app.MustCreateParseAndExecute(context.Background(), nil, os.Args[1:]...) +} diff --git a/invocation-lifecycle.go b/invocation-lifecycle.go index f3bc0ff..8508530 100644 --- a/invocation-lifecycle.go +++ b/invocation-lifecycle.go @@ -1,6 +1,7 @@ package cli import ( + "context" "errors" "sync" "sync/atomic" @@ -19,6 +20,7 @@ type invocationLifecycle struct { closed atomic.Bool once sync.Once cleanup func() error + operation func(context.Context) error // request-only protocol invocation err error } diff --git a/invocation.go b/invocation.go index b01dd43..05cd82c 100644 --- a/invocation.go +++ b/invocation.go @@ -174,6 +174,8 @@ func (this Invocation[O]) Argument(i int) (*Argument[any, any], any) { // handler, failures close this invocation before reporting. Without one, // the caller retains cleanup responsibility. Successful invocations must be closed. // A help invocation executes only the application's HelpHandler. +// A completion request invocation executes only its protocol operation. Public +// completion commands execute through the ordinary command lifecycle. // Context streams apply to handlers; file values already resolved by Parse // retain their original streams. Use the same stream context for both phases. func (this *Invocation[O]) Execute(ctx context.Context) (err error) { @@ -238,6 +240,10 @@ func (this *Invocation[O]) execute(ctx context.Context, target *Container) error if err := ctx.Err(); err != nil { return err } + if this.lifecycle.operation != nil { + *target = this.frames[len(this.frames)-1].container + return this.lifecycle.operation(withContainerContext(ctx, *target)) + } if this.HelpRequested() { *target = this.frames[len(this.frames)-1].container if err := this.application.helpHandler(withContainerContext(ctx, *target), this); err != nil { diff --git a/mise.toml b/mise.toml index b52cf00..ce2960f 100644 --- a/mise.toml +++ b/mise.toml @@ -95,6 +95,17 @@ description = "Build a Go module archive and test it without nested modules or G env.GOWORK = "off" run = 'go test -count=1 -v ./internal/integration -run "^TestModuleArchive$" -args -module-archive' +[tasks."test:shells"] +description = "Run mandatory Bash/Nushell terminal E2E tests in Podman (preferred) or Docker" +usage = ''' +flag "--engine " help="Container engine: auto, podman or docker" default="auto" +''' +env.GOWORK = "off" +shell = "{% if os() == 'windows' %}pwsh -Command{% else %}sh -o errexit -c{% endif %}" +run = ''' +go run ./internal/shelltestcmd --engine {% if os() == 'windows' %}'{{usage.engine | replace(from="'", to="''")}}'{% else %}{{usage.engine | quote}}{% endif %}{% if os() == 'windows' %}; exit $LASTEXITCODE{% endif %} +''' + [tasks."test:benchmarks"] description = "Check benchmark adapters, formatting, module files, vet, and lint" dir = "{{config_root}}/_benchmarks" @@ -209,7 +220,10 @@ depends = ["lint:go:code", "lint:go:vet"] [tasks."lint:go:code"] description = "Lint all Go packages" tools.golangci-lint = "{{vars.golangci_lint}}" -run = 'golangci-lint run --config "{{config_root}}/.golangci.yaml"' +run = [ + 'golangci-lint run --config "{{config_root}}/.golangci.yaml"', + 'golangci-lint run --config "{{config_root}}/.golangci.yaml" --build-tags=shell_e2e ./internal/shelltestcmd', +] [tasks."lint:go:vet"] description = "Vet all packages" @@ -262,4 +276,4 @@ depends = ["format:check", "lint", "test --race"] [tasks."check:full"] description = "Run all checks, the Go version matrix, and vulnerability scans" -depends = ["check", "check:quality", "test:matrix", "test:benchmarks"] +depends = ["check", "check:quality", "test:matrix", "test:benchmarks", "test:shells"] From 0b85865907af79f81cbaf723ea1790de0a3625dd Mon Sep 17 00:00:00 2001 From: Gregor Noczinski Date: Tue, 20 Oct 2026 16:55:12 +0200 Subject: [PATCH 02/14] Add PowerShell completion and terminal E2E tests --- .github/workflows/ci.yml | 2 +- README.md | 5 +- _examples/completion/README.md | 8 ++ completion_command.go | 12 +- completion_command_test.go | 10 +- completion_powershell.go | 107 +++++++++++++++ completion_powershell.ps1 | 103 ++++++++++++++ completion_powershell_test.go | 52 +++++++ completion_protocol.go | 28 +++- completion_protocol_test.go | 37 ++++- completion_shell.go | 96 ++++++++++--- doc.go | 25 +++- docs/development.md | 21 +-- .../shelltestcmd/Containerfile.powershell | 8 ++ internal/shelltestcmd/container_linux_test.go | 10 +- internal/shelltestcmd/main.go | 25 +++- internal/shelltestcmd/powershell.ps1 | 15 +++ .../shelltestcmd/powershell_linux_test.go | 127 ++++++++++++++++++ mise.toml | 2 +- 19 files changed, 636 insertions(+), 57 deletions(-) create mode 100644 completion_powershell.go create mode 100644 completion_powershell.ps1 create mode 100644 completion_powershell_test.go create mode 100644 internal/shelltestcmd/Containerfile.powershell create mode 100644 internal/shelltestcmd/powershell.ps1 create mode 100644 internal/shelltestcmd/powershell_linux_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48bc023..6510069 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,7 +134,7 @@ jobs: - name: Set up rootless Podman uses: ./.github/actions/setup-rootless-podman - - name: Test Bash and Nushell in real terminals + - name: Test Bash, Nushell and PowerShell in real terminals run: mise run test:shells --engine podman quality: diff --git a/README.md b/README.md index 70eb3dd..67025b5 100644 --- a/README.md +++ b/README.md @@ -99,12 +99,13 @@ code size, and cognitive complexity. ## Shell completion -Bash **4.4+** and Nushell **0.114+** adapters query the current binary, so binary -updates need no completion reinstall. Install once with: +Bash **4.4+**, Nushell **0.114+**, and PowerShell **5.1+** adapters query the current +binary, so binary updates need no completion reinstall. Install once with: ```shell your-app completion install bash # Requires a configured bash-completion loader your-app completion install nu # Autoloaded at the next interactive Nu startup +your-app completion install powershell # Loaded through the user profiles on next startup ``` `WithCompletion(true)` selects `DefaultCompletionCommand` and diff --git a/_examples/completion/README.md b/_examples/completion/README.md index 9420369..b12efec 100644 --- a/_examples/completion/README.md +++ b/_examples/completion/README.md @@ -42,6 +42,14 @@ ship completion install nu # ship --context production --namespace payments deploy ch ``` +In **Windows PowerShell 5.1+ or PowerShell 7+**: + +```powershell +ship completion install powershell +# Start a new PowerShell session, then type and press Tab: +# ship --context production --namespace payments deploy ch +``` + The standard handler completes `deploy`, flag names, the `development`/`production` context enum, and bool values automatically. The two callbacks in `inventory.go` add domain knowledge: - Namespace suggestions depend on the context and the selected service. diff --git a/completion_command.go b/completion_command.go index 7f54108..bcc3afc 100644 --- a/completion_command.go +++ b/completion_command.go @@ -26,17 +26,19 @@ var DefaultCompletionCommand CommandRequirementLike = NewCompletionCommand() var DefaultCompletionRequestCommandName = "__complete" // CompletionInstallOptions configures the standard completion install command. -// Shell is bash or nu. Directory overrides the shell's default installation path. +// Shell is bash, nu or powershell. Directory overrides the shell's default +// installation path; for PowerShell, an explicit directory also skips profile setup. // These are ordinary command options, parsed and validated through the usual CLI // lifecycle together with the selected application and command scopes. type CompletionInstallOptions struct { - Shell string `argument:"shell" enum:"bash|nu" required:"true" description:"Shell to configure."` + Shell string `argument:"shell" enum:"bash|nu|powershell" required:"true" description:"Shell to configure."` Directory string `argument:"directory" type:"completion-directory" description:"Installation directory; omitted uses the shell's default."` } // NewCompletionCommand builds a fresh, ordinary command tree named "completion". -// It supports "completion bash", "completion nu", and "completion install -// [directory]". WithName, WithAliases, handlers and all other ordinary +// It supports "completion bash", "completion nu", "completion powershell", and +// "completion install [directory]". WithName, WithAliases, +// handlers and all other ordinary // CommandRequirement configuration are available. Parent requirements, parsing, // profiles, initialization, execution, cleanup and depth limits apply normally. // Only the independently configured request entry bypasses that lifecycle. @@ -45,7 +47,7 @@ type CompletionInstallOptions struct { // CompletionApplicationFromContext, which custom management commands may also use. func NewCompletionCommand() *CommandRequirement[NoOptions] { command := NewSimpleCommand("completion", "Manage shell completion.").WithHandler(completionGroupHelp) - for _, shell := range []CompletionShell{CompletionBash, CompletionNushell} { + for _, shell := range []CompletionShell{CompletionBash, CompletionNushell, CompletionPowerShell} { command.WithCommand(NewSimpleCommand(string(shell), "Print the "+string(shell)+" adapter."). WithHandler(func(ctx context.Context, _ NoOptions) error { application, found := CompletionApplicationFromContext(ctx) diff --git a/completion_command_test.go b/completion_command_test.go index 9e4f721..fc81630 100644 --- a/completion_command_test.go +++ b/completion_command_test.go @@ -70,18 +70,18 @@ func TestDefaultCompletionCommandIsAnOrdinaryCompiledCommand(t *testing.T) { assert.False(t, found, "request entry is not registered as a command") definition.WithName("changed") assert.Equal(t, "shell-setup", app.CompletionCommandName()) - for _, shell := range []CompletionShell{CompletionBash, CompletionNushell} { + for _, shell := range []CompletionShell{CompletionBash, CompletionNushell, CompletionPowerShell} { var output, diagnostics bytes.Buffer require.NoError(t, app.ParseAndExecute(ContextWithStdio(nil, nil, &output, &diagnostics), nil, "shells", string(shell))) assert.Contains(t, output.String(), "__suggest") assert.Empty(t, diagnostics.String(), "ancestor group handler must not print help for a selected child") } - assert.Equal(t, []string{"initialize", "close", "initialize", "close"}, events) + assert.Equal(t, []string{"initialize", "close", "initialize", "close", "initialize", "close"}, events) var help bytes.Buffer require.NoError(t, app.ParseAndExecute(ContextWithStdio(nil, nil, &help, &help), nil, "shell-setup", "--help")) assert.Contains(t, help.String(), "tool shell-setup") assert.Contains(t, help.String(), "install") - assert.Len(t, events, 4, "ordinary --help skips initialization") + assert.Len(t, events, 6, "ordinary --help skips initialization") var output bytes.Buffer directory := t.TempDir() require.NoError(t, app.ParseAndExecute(ContextWithStdio(nil, nil, &output, &output), nil, "shell-setup", "install", "bash", directory)) @@ -182,7 +182,7 @@ func TestNormalCompletionCommandCompletesItsOwnSchema(t *testing.T) { }{ {[]string{"shell-s"}, []string{"shell-setup"}, false}, {[]string{"__s"}, []string{}, false}, - {[]string{"shell-setup", ""}, []string{"bash", "install", "nu"}, false}, + {[]string{"shell-setup", ""}, []string{"bash", "install", "nu", "powershell"}, false}, {[]string{"shell-setup", "install", "n"}, []string{"nu"}, false}, {[]string{"shell-setup", "install", "bash", "dir"}, []string{}, true}, } { @@ -194,7 +194,7 @@ func TestNormalCompletionCommandCompletesItsOwnSchema(t *testing.T) { renamed := NewSimpleApplication("tool").WithCompletion(true). WithCompletionCommand(NewCompletionCommand().WithName("shell-admin")).WithCompletionRequestCommandName("__suggest").MustCreate() defer func() { assert.NoError(t, renamed.Close()) }() - for _, shell := range []CompletionShell{CompletionBash, CompletionNushell} { + for _, shell := range []CompletionShell{CompletionBash, CompletionNushell, CompletionPowerShell} { before, err := app.CompletionScript(shell) require.NoError(t, err) after, err := renamed.CompletionScript(shell) diff --git a/completion_powershell.go b/completion_powershell.go new file mode 100644 index 0000000..d73c28e --- /dev/null +++ b/completion_powershell.go @@ -0,0 +1,107 @@ +package cli + +import ( + "bytes" + "context" + "crypto/sha256" + _ "embed" + "encoding/base64" + "encoding/binary" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "unicode/utf16" +) + +//go:embed completion_powershell.ps1 +var powershellCompletionScript string + +func quotePowerShell(value string) string { + return "'" + strings.NewReplacer("'", "''", "\u2018", "\u2018\u2018", "\u2019", "\u2019\u2019", "\u201a", "\u201a\u201a", "\u201b", "\u201b\u201b").Replace(value) + "'" +} + +func powershellCompletionProfiles(ctx context.Context) ([]string, error) { + var profiles []string + seen := make(map[string]bool) + for _, name := range []string{"pwsh", "powershell"} { + shell, err := exec.LookPath(name) + if err != nil { + continue + } + output, err := exec.CommandContext(ctx, shell, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", + "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); [Console]::Write($PROFILE.CurrentUserAllHosts)").Output() + if err != nil { + return nil, fmt.Errorf("could not discover %s user profile: %w", name, err) + } + path := strings.TrimSpace(strings.TrimPrefix(string(output), "\ufeff")) + if !filepath.IsAbs(path) { + return nil, fmt.Errorf("%s returned no absolute user profile path", name) + } + key := path + if runtime.GOOS == "windows" { + key = strings.ToLower(key) + } + if !seen[key] { + profiles = append(profiles, path) + seen[key] = true + } + } + if len(profiles) == 0 { + return nil, fmt.Errorf("could not discover PowerShell user profiles: install pwsh or powershell, or supply an installation directory") + } + return profiles, nil +} + +func installPowerShellProfile(ctx context.Context, profile, script, command string) error { + if info, err := os.Lstat(profile); err == nil && info.Mode()&os.ModeSymlink != 0 { + resolved, err := filepath.EvalSymlinks(profile) + if err != nil { + return err + } + profile = resolved + } + data, err := os.ReadFile(profile) + if err != nil && !os.IsNotExist(err) { + return err + } + mode := os.FileMode(0600) + if err == nil { + info, err := os.Stat(profile) + if err != nil { + return err + } + mode = info.Mode().Perm() + } + hash := sha256.Sum256([]byte(command)) + marker := fmt.Sprintf("# cli4g completion %x", hash[:8]) + // ASCII source preserves legacy ANSI profiles too. The path is data, never + // interpolated PowerShell code, including when it contains quotes or Unicode. + block := "\r\n" + marker + "\r\n. ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('" + + base64.StdEncoding.EncodeToString([]byte(script)) + "')))\r\n# end cli4g completion\r\n" + encode := func(value string) []byte { return []byte(value) } + if bytes.HasPrefix(data, []byte{0xff, 0xfe}) || bytes.HasPrefix(data, []byte{0xfe, 0xff}) { + var order binary.ByteOrder = binary.LittleEndian + if data[0] == 0xfe { + order = binary.BigEndian + } + encode = func(value string) []byte { + units := utf16.Encode([]rune(value)) + result := make([]byte, len(units)*2) + for i, unit := range units { + order.PutUint16(result[i*2:], unit) + } + return result + } + } + addition := encode(block) + if bytes.Contains(data, addition) { + return nil + } + if bytes.Contains(data, encode(marker)) { + return fmt.Errorf("PowerShell profile already contains a different cli4g loader for %q: %s", command, profile) + } + return replaceCompletionFile(ctx, profile, append(data, addition...), mode) +} diff --git a/completion_powershell.ps1 b/completion_powershell.ps1 new file mode 100644 index 0000000..b2791e8 --- /dev/null +++ b/completion_powershell.ps1 @@ -0,0 +1,103 @@ +Register-ArgumentCompleter -CommandName @(@PSCOMMAND@, @PSEXE@) -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + + # All helpers and preferences belong to this invocation, not the user session. + Set-StrictMode -Off + $ErrorActionPreference = 'Stop' + function Read-Literal($element) { + if ($element -is [System.Management.Automation.Language.StringConstantExpressionAst] -or + ($element -is [System.Management.Automation.Language.ExpandableStringExpressionAst] -and + $element.NestedExpressions.Count -eq 0)) { + return [string]$element.Value + } + return [string]$element.Extent.Text + } + function Read-Prefix([string]$text) { + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseInput('x ' + $text, [ref]$tokens, [ref]$errors) + $elements = $ast.EndBlock.Statements[0].PipelineElements[0].CommandElements + if ($elements.Count -eq 2) { return Read-Literal $elements[1] } + return $text + } + function Quote-Literal([string]$value) { + if ($value -match '^[-\p{L}\p{N}_./:\\=+]+$') { return $value } + # Legacy native argument passing strips unescaped embedded double quotes. + if ($PSVersionTable.PSVersion -lt [version]'7.3' -or $PSNativeCommandArgumentPassing -eq 'Legacy') { + $value = [regex]::Replace($value, '(\\*)"', '$1$1\"') + } + return "'" + ($value -replace "['\u2018\u2019\u201a\u201b]", '$&$&') + "'" + } + + try { + $words = [System.Collections.Generic.List[string]]::new() + $word = -1 + $offset = 0 + foreach ($element in ($commandAst.CommandElements | Select-Object -Skip 1)) { + $start = $element.Extent.StartOffset + $end = $element.Extent.EndOffset + if ($word -lt 0 -and $cursorPosition -lt $start) { + $word = $words.Count + $words.Add('') + } + if ($word -lt 0 -and $cursorPosition -ge $start -and $cursorPosition -le $end) { + $word = $words.Count + $prefix = Read-Prefix $element.Extent.Text.Substring(0, $cursorPosition - $start) + $offset = [System.Text.Encoding]::UTF8.GetByteCount([string]$prefix) + } + $words.Add((Read-Literal $element)) + } + if ($word -lt 0) { + $word = $words.Count + $words.Add('') + } + $request = @{words = $words.ToArray(); word = $word; offset = $offset} | ConvertTo-Json -Depth 4 -Compress + $inputText = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($request)) + $program = $commandAst.GetCommandName() + $response = $inputText | & $program @PSREQUEST@ 1 powershell 2>$null + if ($LASTEXITCODE -ne 0) { return '' } + $json = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String(($response -join ''))) + $result = $json | ConvertFrom-Json + $emitted = 0 + foreach ($candidate in $result.candidates) { + $description = [string]$candidate.description + if (!$description) { $description = ' ' } + [System.Management.Automation.CompletionResult]::new( + (Quote-Literal $candidate.value), $candidate.value, 'ParameterValue', $description) + $emitted++ + } + if ($result.files -or $result.directories) { + $search = [string]$result.fileInput + # PowerShell's provider completer treats a colon as a drive even on + # Unix. Native applications can also consume literal colon filenames. + if ([System.IO.Path]::DirectorySeparatorChar -eq '/' -and $search -match '^([^/\\:]+):') { + $directory = [System.IO.Path]::GetDirectoryName($search) + if (!$directory) { $directory = '.' } + $prefix = [System.IO.Path]::GetFileName($search) + $paths = foreach ($entry in [System.IO.Directory]::EnumerateFileSystemEntries($directory)) { + $leaf = [System.IO.Path]::GetFileName($entry) + if (!$leaf.StartsWith($prefix, [System.StringComparison]::Ordinal)) { continue } + $kind = 'ProviderItem' + if ([System.IO.Directory]::Exists($entry)) { + $kind = 'ProviderContainer' + $entry += '/' + } + [System.Management.Automation.CompletionResult]::new((Quote-Literal $entry), $leaf, $kind, $entry) + } + } else { + $paths = [System.Management.Automation.CompletionCompleters]::CompleteFilename($search) + } + foreach ($path in $paths) { + if ($result.directories -and $path.ResultType -ne 'ProviderContainer') { continue } + $value = [string]$result.filePrefix + (Read-Prefix $path.CompletionText) + [System.Management.Automation.CompletionResult]::new( + (Quote-Literal $value), $path.ListItemText, $path.ResultType, $path.ToolTip) + $emitted++ + } + } + # An empty string suppresses PowerShell's implicit filename fallback. + if ($emitted -eq 0) { return '' } + } catch { + return '' + } +} diff --git a/completion_powershell_test.go b/completion_powershell_test.go new file mode 100644 index 0000000..7252743 --- /dev/null +++ b/completion_powershell_test.go @@ -0,0 +1,52 @@ +package cli + +import ( + "context" + "encoding/binary" + "os" + "path/filepath" + "testing" + "unicode/utf16" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPowerShellProfileInstallationPreservesContentAndEncoding(t *testing.T) { + for _, encoding := range []string{"utf8", "utf8-bom", "utf16-le", "utf16-be", "ansi"} { + t.Run(encoding, func(t *testing.T) { + directory := t.TempDir() + path := filepath.Join(directory, "profile.ps1") + script := filepath.Join(directory, "über's completions.ps1") + original := []byte("# user's profile: über\r\n$env:KEEP = 'yes'\r\n") + switch encoding { + case "utf8-bom": + original = append([]byte{0xef, 0xbb, 0xbf}, original...) + case "utf16-le", "utf16-be": + var order binary.ByteOrder = binary.LittleEndian + if encoding == "utf16-be" { + order = binary.BigEndian + } + units := utf16.Encode([]rune(string(original))) + original = make([]byte, (len(units)+1)*2) + order.PutUint16(original, 0xfeff) + for i, unit := range units { + order.PutUint16(original[(i+1)*2:], unit) + } + case "ansi": + original = []byte("# user's profile: \xfcber\r\n$env:KEEP = 'yes'\r\n") + } + require.NoError(t, os.WriteFile(path, original, 0600)) + require.NoError(t, installPowerShellProfile(context.Background(), path, script, "ship")) + installed, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, original, installed[:len(original)]) + require.Greater(t, len(installed), len(original)) + require.NoError(t, installPowerShellProfile(context.Background(), path, script, "ship")) + again, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, installed, again) + require.ErrorContains(t, installPowerShellProfile(context.Background(), path, script+"other", "ship"), "different cli4g loader") + }) + } +} diff --git a/completion_protocol.go b/completion_protocol.go index 77536e4..fe1f094 100644 --- a/completion_protocol.go +++ b/completion_protocol.go @@ -1,7 +1,9 @@ package cli import ( + "bytes" "context" + "encoding/base64" "encoding/json" "fmt" "io" @@ -28,6 +30,12 @@ import ( // 1 MiB. An optional raw=true additionally removes simple Nu literal quoting // from span tokens; it never evaluates expressions. stdout is a JSON // codec.CompletionResult. Input excludes the executable. +// - __complete 1 powershell: the same JSON request/result as nu, without raw +// token normalization, each wrapped in standard Base64. The decoded request +// is limited to 1 MiB. ASCII-only transport preserves Unicode even with +// Windows PowerShell 5.1's legacy native-pipeline encoding. The adapter sends +// literal AST values, keeps arguments after the cursor, and converts UTF-16 +// cursor positions to UTF-8 byte offsets without evaluating expressions. // // New transports may be added without changing existing transports. Query errors // become authoritative empty responses with no diagnostics; direct Complete @@ -100,18 +108,22 @@ func (this *Application[O]) queryCompletion(ctx context.Context, opts *ParseOpts } } return nil - case CompletionNushell: + case CompletionNushell, CompletionPowerShell: var request struct { CompletionRequest Raw bool `json:"raw,omitempty"` } if input := codec.StdinOfContext(ctx); input != nil { - decoder := json.NewDecoder(io.LimitReader(input, 1<<20)) + if CompletionShell(args[1]) == CompletionPowerShell { + input = base64.NewDecoder(base64.StdEncoding, input) + } + data, readErr := io.ReadAll(io.LimitReader(input, (1<<20)+1)) + decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() - if err := decoder.Decode(&request); err == nil { + if err := decoder.Decode(&request); err == nil && readErr == nil && len(data) <= 1<<20 { var trailing any if decoder.Decode(&trailing) == io.EOF { - if request.Raw { + if request.Raw && CompletionShell(args[1]) == CompletionNushell { unquoteNuCompletion(&request.CompletionRequest) } if completed, err := this.Complete(ctx, opts, request.CompletionRequest); err == nil { @@ -120,6 +132,14 @@ func (this *Application[O]) queryCompletion(ctx context.Context, opts *ParseOpts } } } + if CompletionShell(args[1]) == CompletionPowerShell { + encoder := base64.NewEncoder(base64.StdEncoding, output) + if err := json.NewEncoder(encoder).Encode(result); err != nil { + _ = encoder.Close() + return err + } + return encoder.Close() + } return json.NewEncoder(output).Encode(result) } return nil diff --git a/completion_protocol_test.go b/completion_protocol_test.go index b4589cf..93ed2d7 100644 --- a/completion_protocol_test.go +++ b/completion_protocol_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "context" + "encoding/base64" "encoding/json" "os" "path/filepath" @@ -52,13 +53,13 @@ func TestCompletionProtocolRejectsMalformedRequestsQuietly(t *testing.T) { func TestCompletionInstallationIsIdempotentAndProtectsExistingFiles(t *testing.T) { app := NewSimpleApplication("tool").WithCompletionRequestCommandName(DefaultCompletionRequestCommandName).MustCreate() defer func() { assert.NoError(t, app.Close()) }() - for _, shell := range []CompletionShell{CompletionBash, CompletionNushell} { + for _, shell := range []CompletionShell{CompletionBash, CompletionNushell, CompletionPowerShell} { directory := t.TempDir() path, err := app.InstallCompletion(nil, shell, directory) require.NoError(t, err) contents, err := os.ReadFile(path) require.NoError(t, err) - assert.True(t, strings.HasPrefix(string(contents), completionScriptHeader)) + assert.True(t, strings.HasPrefix(strings.TrimPrefix(string(contents), "\ufeff"), completionScriptHeader)) again, err := app.InstallCompletion(nil, shell, directory) require.NoError(t, err) assert.Equal(t, path, again) @@ -79,6 +80,38 @@ func TestCompletionInstallationIsIdempotentAndProtectsExistingFiles(t *testing.T require.Error(t, err) } +func TestPowerShellCompletionProtocolPreservesUnicodeAndRejectsMalformedInput(t *testing.T) { + app := NewApplication[struct { + Format string `flag:"format" enum:"über|two words"` + }]("tool").WithCompletion(true).WithInitializeHandler(func(context.Context, struct { + Format string `flag:"format" enum:"über|two words"` + }) error { + panic("completion must skip initialization") + }).MustCreate() + defer func() { assert.NoError(t, app.Close()) }() + for _, test := range []struct { + input string + want []string + }{ + {base64.StdEncoding.EncodeToString([]byte(`{"words":["--format","ü"],"word":1,"offset":2}`)), []string{"über"}}, + {base64.StdEncoding.EncodeToString([]byte(`{"words":["--format","two wo"],"word":1,"offset":6}`)), []string{"two words"}}, + {"not base64!", []string{}}, + {base64.StdEncoding.EncodeToString([]byte(`{} {}`)), []string{}}, + {base64.StdEncoding.EncodeToString([]byte(`{"unexpected":true}`)), []string{}}, + {base64.StdEncoding.EncodeToString([]byte(`{"words":["--format","ü"],"word":1,"offset":2}` + strings.Repeat(" ", 1<<20))), []string{}}, + } { + var output, diagnostics bytes.Buffer + ctx := ContextWithStdio(nil, strings.NewReader(test.input), &output, &diagnostics) + require.NoError(t, app.ParseAndExecute(ctx, nil, DefaultCompletionRequestCommandName, "1", "powershell")) + decoded, err := base64.StdEncoding.DecodeString(output.String()) + require.NoError(t, err) + var result codec.CompletionResult + require.NoError(t, json.Unmarshal(decoded, &result)) + assert.Equal(t, test.want, completionTexts(result)) + assert.Empty(t, diagnostics.String()) + } +} + func TestCompletionDoesNotReserveTheFormerProtocolFlag(t *testing.T) { app := NewSimpleApplication("tool").WithCompletion(true). WithHandler(func(context.Context, NoOptions) error { return nil }). diff --git a/completion_shell.go b/completion_shell.go index 37ed797..17842f8 100644 --- a/completion_shell.go +++ b/completion_shell.go @@ -15,8 +15,9 @@ import ( type CompletionShell string const ( - CompletionBash CompletionShell = "bash" // Bash 4.4+; bash-completion enables on-demand loading. - CompletionNushell CompletionShell = "nu" // Nushell 0.114+ with command-wide completers. + CompletionBash CompletionShell = "bash" // Bash 4.4+; bash-completion enables on-demand loading. + CompletionNushell CompletionShell = "nu" // Nushell 0.114+ with command-wide completers. + CompletionPowerShell CompletionShell = "powershell" // Windows PowerShell 5.1+ and PowerShell 7+. ) const completionScriptHeader = "# Generated by cli4g shell completion protocol v1.\n" @@ -37,6 +38,17 @@ const completionScriptHeader = "# Generated by cli4g shell completion protocol v // named place records. It quotes literal candidates and preserves native Nu path // quoting; Nu controls whitespace insertion. Bash honors NoSpace and KeepOrder. // +// PowerShell registers a native argument completer, including the .exe spelling. +// Dot-source the script or use InstallCompletion for persistent profile loading. +// Literal AST strings and escapes are decoded without evaluating variables, +// expressions or substitutions. Arguments after the cursor remain available to +// context-aware providers. Candidates are quoted as literal native arguments and +// carry descriptions; path completion uses PowerShell's own filename completer +// (filesystem enumeration for literal Unix colon paths). +// Returned order is retained; the user's PSReadLine completion binding controls +// space insertion, so NoSpace does not change that binding. Scripts use UTF-8 with +// a BOM for Windows PowerShell 5.1 and an ASCII-only request/response transport. +// // Bash recognizes simple quotes, escapes, and separators but deliberately does // not evaluate shell substitutions or expansions while collecting dependency // values. Applications requiring already-expanded values may call Complete with @@ -56,15 +68,24 @@ func (this *Application[O]) CompletionScript(shell CompletionShell) (string, err template = bashCompletionScript case CompletionNushell: template = nushellCompletionScript + case CompletionPowerShell: + template = powershellCompletionScript default: - return "", NewValidationError("supported completion shells are bash and nu") + return "", NewValidationError("supported completion shells are bash, nu and powershell") + } + header := completionScriptHeader + if shell == CompletionPowerShell { + header = "\ufeff" + header } - return completionScriptHeader + strings.NewReplacer( + return header + strings.NewReplacer( "@FUNCTION@", function, "@BASHCOMMAND@", "'"+strings.ReplaceAll(this.Name(), "'", "'\\''")+"'", "@NUCOMMAND@", strconv.Quote(this.Name()), "@BASHREQUEST@", "'"+strings.ReplaceAll(this.CompletionRequestCommandName(), "'", "'\\''")+"'", "@NUREQUEST@", strconv.Quote(this.CompletionRequestCommandName()), + "@PSCOMMAND@", quotePowerShell(this.Name()), + "@PSEXE@", quotePowerShell(strings.TrimSuffix(this.Name(), ".exe")+".exe"), + "@PSREQUEST@", quotePowerShell(this.CompletionRequestCommandName()), ).Replace(template), nil } @@ -83,6 +104,17 @@ func (this *Application[O]) CompletionScript(shell CompletionShell) (string, err // the current session. Files contain no application schema or user input values. // Call this again to update the adapter implementation, not for ordinary binary // updates. The ordinary standard install command uses its invocation's context streams. +// +// For PowerShell, an empty directory discovers the CurrentUserAllHosts profiles +// of installed pwsh and powershell hosts (without loading profiles), writes one +// adapter below the first profile's cli4g-completions directory, and appends an +// idempotent, static dot-source block to each discovered profile. Existing profile +// bytes and UTF-8/UTF-16 encoding are preserved; no application executable runs at +// shell startup. +// Both installed Windows PowerShell and PowerShell hosts are configured. New hosts +// need another installation. A nonempty directory writes only the .ps1 adapter, +// leaving profiles untouched for packaging or manual dot-sourcing. Installation +// respects the host's existing execution policy and requires no administrator rights. func (this *Application[O]) InstallCompletion(ctx context.Context, shell CompletionShell, directory string) (string, error) { if ctx == nil { ctx = context.Background() @@ -97,6 +129,14 @@ func (this *Application[O]) InstallCompletion(ctx context.Context, shell Complet if filepath.Base(this.Name()) != this.Name() || this.Name() == "." || this.Name() == ".." || strings.ContainsAny(this.Name(), "/\\") { return "", fmt.Errorf("completion installation requires a base command name") } + var profiles []string + if directory == "" && shell == CompletionPowerShell { + profiles, err = powershellCompletionProfiles(ctx) + if err != nil { + return "", err + } + directory = filepath.Join(filepath.Dir(profiles[0]), "cli4g-completions") + } if directory == "" { directory, err = completionDirectory(ctx, shell) if err != nil { @@ -106,45 +146,61 @@ func (this *Application[O]) InstallCompletion(ctx context.Context, shell Complet name := this.Name() + ".bash" if shell == CompletionNushell { name = "cli4g-" + this.Name() + ".nu" + } else if shell == CompletionPowerShell { + name = "cli4g-" + this.Name() + ".ps1" } path := filepath.Join(directory, name) + if err := writeCompletionFile(ctx, path, script); err != nil { + return "", err + } + for _, profile := range profiles { + if err := installPowerShellProfile(ctx, profile, path, this.Name()); err != nil { + return "", err + } + } + return path, nil +} + +func writeCompletionFile(ctx context.Context, path, script string) error { previous, err := os.ReadFile(path) if err == nil { if string(previous) == script { - return path, nil + return nil } - if !strings.HasPrefix(string(previous), completionScriptHeader) { - return "", fmt.Errorf("completion file already exists and is not managed by cli4g") + if !strings.HasPrefix(strings.TrimPrefix(string(previous), "\ufeff"), completionScriptHeader) { + return fmt.Errorf("completion file already exists and is not managed by cli4g") } } else if !os.IsNotExist(err) { - return "", err + return err } + return replaceCompletionFile(ctx, path, []byte(script), 0644) +} + +func replaceCompletionFile(ctx context.Context, path string, data []byte, mode os.FileMode) error { + directory := filepath.Dir(path) if err := os.MkdirAll(directory, 0755); err != nil { - return "", err + return err } file, err := os.CreateTemp(directory, ".cli4g-completion-*") if err != nil { - return "", err + return err } defer func() { _ = os.Remove(file.Name()) }() - _, writeErr := file.WriteString(script) + _, writeErr := file.Write(data) closeErr := file.Close() if writeErr != nil { - return "", writeErr + return writeErr } if closeErr != nil { - return "", closeErr + return closeErr } - if err := os.Chmod(file.Name(), 0644); err != nil { - return "", err + if err := os.Chmod(file.Name(), mode); err != nil { + return err } if err := ctx.Err(); err != nil { - return "", err + return err } - if err := os.Rename(file.Name(), path); err != nil { - return "", err - } - return path, nil + return os.Rename(file.Name(), path) } func completionDirectory(ctx context.Context, shell CompletionShell) (string, error) { diff --git a/doc.go b/doc.go index 664cfb7..10959a9 100644 --- a/doc.go +++ b/doc.go @@ -139,15 +139,36 @@ // // tool completion install bash // tool completion install nu +// tool completion install powershell // // Bash 4.4+ uses the bash-completion package's on-demand loader. Nushell 0.114+ -// uses its user autoload directory on the next interactive startup. Source the -// installed file to activate it in an existing session. For packaging, supply a +// uses its user autoload directory on the next interactive startup. PowerShell +// 5.1+ registers a native argument completer through static dot-source blocks in +// the installed pwsh/powershell hosts' CurrentUserAllHosts profiles. The installer +// discovers those paths without loading profiles, preserves existing profile +// content and encoding, and writes a UTF-8-with-BOM adapter below the first +// profile's cli4g-completions directory. Both Windows PowerShell and PowerShell 7 +// are configured when installed; install again after adding another host. Existing +// execution policies apply. No application process runs when the adapter is loaded. +// Source the installed file (dot-source it in PowerShell) to activate it in an +// existing session. For packaging, supply a // directory after the shell name or use [Application.CompletionScript] to obtain // an adapter without installing it. A shell subcommand also prints it to stdout: // // tool completion bash // +// An explicit PowerShell installation directory writes only the adapter and does +// not edit profiles. PowerShell sends literal AST values and UTF-8 byte cursor +// offsets through an ASCII-safe Base64/JSON transport, including on Windows +// PowerShell 5.1's legacy native pipeline. Tokens after the cursor remain available +// for dependencies. Variables and expressions are not evaluated to collect values. +// Candidates include descriptions and use literal quoting, including legacy native +// argument escaping. Paths normally use PowerShell's filename completer; literal +// Unix colon filenames use filesystem enumeration because the provider interprets +// colons as drives. Candidate order is preserved. The user's PSReadLine binding +// determines whitespace insertion; the adapter does not change that binding. +// See [Application.InstallCompletion] and [CompletionProtocolVersion] for details. +// // ApplicationRequirement.CompletionCommand is an ordinary CommandRequirementLike. // Nil means absent. Create compiles it alongside Commands without modifying the // builder list or shared default. It uses the same parsing, inherited inputs, diff --git a/docs/development.md b/docs/development.md index 4dd49af..e056dca 100644 --- a/docs/development.md +++ b/docs/development.md @@ -19,7 +19,7 @@ mise run check:full | `mise run test --coverage` | Go and example coverage | | `mise run test:matrix` | Minimum and stable Go versions | | `mise run test:module` | Test the distributable module archive without nested modules or Git metadata | -| `mise run test:shells` | Mandatory interactive Bash/Nushell E2E tests in a Linux container | +| `mise run test:shells` | Mandatory interactive Bash/Nushell/PowerShell E2E tests in Linux containers | | `mise run test:fuzz` | Bounded parser fuzzing | | `mise run test:benchmarks` | Check comparison adapters and their module | | `mise run bench` | Measure CLI lifecycle comparisons and update the results page | @@ -45,18 +45,23 @@ The runner cross-builds static Linux fixture binaries with the project's Go toolchain. Its temporary build context contains only those binaries and the test assets, not the repository, Git metadata, or local configuration. No bind mounts, daemon socket mounts, privileged containers, or host shell installations are needed. -The test container has no network access; image building needs registry/package +The test containers have no network access; image building needs registry/package access. Containers and temporary files are cleaned up; image layers remain cached. -The Containerfile pins Bash **5.2.37** and Nushell **0.114.1** images by manifest -digest. The runner cross-compiles a Go test binary with the `shell_e2e` build tag. +The Containerfiles pin Bash **5.2.37**, Nushell **0.114.1**, and PowerShell **7.5.0** +images by manifest digest. PowerShell runs separately on Ubuntu (amd64) or Azure +Linux (arm64), keeping its native .NET environment. The runner cross-compiles a Go +test binary with the `shell_e2e` build tag. Its Go PTY driver uses Linux terminal ioctls from the existing `golang.org/x/sys` -dependency and embedded Bash/Nu setup scripts. It installs adapters through the -real CLI command, starts interactive Bash/Readline and Nu/Reedline, +dependency and embedded shell setup scripts. It installs adapters through the +real CLI command, starts interactive Bash/Readline, Nu/Reedline, and PowerShell/PSReadLine, presses Tab, and reads the line-editor buffer. It verifies on-demand Bash loading, -Nu autoload, quotes, Unicode, paths, context-dependent values, real `compopt` +Nu autoload, PowerShell profile loading/preservation, quotes, Unicode, paths, context-dependent values, real `compopt` NoSpace/KeepOrder behavior, cursor edits, and binary replacement without adapter reinstallation. Initialization markers prove that requests bypass normal startup. +PowerShell tests also execute completed arguments in both Standard and Legacy +native argument modes, with ASCII pipeline encoding. The adapter targets 5.1+; +Windows PowerShell 5.1 itself is not part of this Linux E2E suite. Missing/wrong-version shells and missing runtimes are errors, never skips. The lighter `TestNativeShellCompletion` Go test still uses locally available @@ -91,7 +96,7 @@ complete repository. - Go linting: Linux, macOS, Windows. - Runtime tests: minimum/stable Go on each OS, including example binaries. -- Shell E2E: a required Linux job using rootless Podman and pinned Bash/Nushell images. +- Shell E2E: a required Linux job using rootless Podman and pinned Bash/Nushell/PowerShell images. - Shared checks: once on Linux; formats, module archives, workflows, example code, benchmark-adapter correctness, fuzzing, and vulnerabilities. Timing measurements are run separately on an idle machine, not in CI. diff --git a/internal/shelltestcmd/Containerfile.powershell b/internal/shelltestcmd/Containerfile.powershell new file mode 100644 index 0000000..d87f035 --- /dev/null +++ b/internal/shelltestcmd/Containerfile.powershell @@ -0,0 +1,8 @@ +# Pin the actual PowerShell host independently of the Alpine Bash/Nu environment. +# The runner selects a pinned Azure Linux image on arm64; Microsoft's Ubuntu image is amd64-only. +ARG POWERSHELL_IMAGE=mcr.microsoft.com/powershell:7.5-ubuntu-24.04@sha256:042240d57ec9e47e511033b92625a8d95875ee5860af3015992c248b58a8be81 +FROM ${POWERSHELL_IMAGE} +COPY fixture-initial fixture-updated /opt/fixtures/ +COPY shell-e2e /opt/tests/shell-e2e +RUN pwsh -NoLogo -NoProfile -Command '$PSVersionTable.PSVersion.ToString()' +ENTRYPOINT ["/opt/tests/shell-e2e", "-test.v", "-test.run=^TestContainerPowerShellCompletion$", "-test.timeout=2m"] diff --git a/internal/shelltestcmd/container_linux_test.go b/internal/shelltestcmd/container_linux_test.go index 364f95d..3bf4a00 100644 --- a/internal/shelltestcmd/container_linux_test.go +++ b/internal/shelltestcmd/container_linux_test.go @@ -13,7 +13,7 @@ import ( "testing" ) -//go:embed bash.sh config.nu +//go:embed bash.sh config.nu powershell.ps1 var shellAssets embed.FS type shellEnvironment struct { @@ -83,6 +83,14 @@ func (environment *shellEnvironment) start(shell string) *terminal { terminal.prompt() return terminal } + if shell == "powershell" { + setup, err := shellAssets.ReadFile("powershell.ps1") + must(environment.t, err) + terminal := startTerminal(environment.t, "pwsh", []string{"-NoLogo", "-NoExit", "-Command", string(setup)}, environment.env, environment.root) + terminal.expect("__CLI4G_READY__\r?\n") + terminal.prompt() + return terminal + } path := filepath.Join(environment.root, "config", "nushell", "config.nu") must(environment.t, os.MkdirAll(filepath.Dir(path), 0700)) config, err := shellAssets.ReadFile("config.nu") diff --git a/internal/shelltestcmd/main.go b/internal/shelltestcmd/main.go index 1921a6c..d59b1fb 100644 --- a/internal/shelltestcmd/main.go +++ b/internal/shelltestcmd/main.go @@ -22,7 +22,7 @@ import ( "github.com/echocat/cli4g/internal/exampleutil" ) -//go:embed Containerfile +//go:embed Containerfile Containerfile.powershell var assets embed.FS func main() { @@ -65,7 +65,7 @@ func run(parent context.Context, args []string, output, diagnostics io.Writer) e return err } defer func() { _ = os.RemoveAll(directory) }() - for _, name := range []string{"Containerfile"} { + for _, name := range []string{"Containerfile", "Containerfile.powershell"} { data, err := assets.ReadFile(name) if err != nil { return err @@ -91,16 +91,29 @@ func run(parent context.Context, args []string, output, diagnostics io.Writer) e "-o", filepath.Join(directory, "shell-e2e"), "./internal/shelltestcmd"); err != nil { return err } - idFile := filepath.Join(directory, "image-id") - if err := command(ctx, directory, nil, output, diagnostics, selected, "build", "--platform", "linux/"+runtime.GOARCH, - "--file", filepath.Join(directory, "Containerfile"), "--iidfile", idFile, "--tag", "localhost/cli4g-shell-e2e:local", directory); err != nil { + for _, file := range []string{"Containerfile", "Containerfile.powershell"} { + if err := runContainer(ctx, selected, directory, file, output, diagnostics); err != nil { + return err + } + } + return nil +} + +func runContainer(ctx context.Context, selected, directory, file string, output, diagnostics io.Writer) error { + idFile := filepath.Join(directory, file+".image-id") + args := []string{"build", "--platform", "linux/" + runtime.GOARCH, "--file", filepath.Join(directory, file), "--iidfile", idFile} + if file == "Containerfile.powershell" && runtime.GOARCH == "arm64" { + args = append(args, "--build-arg", "POWERSHELL_IMAGE=mcr.microsoft.com/powershell:7.5-azurelinux-3.0-arm64@sha256:60d31793816646c4dd80b719b350ca68c8a53c000cbb2740655ddafe5e127903") + } + args = append(args, directory) + if err := command(ctx, directory, nil, output, diagnostics, selected, args...); err != nil { return err } image, err := os.ReadFile(idFile) if err != nil { return err } - name := filepath.Base(directory) + name := filepath.Base(directory) + "-" + strings.ToLower(file) defer func() { cleanup, stop := context.WithTimeout(context.Background(), 15*time.Second) defer stop() diff --git a/internal/shelltestcmd/powershell.ps1 b/internal/shelltestcmd/powershell.ps1 new file mode 100644 index 0000000..d8c77b8 --- /dev/null +++ b/internal/shelltestcmd/powershell.ps1 @@ -0,0 +1,15 @@ +Import-Module PSReadLine +$OutputEncoding = [System.Text.Encoding]::ASCII +Set-PSReadLineOption -EditMode Emacs -PredictionSource None -BellStyle None +Set-PSReadLineKeyHandler -Chord Tab -Function TabCompleteNext +Set-PSReadLineKeyHandler -Chord Ctrl+o -ScriptBlock { + $line = '' + $cursor = 0 + [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$cursor) + [Console]::WriteLine() + [Console]::WriteLine('__CLI4G_BUFFER__' + (ConvertTo-Json -InputObject $line -Compress) + '__CLI4G_END__') + [Microsoft.PowerShell.PSConsoleReadLine]::Replace(0, $line.Length, '') + [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine() +} +function global:prompt { 'E2E> ' } +[Console]::WriteLine('__CLI4G_READY__') diff --git a/internal/shelltestcmd/powershell_linux_test.go b/internal/shelltestcmd/powershell_linux_test.go new file mode 100644 index 0000000..1900cec --- /dev/null +++ b/internal/shelltestcmd/powershell_linux_test.go @@ -0,0 +1,127 @@ +//go:build linux && shell_e2e + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestContainerPowerShellCompletion(t *testing.T) { + environment := newShellEnvironment(t) + version := strings.TrimSpace(string(environment.output("pwsh", "-NoLogo", "-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"))) + if version != "7.5.0" { + t.Fatalf("unexpected PowerShell version: %q", version) + } + t.Logf("Testing PowerShell %s / PSReadLine in an actual terminal", version) + profile := filepath.Join(environment.root, "config", "powershell", "profile.ps1") + must(t, os.MkdirAll(filepath.Dir(profile), 0700)) + const userProfile = "$env:CLI4G_PROFILE_SENTINEL = 'kept'\n" + must(t, os.WriteFile(profile, []byte(userProfile), 0600)) + must(t, os.WriteFile(filepath.Join(environment.root, "nope-file"), nil, 0600)) + terminal := environment.start("powershell") + name := filepath.Base(environment.program) + if got := strings.TrimSpace(string(environment.output("pwsh", "-NoLogo", "-Command", "$env:CLI4G_PROFILE_SENTINEL"))); got != "kept" { + t.Fatalf("lost user profile content: %q", got) + } + for _, value := range []struct { + line string + want []string + }{ + {name + " --format ü", []string{"über"}}, + {"Write-Output ignored | " + name + " --format=ya", []string{"--format=yaml"}}, + } { + command := "$ErrorActionPreference = 'Stop'; $line = '" + strings.ReplaceAll(value.line, "'", "''") + "'; " + + "$completion = [System.Management.Automation.CommandCompletion]::CompleteInput($line, $line.Length, $null); " + + "ConvertTo-Json -InputObject @($completion.CompletionMatches | ForEach-Object CompletionText) -Compress" + var matches []string + must(t, json.Unmarshal(environment.output("pwsh", "-NoLogo", "-Command", command), &matches)) + if strings.Join(matches, "\x00") != strings.Join(value.want, "\x00") { + t.Errorf("native completion API for %q: %q, want %q", value.line, matches, value.want) + } + } + for _, value := range [][2]string{ + {" --format ya", " --format yaml"}, {" --format=ya", " --format=yaml"}, {" --format ü", " --format über"}, + {` --format "two wo`, ` --format 'two words'`}, {` --format="two wo`, ` '--format=two words'`}, + {" get po", " get pods"}, {" --context cluster-b --namespace cl", " --context cluster-b --namespace cluster-b-namespace"}, + {` --context 'two words' --namespace two`, ` --context 'two words' --namespace 'two words-namespace'`}, + {" --selector en", " --selector env="}, {" --ordered ", " --ordered zulu"}, + {" --format nope", " --format nope"}, + } { + if got := terminal.complete(name + value[0]); got != name+value[1] { + t.Errorf("complete %q: got %q, want %q", value[0], got, name+value[1]) + } + } + line := name + " --namespace cl --context cluster-b" + if got := terminal.buffer(line, "\t", len(name+" --namespace cl")); got != name+" --namespace cluster-b-namespace --context cluster-b" { + t.Errorf("cursor completion: %q", got) + } + if got := terminal.buffer(name+" --ordered ", "\t\t", -1); got != name+" --ordered alpha" { + t.Errorf("KeepOrder: %q", got) + } + must(t, os.Mkdir(filepath.Join(environment.root, "directory name"), 0700)) + must(t, os.WriteFile(filepath.Join(environment.root, "directory-file"), nil, 0600)) + if got := terminal.complete(name + " shell-setup install powershell directory"); got != name+" shell-setup install powershell './directory name/'" { + t.Errorf("directory completion: %q", got) + } + for _, suffix := range []string{" --input=config.y", ` --input="space fi`, " --input=colon:fi"} { + completed := terminal.complete(name + suffix) + echoed := strings.Replace(completed, name, name+" __echo-args", 1) + var argv []string + must(t, json.Unmarshal(environment.output("pwsh", "-NoLogo", "-NoProfile", "-Command", echoed), &argv)) + want := "config.yaml" + if strings.Contains(suffix, "space") { + want = "space file.yaml" + } else if strings.Contains(suffix, "colon") { + want = "colon:file.yaml" + } + if len(argv) != 1 || !strings.HasPrefix(argv[0], "--input=") || filepath.Base(strings.TrimPrefix(argv[0], "--input=")) != want { + t.Errorf("path completion %q changed arguments: %q (buffer %q)", suffix, argv, completed) + } + } + // Literal candidate quoting is tested by native argument execution, including + // PowerShell 7's legacy mode used to exercise Windows PowerShell 5.1 behavior. + for _, mode := range []string{"Standard", "Legacy"} { + terminal.send("$PSNativeCommandArgumentPassing = '" + mode + "'\r") + terminal.prompt() + for _, value := range [][2]string{ + {`'a"b'`, `a"b-namespace`}, {`'a\"b'`, `a\"b-namespace`}, + {`'a''b'`, `a'b-namespace`}, {`'a’’b'`, `a’b-namespace`}, + {`'$literal;value'`, `$literal;value-namespace`}, {`''`, `-namespace`}, + } { + line := name + " --context " + value[0] + " --namespace " + completed := terminal.complete(line) + _, candidate, found := strings.Cut(completed, " --namespace ") + if !found || candidate == "" { + t.Fatalf("missing literal completion: %q", completed) + } + command := "$PSNativeCommandArgumentPassing = '" + mode + "'; " + name + " __echo-args " + candidate + var argv []string + must(t, json.Unmarshal(environment.output("pwsh", "-NoLogo", "-NoProfile", "-Command", command), &argv)) + if len(argv) != 1 || argv[0] != value[1] { + t.Errorf("literal arguments (%s): %q, want %q (buffer %q)", mode, argv, value[1], completed) + } + } + } + if got := terminal.complete(name + " --ini"); got != name+" --initial" { + t.Errorf("initial binary completion: %q", got) + } + environment.updateBinary() + if got := terminal.complete(name + " --up"); got != name+" --updated" { + t.Errorf("updated binary completion: %q", got) + } + environment.assertRequestsSkipInitialization() + // Repeat installation with the current public command; no duplicate loader or + // discarded user profile content, and no dependency on the old command name. + before, err := os.ReadFile(profile) + must(t, err) + environment.output(environment.program, "shell-admin", "install", "powershell") + after, err := os.ReadFile(profile) + must(t, err) + if string(before) != string(after) { + t.Fatal("reinstallation changed the PowerShell profile") + } +} diff --git a/mise.toml b/mise.toml index ce2960f..c7e1cd4 100644 --- a/mise.toml +++ b/mise.toml @@ -96,7 +96,7 @@ env.GOWORK = "off" run = 'go test -count=1 -v ./internal/integration -run "^TestModuleArchive$" -args -module-archive' [tasks."test:shells"] -description = "Run mandatory Bash/Nushell terminal E2E tests in Podman (preferred) or Docker" +description = "Run mandatory Bash/Nushell/PowerShell terminal E2E tests in Podman (preferred) or Docker" usage = ''' flag "--engine " help="Container engine: auto, podman or docker" default="auto" ''' From 014e4a44c41eb101fd5b26a39f3b78a00db6eee9 Mon Sep 17 00:00:00 2001 From: Gregor Noczinski Date: Tue, 20 Oct 2026 17:54:16 +0200 Subject: [PATCH 03/14] Add Zsh completion and terminal E2E tests --- .github/workflows/ci.yml | 2 +- README.md | 5 +- _examples/completion/README.md | 8 ++ completion_command.go | 16 +-- completion_command_test.go | 10 +- completion_powershell.go | 47 +------ completion_profile.go | 58 +++++++++ completion_protocol.go | 36 ++++++ completion_protocol_test.go | 4 +- completion_shell.go | 45 ++++++- completion_zsh.go | 46 +++++++ completion_zsh.zsh | 44 +++++++ completion_zsh_test.go | 42 +++++++ doc.go | 20 +++ docs/development.md | 14 ++- internal/shelltestcmd/Containerfile | 6 +- internal/shelltestcmd/container_linux_test.go | 27 +++- internal/shelltestcmd/zsh.zsh | 15 +++ internal/shelltestcmd/zsh_linux_test.go | 118 ++++++++++++++++++ mise.toml | 2 +- 20 files changed, 486 insertions(+), 79 deletions(-) create mode 100644 completion_profile.go create mode 100644 completion_zsh.go create mode 100644 completion_zsh.zsh create mode 100644 completion_zsh_test.go create mode 100644 internal/shelltestcmd/zsh.zsh create mode 100644 internal/shelltestcmd/zsh_linux_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6510069..9c7d23d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,7 +134,7 @@ jobs: - name: Set up rootless Podman uses: ./.github/actions/setup-rootless-podman - - name: Test Bash, Nushell and PowerShell in real terminals + - name: Test Bash, Nushell, PowerShell and Zsh in real terminals run: mise run test:shells --engine podman quality: diff --git a/README.md b/README.md index 67025b5..3a06306 100644 --- a/README.md +++ b/README.md @@ -99,13 +99,14 @@ code size, and cognitive complexity. ## Shell completion -Bash **4.4+**, Nushell **0.114+**, and PowerShell **5.1+** adapters query the current -binary, so binary updates need no completion reinstall. Install once with: +Bash **4.4+**, Nushell **0.114+**, PowerShell **5.1+**, and Zsh **5.8+** adapters query +the current binary, so binary updates need no completion reinstall. Install once with: ```shell your-app completion install bash # Requires a configured bash-completion loader your-app completion install nu # Autoloaded at the next interactive Nu startup your-app completion install powershell # Loaded through the user profiles on next startup +your-app completion install zsh # Configures fpath/autoloading for the next Zsh session ``` `WithCompletion(true)` selects `DefaultCompletionCommand` and diff --git a/_examples/completion/README.md b/_examples/completion/README.md index b12efec..0fcd106 100644 --- a/_examples/completion/README.md +++ b/_examples/completion/README.md @@ -50,6 +50,14 @@ ship completion install powershell # ship --context production --namespace payments deploy ch ``` +In **Zsh 5.8+**: + +```zsh +ship completion install zsh +# Start a new Zsh session, then type and press Tab: +# ship --context production --namespace payments deploy ch +``` + The standard handler completes `deploy`, flag names, the `development`/`production` context enum, and bool values automatically. The two callbacks in `inventory.go` add domain knowledge: - Namespace suggestions depend on the context and the selected service. diff --git a/completion_command.go b/completion_command.go index bcc3afc..a1b56b5 100644 --- a/completion_command.go +++ b/completion_command.go @@ -26,20 +26,20 @@ var DefaultCompletionCommand CommandRequirementLike = NewCompletionCommand() var DefaultCompletionRequestCommandName = "__complete" // CompletionInstallOptions configures the standard completion install command. -// Shell is bash, nu or powershell. Directory overrides the shell's default -// installation path; for PowerShell, an explicit directory also skips profile setup. +// Shell is bash, nu, powershell or zsh. Directory overrides the shell's default +// installation path; for PowerShell and Zsh, it also skips startup-file setup. // These are ordinary command options, parsed and validated through the usual CLI // lifecycle together with the selected application and command scopes. type CompletionInstallOptions struct { - Shell string `argument:"shell" enum:"bash|nu|powershell" required:"true" description:"Shell to configure."` + Shell string `argument:"shell" enum:"bash|nu|powershell|zsh" required:"true" description:"Shell to configure."` Directory string `argument:"directory" type:"completion-directory" description:"Installation directory; omitted uses the shell's default."` } // NewCompletionCommand builds a fresh, ordinary command tree named "completion". -// It supports "completion bash", "completion nu", "completion powershell", and -// "completion install [directory]". WithName, WithAliases, -// handlers and all other ordinary -// CommandRequirement configuration are available. Parent requirements, parsing, +// It supports "completion " and +// "completion install [directory]". WithName, WithAliases, +// handlers and all other ordinary CommandRequirement configuration are available. +// Parent requirements, parsing, // profiles, initialization, execution, cleanup and depth limits apply normally. // Only the independently configured request entry bypasses that lifecycle. // @@ -47,7 +47,7 @@ type CompletionInstallOptions struct { // CompletionApplicationFromContext, which custom management commands may also use. func NewCompletionCommand() *CommandRequirement[NoOptions] { command := NewSimpleCommand("completion", "Manage shell completion.").WithHandler(completionGroupHelp) - for _, shell := range []CompletionShell{CompletionBash, CompletionNushell, CompletionPowerShell} { + for _, shell := range []CompletionShell{CompletionBash, CompletionNushell, CompletionPowerShell, CompletionZsh} { command.WithCommand(NewSimpleCommand(string(shell), "Print the "+string(shell)+" adapter."). WithHandler(func(ctx context.Context, _ NoOptions) error { application, found := CompletionApplicationFromContext(ctx) diff --git a/completion_command_test.go b/completion_command_test.go index fc81630..8b77c6f 100644 --- a/completion_command_test.go +++ b/completion_command_test.go @@ -70,18 +70,18 @@ func TestDefaultCompletionCommandIsAnOrdinaryCompiledCommand(t *testing.T) { assert.False(t, found, "request entry is not registered as a command") definition.WithName("changed") assert.Equal(t, "shell-setup", app.CompletionCommandName()) - for _, shell := range []CompletionShell{CompletionBash, CompletionNushell, CompletionPowerShell} { + for _, shell := range []CompletionShell{CompletionBash, CompletionNushell, CompletionPowerShell, CompletionZsh} { var output, diagnostics bytes.Buffer require.NoError(t, app.ParseAndExecute(ContextWithStdio(nil, nil, &output, &diagnostics), nil, "shells", string(shell))) assert.Contains(t, output.String(), "__suggest") assert.Empty(t, diagnostics.String(), "ancestor group handler must not print help for a selected child") } - assert.Equal(t, []string{"initialize", "close", "initialize", "close", "initialize", "close"}, events) + assert.Equal(t, []string{"initialize", "close", "initialize", "close", "initialize", "close", "initialize", "close"}, events) var help bytes.Buffer require.NoError(t, app.ParseAndExecute(ContextWithStdio(nil, nil, &help, &help), nil, "shell-setup", "--help")) assert.Contains(t, help.String(), "tool shell-setup") assert.Contains(t, help.String(), "install") - assert.Len(t, events, 6, "ordinary --help skips initialization") + assert.Len(t, events, 8, "ordinary --help skips initialization") var output bytes.Buffer directory := t.TempDir() require.NoError(t, app.ParseAndExecute(ContextWithStdio(nil, nil, &output, &output), nil, "shell-setup", "install", "bash", directory)) @@ -182,7 +182,7 @@ func TestNormalCompletionCommandCompletesItsOwnSchema(t *testing.T) { }{ {[]string{"shell-s"}, []string{"shell-setup"}, false}, {[]string{"__s"}, []string{}, false}, - {[]string{"shell-setup", ""}, []string{"bash", "install", "nu", "powershell"}, false}, + {[]string{"shell-setup", ""}, []string{"bash", "install", "nu", "powershell", "zsh"}, false}, {[]string{"shell-setup", "install", "n"}, []string{"nu"}, false}, {[]string{"shell-setup", "install", "bash", "dir"}, []string{}, true}, } { @@ -194,7 +194,7 @@ func TestNormalCompletionCommandCompletesItsOwnSchema(t *testing.T) { renamed := NewSimpleApplication("tool").WithCompletion(true). WithCompletionCommand(NewCompletionCommand().WithName("shell-admin")).WithCompletionRequestCommandName("__suggest").MustCreate() defer func() { assert.NoError(t, renamed.Close()) }() - for _, shell := range []CompletionShell{CompletionBash, CompletionNushell, CompletionPowerShell} { + for _, shell := range []CompletionShell{CompletionBash, CompletionNushell, CompletionPowerShell, CompletionZsh} { before, err := app.CompletionScript(shell) require.NoError(t, err) after, err := renamed.CompletionScript(shell) diff --git a/completion_powershell.go b/completion_powershell.go index d73c28e..53f7461 100644 --- a/completion_powershell.go +++ b/completion_powershell.go @@ -1,19 +1,15 @@ package cli import ( - "bytes" "context" "crypto/sha256" _ "embed" "encoding/base64" - "encoding/binary" "fmt" - "os" "os/exec" "path/filepath" "runtime" "strings" - "unicode/utf16" ) //go:embed completion_powershell.ps1 @@ -56,52 +52,11 @@ func powershellCompletionProfiles(ctx context.Context) ([]string, error) { } func installPowerShellProfile(ctx context.Context, profile, script, command string) error { - if info, err := os.Lstat(profile); err == nil && info.Mode()&os.ModeSymlink != 0 { - resolved, err := filepath.EvalSymlinks(profile) - if err != nil { - return err - } - profile = resolved - } - data, err := os.ReadFile(profile) - if err != nil && !os.IsNotExist(err) { - return err - } - mode := os.FileMode(0600) - if err == nil { - info, err := os.Stat(profile) - if err != nil { - return err - } - mode = info.Mode().Perm() - } hash := sha256.Sum256([]byte(command)) marker := fmt.Sprintf("# cli4g completion %x", hash[:8]) // ASCII source preserves legacy ANSI profiles too. The path is data, never // interpolated PowerShell code, including when it contains quotes or Unicode. block := "\r\n" + marker + "\r\n. ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('" + base64.StdEncoding.EncodeToString([]byte(script)) + "')))\r\n# end cli4g completion\r\n" - encode := func(value string) []byte { return []byte(value) } - if bytes.HasPrefix(data, []byte{0xff, 0xfe}) || bytes.HasPrefix(data, []byte{0xfe, 0xff}) { - var order binary.ByteOrder = binary.LittleEndian - if data[0] == 0xfe { - order = binary.BigEndian - } - encode = func(value string) []byte { - units := utf16.Encode([]rune(value)) - result := make([]byte, len(units)*2) - for i, unit := range units { - order.PutUint16(result[i*2:], unit) - } - return result - } - } - addition := encode(block) - if bytes.Contains(data, addition) { - return nil - } - if bytes.Contains(data, encode(marker)) { - return fmt.Errorf("PowerShell profile already contains a different cli4g loader for %q: %s", command, profile) - } - return replaceCompletionFile(ctx, profile, append(data, addition...), mode) + return appendCompletionProfile(ctx, profile, marker, block) } diff --git a/completion_profile.go b/completion_profile.go new file mode 100644 index 0000000..0f9c47a --- /dev/null +++ b/completion_profile.go @@ -0,0 +1,58 @@ +package cli + +import ( + "bytes" + "context" + "encoding/binary" + "fmt" + "os" + "path/filepath" + "unicode/utf16" +) + +// appendCompletionProfile retains user content, encoding, permissions and symlink +// targets. A different block under the same marker is never silently replaced. +func appendCompletionProfile(ctx context.Context, profile, marker, block string) error { + if info, err := os.Lstat(profile); err == nil && info.Mode()&os.ModeSymlink != 0 { + resolved, err := filepath.EvalSymlinks(profile) + if err != nil { + return err + } + profile = resolved + } + data, err := os.ReadFile(profile) + if err != nil && !os.IsNotExist(err) { + return err + } + mode := os.FileMode(0600) + if err == nil { + info, err := os.Stat(profile) + if err != nil { + return err + } + mode = info.Mode().Perm() + } + encode := func(value string) []byte { return []byte(value) } + if bytes.HasPrefix(data, []byte{0xff, 0xfe}) || bytes.HasPrefix(data, []byte{0xfe, 0xff}) { + var order binary.ByteOrder = binary.LittleEndian + if data[0] == 0xfe { + order = binary.BigEndian + } + encode = func(value string) []byte { + units := utf16.Encode([]rune(value)) + result := make([]byte, len(units)*2) + for i, unit := range units { + order.PutUint16(result[i*2:], unit) + } + return result + } + } + addition := encode(block) + if bytes.Contains(data, addition) { + return nil + } + if bytes.Contains(data, encode(marker)) { + return fmt.Errorf("shell startup file already contains a different cli4g loader: %s", profile) + } + return replaceCompletionFile(ctx, profile, append(data, addition...), mode) +} diff --git a/completion_protocol.go b/completion_protocol.go index fe1f094..7c4f1a7 100644 --- a/completion_protocol.go +++ b/completion_protocol.go @@ -36,6 +36,13 @@ import ( // Windows PowerShell 5.1's legacy native-pipeline encoding. The adapter sends // literal AST values, keeps arguments after the cursor, and converts UTF-16 // cursor positions to UTF-8 byte offsets without evaluating expressions. +// - __complete 1 zsh WORD PREFIX WORDS...: WORD is the zero-based current word +// index; WORDS excludes the executable but includes the full current word and +// tokens after the cursor. PREFIX is its unquoted cursor prefix, whose UTF-8 +// byte length is the offset. Output is NUL-delimited: "1", path mode (0 none, +// 1 files, 2 directories), NoSpace (0/1), KeepOrder (0/1), FilePrefix, FileInput, +// then alternating candidate values and descriptions. Zsh handles quoting and +// native paths. Empty words are preserved as native process arguments. // // New transports may be added without changing existing transports. Query errors // become authoritative empty responses with no diagnostics; direct Complete @@ -61,6 +68,35 @@ func (this *Application[O]) queryCompletion(ctx context.Context, opts *ParseOpts output = io.Discard } switch CompletionShell(args[1]) { + case CompletionZsh: + if len(args) >= 4 { + word, err := strconv.Atoi(args[2]) + words := args[4:] + if err == nil && word >= 0 && word < len(words) && strings.HasPrefix(words[word], args[3]) { + if completed, err := this.Complete(ctx, opts, CompletionRequest{Words: words, Word: word, Offset: len(args[3])}); err == nil { + result = completed + } + } + } + mode := 0 + if result.Files { + mode = 1 + } + if result.Directories { + mode = 2 + } + if _, err := fmt.Fprintf(output, "1%c%d%c%d%c%d%c%s%c%s%c", 0, mode, 0, boolInt(result.NoSpace), 0, boolInt(result.KeepOrder), 0, result.FilePrefix, 0, result.FileInput, 0); err != nil { + return err + } + for _, candidate := range result.Candidates { + if strings.ContainsRune(candidate.Value, 0) { + continue + } + if _, err := fmt.Fprintf(output, "%s%c%s%c", candidate.Value, 0, strings.ReplaceAll(candidate.Description, "\x00", " "), 0); err != nil { + return err + } + } + return nil case CompletionBash: var request CompletionRequest var err error diff --git a/completion_protocol_test.go b/completion_protocol_test.go index 93ed2d7..f2e1ed0 100644 --- a/completion_protocol_test.go +++ b/completion_protocol_test.go @@ -53,13 +53,13 @@ func TestCompletionProtocolRejectsMalformedRequestsQuietly(t *testing.T) { func TestCompletionInstallationIsIdempotentAndProtectsExistingFiles(t *testing.T) { app := NewSimpleApplication("tool").WithCompletionRequestCommandName(DefaultCompletionRequestCommandName).MustCreate() defer func() { assert.NoError(t, app.Close()) }() - for _, shell := range []CompletionShell{CompletionBash, CompletionNushell, CompletionPowerShell} { + for _, shell := range []CompletionShell{CompletionBash, CompletionNushell, CompletionPowerShell, CompletionZsh} { directory := t.TempDir() path, err := app.InstallCompletion(nil, shell, directory) require.NoError(t, err) contents, err := os.ReadFile(path) require.NoError(t, err) - assert.True(t, strings.HasPrefix(strings.TrimPrefix(string(contents), "\ufeff"), completionScriptHeader)) + assert.Contains(t, string(contents), completionScriptHeader) again, err := app.InstallCompletion(nil, shell, directory) require.NoError(t, err) assert.Equal(t, path, again) diff --git a/completion_shell.go b/completion_shell.go index 17842f8..8b10fa4 100644 --- a/completion_shell.go +++ b/completion_shell.go @@ -18,6 +18,7 @@ const ( CompletionBash CompletionShell = "bash" // Bash 4.4+; bash-completion enables on-demand loading. CompletionNushell CompletionShell = "nu" // Nushell 0.114+ with command-wide completers. CompletionPowerShell CompletionShell = "powershell" // Windows PowerShell 5.1+ and PowerShell 7+. + CompletionZsh CompletionShell = "zsh" // Zsh 5.8+ with its native completion system. ) const completionScriptHeader = "# Generated by cli4g shell completion protocol v1.\n" @@ -49,6 +50,13 @@ const completionScriptHeader = "# Generated by cli4g shell completion protocol v // space insertion, so NoSpace does not change that binding. Scripts use UTF-8 with // a BOM for Windows PowerShell 5.1 and an ASCII-only request/response transport. // +// Zsh uses compinit/fpath autoloading or can source the adapter after compinit. +// Its native tokenizer supplies words and cursor prefixes; simple quoting is +// removed without evaluating expressions. All argument words, including those +// after the cursor, are preserved. compadd quotes candidates, shows descriptions, +// and honors NoSpace/KeepOrder; _files provides native path completion. No global +// widgets, styles, aliases or external completers are replaced. +// // Bash recognizes simple quotes, escapes, and separators but deliberately does // not evaluate shell substitutions or expansions while collecting dependency // values. Applications requiring already-expanded values may call Complete with @@ -70,12 +78,16 @@ func (this *Application[O]) CompletionScript(shell CompletionShell) (string, err template = nushellCompletionScript case CompletionPowerShell: template = powershellCompletionScript + case CompletionZsh: + template = zshCompletionScript default: - return "", NewValidationError("supported completion shells are bash, nu and powershell") + return "", NewValidationError("supported completion shells are bash, nu, powershell and zsh") } header := completionScriptHeader if shell == CompletionPowerShell { header = "\ufeff" + header + } else if shell == CompletionZsh { + header = "#compdef " + this.Name() + "\n" + header } return header + strings.NewReplacer( "@FUNCTION@", function, @@ -86,6 +98,7 @@ func (this *Application[O]) CompletionScript(shell CompletionShell) (string, err "@PSCOMMAND@", quotePowerShell(this.Name()), "@PSEXE@", quotePowerShell(strings.TrimSuffix(this.Name(), ".exe")+".exe"), "@PSREQUEST@", quotePowerShell(this.CompletionRequestCommandName()), + "@ZSHAUTOLOAD@", "'"+strings.ReplaceAll("_"+this.Name(), "'", "'\\''")+"'", ).Replace(template), nil } @@ -115,6 +128,16 @@ func (this *Application[O]) CompletionScript(shell CompletionShell) (string, err // need another installation. A nonempty directory writes only the .ps1 adapter, // leaving profiles untouched for packaging or manual dot-sourcing. Installation // respects the host's existing execution policy and requires no administrator rights. +// +// Zsh installs _Name under XDG_DATA_HOME/zsh/site-functions (otherwise +// ~/.local/share/zsh/site-functions). The installed zsh discovers ZDOTDIR/HOME by +// reading its normal noninteractive startup files, including .zshenv, only during +// installation. An idempotent block is appended to .zshrc there: it extends fpath, +// runs compinit only if compdef is unavailable, and registers the autoload function. +// Application and adapter code do not execute during shell startup. Existing user +// content and completion styles are preserved. An explicit directory writes only +// the autoload file, for a packager's fpath or manual setup. Source it after +// compinit to activate it in an existing session. func (this *Application[O]) InstallCompletion(ctx context.Context, shell CompletionShell, directory string) (string, error) { if ctx == nil { ctx = context.Background() @@ -130,6 +153,7 @@ func (this *Application[O]) InstallCompletion(ctx context.Context, shell Complet return "", fmt.Errorf("completion installation requires a base command name") } var profiles []string + var zshProfile string if directory == "" && shell == CompletionPowerShell { profiles, err = powershellCompletionProfiles(ctx) if err != nil { @@ -137,6 +161,12 @@ func (this *Application[O]) InstallCompletion(ctx context.Context, shell Complet } directory = filepath.Join(filepath.Dir(profiles[0]), "cli4g-completions") } + if directory == "" && shell == CompletionZsh { + directory, zshProfile, err = zshCompletionInstallation(ctx) + if err != nil { + return "", err + } + } if directory == "" { directory, err = completionDirectory(ctx, shell) if err != nil { @@ -148,6 +178,8 @@ func (this *Application[O]) InstallCompletion(ctx context.Context, shell Complet name = "cli4g-" + this.Name() + ".nu" } else if shell == CompletionPowerShell { name = "cli4g-" + this.Name() + ".ps1" + } else if shell == CompletionZsh { + name = "_" + this.Name() } path := filepath.Join(directory, name) if err := writeCompletionFile(ctx, path, script); err != nil { @@ -158,6 +190,11 @@ func (this *Application[O]) InstallCompletion(ctx context.Context, shell Complet return "", err } } + if zshProfile != "" { + if err := installZshProfile(ctx, zshProfile, directory, this.Name()); err != nil { + return "", err + } + } return path, nil } @@ -167,7 +204,11 @@ func writeCompletionFile(ctx context.Context, path, script string) error { if string(previous) == script { return nil } - if !strings.HasPrefix(strings.TrimPrefix(string(previous), "\ufeff"), completionScriptHeader) { + managed := strings.TrimPrefix(string(previous), "\ufeff") + if strings.HasPrefix(managed, "#compdef ") { + _, managed, _ = strings.Cut(managed, "\n") + } + if !strings.HasPrefix(managed, completionScriptHeader) { return fmt.Errorf("completion file already exists and is not managed by cli4g") } } else if !os.IsNotExist(err) { diff --git a/completion_zsh.go b/completion_zsh.go new file mode 100644 index 0000000..90df246 --- /dev/null +++ b/completion_zsh.go @@ -0,0 +1,46 @@ +package cli + +import ( + "context" + "crypto/sha256" + _ "embed" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +//go:embed completion_zsh.zsh +var zshCompletionScript string + +func zshCompletionInstallation(ctx context.Context) (directory, profile string, err error) { + output, err := exec.CommandContext(ctx, "zsh", "-c", "print -rn -- ${ZDOTDIR:-$HOME}").Output() + if err != nil { + return "", "", fmt.Errorf("could not discover Zsh startup directory: %w", err) + } + startup := strings.TrimSpace(string(output)) + if !filepath.IsAbs(startup) || strings.ContainsAny(startup, "\r\n\x00") { + return "", "", fmt.Errorf("zsh returned no absolute startup directory") + } + data := os.Getenv("XDG_DATA_HOME") + if data == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", "", err + } + data = filepath.Join(home, ".local", "share") + } + return filepath.Join(data, "zsh", "site-functions"), filepath.Join(startup, ".zshrc"), nil +} + +func installZshProfile(ctx context.Context, profile, directory, command string) error { + quote := func(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" } + hash := sha256.Sum256([]byte(command)) + marker := fmt.Sprintf("# cli4g completion %x", hash[:8]) + block := "\n" + marker + "\nfpath=(" + quote(directory) + " $fpath)\n" + + "if (( ! $+functions[compdef] )); then\n autoload -Uz compinit\n compinit\nfi\n" + + "autoload -Uz -- " + quote("_"+command) + "\ncompdef " + quote("_"+command) + " " + quote(command) + + "\n# end cli4g completion\n" + return appendCompletionProfile(ctx, profile, marker, block) +} diff --git a/completion_zsh.zsh b/completion_zsh.zsh new file mode 100644 index 0000000..2be2ed1 --- /dev/null +++ b/completion_zsh.zsh @@ -0,0 +1,44 @@ +_@FUNCTION@() { + emulate -L zsh + setopt extendedglob + local program="${(Q)words[1]}" field mode value description i + local -a response values displays addopts args + args=("${(@Q)words[2,-1]}") + # Zsh's cursor fragments already account for an unfinished quote, unlike + # dequoting the full raw word, which requires a matching closing quote. + args[CURRENT-1]="${(Q)PREFIX}${(Q)SUFFIX}" + while IFS= read -r -d '' field; do + response+=("$field") + done < <("$program" @BASHREQUEST@ 1 zsh "$((CURRENT - 2))" "${(Q)PREFIX}" "${args[@]}" 2>/dev/null) + (( ${#response} >= 6 )) && [[ $response[1] == 1 ]] || return 0 + if [[ $response[4] == 1 ]]; then + addopts=(-V @FUNCTION@) + else + addopts=(-J @FUNCTION@) + fi + if [[ $response[3] == 1 ]] || (( CURRENT < ${#words} )); then + addopts+=(-S '') + fi + for ((i=7; i<${#response}; i+=2)); do + value=$response[i] + description=$response[i+1] + values+=("$value") + displays+=("$value${description:+ -- $description}") + done + (( ${#values} )) && compadd -U "${addopts[@]}" -d displays -- "${values[@]}" + mode=$response[2] + if [[ $mode != 0 ]]; then + [[ -n $response[5] ]] && compset -P "${(b)response[5]}" + if [[ $mode == 2 ]]; then + _files -/ + else + _files + fi + fi + return 0 +} +if [[ $funcstack[1] == @ZSHAUTOLOAD@ ]]; then + _@FUNCTION@ "$@" +else + compdef _@FUNCTION@ @BASHCOMMAND@ +fi diff --git a/completion_zsh_test.go b/completion_zsh_test.go new file mode 100644 index 0000000..2eb650e --- /dev/null +++ b/completion_zsh_test.go @@ -0,0 +1,42 @@ +package cli + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/echocat/cli4g/codec" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestZshCompletionProtocolPreservesContextDescriptionsAndDirectives(t *testing.T) { + var input codec.CompletionInput + app := NewSimpleApplication("tool").WithCompletion(true). + WithInitializeHandler(func(context.Context, NoOptions) error { panic("completion must skip initialization") }). + WithFlag(NewFlag(codec.WithCompletionHandler(codec.NewString(), func(_ context.Context, value codec.CompletionInput) (codec.CompletionResult, error) { + input = value + return codec.CompletionResult{Handled: true, NoSpace: true, KeepOrder: true, Candidates: []codec.CompletionCandidate{ + {Value: "ü:zulu", Description: "First: choice\nnext line"}, {Value: "ü:alpha", Description: "Second"}, + }}, nil + }), "value")).MustCreate() + defer func() { assert.NoError(t, app.Close()) }() + var output, diagnostics bytes.Buffer + ctx := ContextWithStdio(nil, nil, &output, &diagnostics) + require.NoError(t, app.ParseAndExecute(ctx, nil, "__complete", "1", "zsh", "1", "ü:", "--value", "ü:suffix", "--later")) + assert.Equal(t, []string{"--value", "ü:suffix", "--later"}, input.Words) + assert.Equal(t, "ü:", input.Prefix) + assert.Equal(t, "suffix", input.Suffix) + assert.Equal(t, []string{"1", "0", "1", "1", "", "", "ü:zulu", "First: choice\nnext line", "ü:alpha", "Second", ""}, strings.Split(output.String(), "\x00")) + assert.Empty(t, diagnostics.String()) + for _, args := range [][]string{ + {"1", "zsh"}, {"1", "zsh", "-1", "", ""}, {"1", "zsh", "no-index", "", ""}, + {"1", "zsh", "0", "not-a-prefix", "value"}, {"1", "zsh", "2", "", "value"}, + } { + output.Reset() + require.NoError(t, app.ParseAndExecute(ctx, nil, append([]string{"__complete"}, args...)...)) + assert.Equal(t, "1\x000\x000\x000\x00\x00\x00", output.String()) + assert.Empty(t, diagnostics.String()) + } +} diff --git a/doc.go b/doc.go index 10959a9..92c61c7 100644 --- a/doc.go +++ b/doc.go @@ -140,6 +140,7 @@ // tool completion install bash // tool completion install nu // tool completion install powershell +// tool completion install zsh // // Bash 4.4+ uses the bash-completion package's on-demand loader. Nushell 0.114+ // uses its user autoload directory on the next interactive startup. PowerShell @@ -169,6 +170,25 @@ // determines whitespace insertion; the adapter does not change that binding. // See [Application.InstallCompletion] and [CompletionProtocolVersion] for details. // +// Zsh 5.8+ uses its native compinit/fpath autoloading. The default installer writes +// _Name below XDG_DATA_HOME/zsh/site-functions (otherwise +// ~/.local/share/zsh/site-functions), and +// appends a static block to .zshrc in Zsh's startup directory. That directory is +// discovered from the installed zsh, including non-exported ZDOTDIR settings in +// .zshenv. Discovery runs only during installation. The block extends fpath, +// initializes compinit if needed, and registers an autoload function; it starts +// neither the application nor the adapter at shell startup. Existing user content, +// widgets and completion styles are preserved. Explicit directories write only +// the autoload file for packaging. Source that file after compinit for an existing +// session. Run installation again after relocating the startup directory. +// +// The Zsh adapter uses native words and cursor prefix/suffix fragments, including +// unfinished quotes and arguments after the cursor. It never evaluates expressions +// while collecting dependency values. Candidates carry descriptions; compadd +// provides literal quoting and NoSpace/KeepOrder, and _files handles native paths. +// ZLE settings control whether completion operates inside a word (COMPLETE_IN_WORD) +// and how menu selection inserts spaces. The adapter retains those settings. +// // ApplicationRequirement.CompletionCommand is an ordinary CommandRequirementLike. // Nil means absent. Create compiles it alongside Commands without modifying the // builder list or shared default. It uses the same parsing, inherited inputs, diff --git a/docs/development.md b/docs/development.md index e056dca..5b7c3b1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -19,7 +19,7 @@ mise run check:full | `mise run test --coverage` | Go and example coverage | | `mise run test:matrix` | Minimum and stable Go versions | | `mise run test:module` | Test the distributable module archive without nested modules or Git metadata | -| `mise run test:shells` | Mandatory interactive Bash/Nushell/PowerShell E2E tests in Linux containers | +| `mise run test:shells` | Mandatory interactive Bash/Nushell/PowerShell/Zsh E2E tests in Linux containers | | `mise run test:fuzz` | Bounded parser fuzzing | | `mise run test:benchmarks` | Check comparison adapters and their module | | `mise run bench` | Measure CLI lifecycle comparisons and update the results page | @@ -49,19 +49,23 @@ The test containers have no network access; image building needs registry/packag access. Containers and temporary files are cleaned up; image layers remain cached. The Containerfiles pin Bash **5.2.37**, Nushell **0.114.1**, and PowerShell **7.5.0** -images by manifest digest. PowerShell runs separately on Ubuntu (amd64) or Azure +images by manifest digest and Zsh **5.9** through Alpine package `zsh=5.9-r5`. +PowerShell runs separately on Ubuntu (amd64) or Azure Linux (arm64), keeping its native .NET environment. The runner cross-compiles a Go test binary with the `shell_e2e` build tag. Its Go PTY driver uses Linux terminal ioctls from the existing `golang.org/x/sys` dependency and embedded shell setup scripts. It installs adapters through the -real CLI command, starts interactive Bash/Readline, Nu/Reedline, and PowerShell/PSReadLine, +real CLI command, starts interactive Bash/Readline, Nu/Reedline, PowerShell/PSReadLine, and Zsh/ZLE, presses Tab, and reads the line-editor buffer. It verifies on-demand Bash loading, -Nu autoload, PowerShell profile loading/preservation, quotes, Unicode, paths, context-dependent values, real `compopt` +Nu/Zsh autoload, PowerShell profile loading/preservation, quotes, Unicode, paths, context-dependent values, real `compopt` NoSpace/KeepOrder behavior, cursor edits, and binary replacement without adapter reinstallation. Initialization markers prove that requests bypass normal startup. PowerShell tests also execute completed arguments in both Standard and Legacy native argument modes, with ASCII pipeline encoding. The adapter targets 5.1+; Windows PowerShell 5.1 itself is not part of this Linux E2E suite. +Zsh tests cover native menus/KeepOrder, NoSpace, mid-word and Unicode cursor edits, +directory-only completion, preserved startup content/styles, existing compinit, +non-exported custom ZDOTDIR settings, and sourcing the generated autoload file. Missing/wrong-version shells and missing runtimes are errors, never skips. The lighter `TestNativeShellCompletion` Go test still uses locally available @@ -96,7 +100,7 @@ complete repository. - Go linting: Linux, macOS, Windows. - Runtime tests: minimum/stable Go on each OS, including example binaries. -- Shell E2E: a required Linux job using rootless Podman and pinned Bash/Nushell/PowerShell images. +- Shell E2E: a required Linux job using rootless Podman and pinned Bash/Nushell/PowerShell/Zsh versions. - Shared checks: once on Linux; formats, module archives, workflows, example code, benchmark-adapter correctness, fuzzing, and vulnerabilities. Timing measurements are run separately on an idle machine, not in CI. diff --git a/internal/shelltestcmd/Containerfile b/internal/shelltestcmd/Containerfile index e1cd792..1474018 100644 --- a/internal/shelltestcmd/Containerfile +++ b/internal/shelltestcmd/Containerfile @@ -1,11 +1,11 @@ -# Bash 5.2.37 and Nushell 0.114.1 / Alpine 3.22; tests are static Go binaries. +# Bash 5.2.37, Nushell 0.114.1 and Zsh 5.9 / Alpine 3.22; static Go test binaries. # Pin image manifests so rebuilding cannot silently change the tested shells. FROM ghcr.io/nushell/nushell:0.114.1-alpine@sha256:278ba1af47dfba2754be3f86d395f45a7c850f8a011f8ed921fa4093642257f3 AS nu FROM docker.io/library/bash:5.2.37@sha256:a54fb4422b18f05dd3107c36f39d67b26334fda7ec89f4126052b45e228e2f15 -RUN apk add --no-cache bash-completion ncurses-terminfo-base +RUN apk add --no-cache bash-completion ncurses-terminfo-base zsh=5.9-r5 COPY --from=nu /usr/bin/nu /usr/local/bin/nu COPY fixture-initial fixture-updated /opt/fixtures/ COPY shell-e2e /opt/tests/shell-e2e -RUN bash --version && nu --version +RUN bash --version && nu --version && zsh --version ENTRYPOINT ["/opt/tests/shell-e2e", "-test.v", "-test.run=^TestContainerShellCompletion$", "-test.timeout=2m"] diff --git a/internal/shelltestcmd/container_linux_test.go b/internal/shelltestcmd/container_linux_test.go index 3bf4a00..ffa6588 100644 --- a/internal/shelltestcmd/container_linux_test.go +++ b/internal/shelltestcmd/container_linux_test.go @@ -13,7 +13,7 @@ import ( "testing" ) -//go:embed bash.sh config.nu powershell.ps1 +//go:embed bash.sh config.nu powershell.ps1 zsh.zsh var shellAssets embed.FS type shellEnvironment struct { @@ -91,6 +91,21 @@ func (environment *shellEnvironment) start(shell string) *terminal { terminal.prompt() return terminal } + if shell == "zsh" { + setup, err := shellAssets.ReadFile("zsh.zsh") + must(environment.t, err) + startup := strings.TrimSpace(string(environment.output("zsh", "-c", "print -rn -- ${ZDOTDIR:-$HOME}"))) + profile := filepath.Join(startup, ".zshrc") + file, err := os.OpenFile(profile, os.O_APPEND|os.O_WRONLY, 0600) + must(environment.t, err) + _, err = file.Write(setup) + must(environment.t, err) + must(environment.t, file.Close()) + terminal := startTerminal(environment.t, "zsh", []string{"-d", "-i"}, environment.env, environment.root) + terminal.expect("__CLI4G_READY__\r?\n") + terminal.prompt() + return terminal + } path := filepath.Join(environment.root, "config", "nushell", "config.nu") must(environment.t, os.MkdirAll(filepath.Dir(path), 0700)) config, err := shellAssets.ReadFile("config.nu") @@ -114,10 +129,12 @@ func TestContainerShellCompletion(t *testing.T) { must(t, err) nu, err := exec.Command("nu", "--version").CombinedOutput() must(t, err) - if !strings.Contains(string(bash), "version 5.2.37") || strings.TrimSpace(string(nu)) != "0.114.1" { - t.Fatalf("unexpected shell versions: %q, %q", bash, nu) + zsh, err := exec.Command("zsh", "--version").CombinedOutput() + must(t, err) + if !strings.Contains(string(bash), "version 5.2.37") || strings.TrimSpace(string(nu)) != "0.114.1" || !strings.HasPrefix(string(zsh), "zsh 5.9 ") { + t.Fatalf("unexpected shell versions: %q, %q, %q", bash, nu, zsh) } - t.Log("Testing Bash 5.2.37 and Nushell 0.114.1 in actual terminals") + t.Log("Testing Bash 5.2.37, Nushell 0.114.1 and Zsh 5.9 in actual terminals") t.Run("bash_readline", func(t *testing.T) { environment := newShellEnvironment(t) terminal := environment.start("bash") @@ -191,4 +208,6 @@ func TestContainerShellCompletion(t *testing.T) { } environment.assertRequestsSkipInitialization() }) + t.Run("zsh_zle", testZshCompletion) + t.Run("zsh_custom_zdotdir", testZshCustomStartup) } diff --git a/internal/shelltestcmd/zsh.zsh b/internal/shelltestcmd/zsh.zsh new file mode 100644 index 0000000..4f7afb6 --- /dev/null +++ b/internal/shelltestcmd/zsh.zsh @@ -0,0 +1,15 @@ +PS1='E2E> ' +setopt completeinword +bindkey -e +bindkey '^I' expand-or-complete +bindkey '^X^N' menu-complete +_e2e_dump() { + print -rn -- $'\n__CLI4G_BUFFER__' + print -rn -- "$BUFFER" | cli4g-completion-test __quote-stdin + print -r -- '__CLI4G_END__' + BUFFER='' + zle accept-line +} +zle -N _e2e_dump +bindkey '^O' _e2e_dump +print -r -- '__CLI4G_READY__' diff --git a/internal/shelltestcmd/zsh_linux_test.go b/internal/shelltestcmd/zsh_linux_test.go new file mode 100644 index 0000000..7fc47e1 --- /dev/null +++ b/internal/shelltestcmd/zsh_linux_test.go @@ -0,0 +1,118 @@ +//go:build linux && shell_e2e + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +func testZshCompletion(t *testing.T) { + environment := newShellEnvironment(t) + profile := filepath.Join(environment.root, "home", ".zshrc") + const userProfile = "export CLI4G_PROFILE_SENTINEL=kept\nzstyle ':completion:*' verbose yes\n" + must(t, os.WriteFile(profile, []byte(userProfile), 0600)) + must(t, os.WriteFile(filepath.Join(environment.root, "nope-file"), nil, 0600)) + terminal := environment.start("zsh") + name := filepath.Base(environment.program) + for _, value := range [][2]string{ + {" --format ya", " --format yaml "}, {" --format=ya", " --format=yaml "}, {" --format ü", " --format über "}, + {` --format "two wo`, ` --format "two words" `}, {" get po", " get pods "}, + {" --context cluster-b --namespace cl", " --context cluster-b --namespace cluster-b-namespace "}, + {" --input=config.y", " --input=config.yaml "}, {` --input="space fi`, ` --input=space\ file.yaml `}, + {" --input=colon:fi", " --input=colon:file.yaml "}, {" --selector en", " --selector env="}, + {" --format nope", " --format nope"}, + } { + if got := terminal.complete(name + value[0]); got != name+value[1] { + t.Errorf("complete %q: got %q, want %q", value[0], got, name+value[1]) + } + } + for _, value := range []struct{ keys, want string }{ + {"\x18\x0e", "zulu"}, {"\x18\x0e\x18\x0e", "alpha"}, + } { + if got := terminal.buffer(name+" --ordered ", value.keys, -1); got != name+" --ordered "+value.want { + t.Errorf("KeepOrder: %q", got) + } + } + line := name + " --namespace cl --context cluster-b" + if got := terminal.buffer(line, "\t", len(name+" --namespace cl")); got != name+" --namespace cluster-b-namespace --context cluster-b" { + t.Errorf("cursor completion: %q", got) + } + line = name + " --namespace cluster-a-namespace --context cluster-b" + if got := terminal.buffer(line, "\t", len(name+" --namespace cl")); got != name+" --namespace cluster-b-namespace --context cluster-b" { + t.Errorf("mid-word cursor completion: %q", got) + } + line = name + " --namespace ü --context über" + if got := terminal.buffer(line, "\t", len([]rune(name+" --namespace ü"))); got != name+" --namespace über-namespace --context über" { + t.Errorf("Unicode cursor completion: %q", got) + } + must(t, os.Mkdir(filepath.Join(environment.root, "directory name"), 0700)) + must(t, os.WriteFile(filepath.Join(environment.root, "directory-file"), nil, 0600)) + completed := terminal.complete(name + " shell-setup install zsh directory") + var arguments []string + must(t, json.Unmarshal(environment.output("zsh", "-f", "-c", strings.Replace(completed, name, name+" __echo-args", 1)), &arguments)) + if !slices.Equal(arguments, []string{"shell-setup", "install", "zsh", "directory name/"}) { + t.Errorf("directory completion: %q (buffer %q)", arguments, completed) + } + for _, value := range [][2]string{{`'two words'`, "two words-namespace"}, {`'a'\''b'`, "a'b-namespace"}, {`'$(not-executed)'`, "$(not-executed)-namespace"}, {`''`, "-namespace"}} { + completed := terminal.complete(name + " --context " + value[0] + " --namespace ") + var argv []string + must(t, json.Unmarshal(environment.output("zsh", "-f", "-c", strings.Replace(completed, name, name+" __echo-args", 1)), &argv)) + if !slices.Equal(argv, []string{"--context", strings.TrimSuffix(value[1], "-namespace"), "--namespace", value[1]}) { + t.Errorf("literal arguments: %q (buffer %q)", argv, completed) + } + } + if got := terminal.complete(name + " --ini"); got != name+" --initial " { + t.Errorf("initial binary: %q", got) + } + environment.updateBinary() + if got := terminal.complete(name + " --up"); got != name+" --updated " { + t.Errorf("updated binary: %q", got) + } + environment.assertRequestsSkipInitialization() + before, err := os.ReadFile(profile) + must(t, err) + environment.output(environment.program, "shell-admin", "install", "zsh") + after, err := os.ReadFile(profile) + must(t, err) + if string(before) != string(after) || !strings.HasPrefix(string(after), userProfile) { + t.Fatal("reinstallation changed user startup content or duplicated the loader") + } +} + +func testZshCustomStartup(t *testing.T) { + environment := newShellEnvironment(t) + startup := filepath.Join(environment.root, "custom zsh's home") + must(t, os.MkdirAll(startup, 0700)) + quote := func(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" } + // ZDOTDIR is deliberately not exported. Installation must discover .zshenv's + // setting rather than assuming the child application's environment has it. + must(t, os.WriteFile(filepath.Join(environment.root, "home", ".zshenv"), []byte("ZDOTDIR="+quote(startup)+"\n"), 0600)) + profile := filepath.Join(startup, ".zshrc") + const existing = "autoload -Uz compinit\ncompinit\nzstyle ':completion:*' verbose yes\n" + must(t, os.WriteFile(profile, []byte(existing), 0600)) + terminal := environment.start("zsh") + name := filepath.Base(environment.program) + if got := terminal.complete(name + " --format ya"); got != name+" --format yaml " { + t.Errorf("custom ZDOTDIR completion: %q", got) + } + // A packager can also source the same generated adapter after compinit. + path := filepath.Join(environment.root, "data", "zsh", "site-functions", "_"+name) + registration := environment.output("zsh", "-f", "-c", "autoload -Uz compinit; compinit; source "+quote(path)+"; print -r -- $_comps["+name+"]") + if !strings.HasPrefix(string(registration), "_cli4g_") { + t.Errorf("sourced adapter was not registered: %q", registration) + } + environment.assertRequestsSkipInitialization() + before, err := os.ReadFile(profile) + must(t, err) + environment.output(environment.program, "shell-setup", "install", "zsh") + after, err := os.ReadFile(profile) + must(t, err) + if string(before) != string(after) || !strings.HasPrefix(string(after), existing) { + t.Fatal("custom ZDOTDIR setup was not preserved or idempotent") + } +} diff --git a/mise.toml b/mise.toml index c7e1cd4..eb0c5d5 100644 --- a/mise.toml +++ b/mise.toml @@ -96,7 +96,7 @@ env.GOWORK = "off" run = 'go test -count=1 -v ./internal/integration -run "^TestModuleArchive$" -args -module-archive' [tasks."test:shells"] -description = "Run mandatory Bash/Nushell/PowerShell terminal E2E tests in Podman (preferred) or Docker" +description = "Run mandatory Bash/Nushell/PowerShell/Zsh terminal E2E tests in Podman (preferred) or Docker" usage = ''' flag "--engine " help="Container engine: auto, podman or docker" default="auto" ''' From 2bde4afd7101edd0581188a8ea39d8b48a921e82 Mon Sep 17 00:00:00 2001 From: Gregor Noczinski Date: Tue, 20 Oct 2026 19:45:05 +0200 Subject: [PATCH 04/14] Add Fish completion and shell E2E coverage --- .github/workflows/ci.yml | 29 ++- .gitignore | 1 + README.md | 6 +- _examples/completion/README.md | 8 + completion_command.go | 10 +- completion_command_test.go | 10 +- completion_fish.fish | 38 ++++ completion_fish.go | 13 ++ completion_fish_input.go | 174 ++++++++++++++++++ completion_fish_test.go | 93 ++++++++++ completion_protocol.go | 22 ++- completion_protocol_test.go | 4 +- completion_shell.go | 42 ++++- doc.go | 23 +++ docs/development.md | 22 ++- internal/shelltestcmd/Containerfile | 6 +- internal/shelltestcmd/config.fish | 17 ++ internal/shelltestcmd/container_linux_test.go | 28 ++- internal/shelltestcmd/fish_linux_test.go | 138 ++++++++++++++ internal/shelltestcmd/main.go | 72 +++++++- internal/testdata/completion/main.go | 4 + mise.toml | 5 +- 22 files changed, 724 insertions(+), 41 deletions(-) create mode 100644 completion_fish.fish create mode 100644 completion_fish.go create mode 100644 completion_fish_input.go create mode 100644 completion_fish_test.go create mode 100644 internal/shelltestcmd/config.fish create mode 100644 internal/shelltestcmd/fish_linux_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c7d23d..eb498db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,8 +134,16 @@ jobs: - name: Set up rootless Podman uses: ./.github/actions/setup-rootless-podman - - name: Test Bash, Nushell, PowerShell and Zsh in real terminals - run: mise run test:shells --engine podman + - name: Test Bash, Fish, Nushell, PowerShell and Zsh in real terminals + run: mise run test:shells --engine podman --coverage + + - name: Store shell coverage + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: tests-shells-${{ github.run_number }} + path: profile.shells.cov + retention-days: 1 + if-no-files-found: error quality: name: Quality gate @@ -167,7 +175,7 @@ jobs: coverage: name: Coverage - needs: test + needs: [ test, shell-e2e ] runs-on: ubuntu-latest steps: - name: Checkout code @@ -176,7 +184,7 @@ jobs: - name: Download coverage profiles uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - pattern: tests-go-* + pattern: tests-* path: coverage - name: Collect coverage profiles @@ -201,6 +209,19 @@ jobs: profiles+=("$profile") done done + + shell_profiles=(coverage/tests-shells-*/profile.shells.cov) + if [[ ${#shell_profiles[@]} -eq 0 ]]; then + echo "::error::No shell E2E coverage artifact was downloaded." + exit 1 + fi + for profile in "${shell_profiles[@]}"; do + if [[ ! -s "$profile" ]] || [[ $(wc -l < "$profile") -le 1 ]]; then + echo "::error::Missing or empty shell coverage profile: $profile" + exit 1 + fi + profiles+=("$profile") + done printf 'files=%s\n' "${profiles[*]}" >> "$GITHUB_OUTPUT" - name: Send coverage diff --git a/.gitignore b/.gitignore index 42a1690..79a3b71 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ profile.cov profile.cov.full profile.examples.cov +profile.shells.cov /mise.local.toml /mise.local.lock /.mise/locks/mise.local/ diff --git a/README.md b/README.md index 3a06306..4859f9b 100644 --- a/README.md +++ b/README.md @@ -99,11 +99,13 @@ code size, and cognitive complexity. ## Shell completion -Bash **4.4+**, Nushell **0.114+**, PowerShell **5.1+**, and Zsh **5.8+** adapters query -the current binary, so binary updates need no completion reinstall. Install once with: +Bash **4.4+**, Fish **3.6+**, Nushell **0.114+**, PowerShell **5.1+**, and Zsh **5.8+** +adapters query the current binary, so binary updates need no completion reinstall. +Install once with: ```shell your-app completion install bash # Requires a configured bash-completion loader +your-app completion install fish # Autoloaded on the next completion in a new session your-app completion install nu # Autoloaded at the next interactive Nu startup your-app completion install powershell # Loaded through the user profiles on next startup your-app completion install zsh # Configures fpath/autoloading for the next Zsh session diff --git a/_examples/completion/README.md b/_examples/completion/README.md index 0fcd106..ca96ae8 100644 --- a/_examples/completion/README.md +++ b/_examples/completion/README.md @@ -58,6 +58,14 @@ ship completion install zsh # ship --context production --namespace payments deploy ch ``` +In **Fish 3.6+**: + +```fish +ship completion install fish +# Start a new Fish session, then type and press Tab: +# ship --context production --namespace payments deploy ch +``` + The standard handler completes `deploy`, flag names, the `development`/`production` context enum, and bool values automatically. The two callbacks in `inventory.go` add domain knowledge: - Namespace suggestions depend on the context and the selected service. diff --git a/completion_command.go b/completion_command.go index a1b56b5..0ba3298 100644 --- a/completion_command.go +++ b/completion_command.go @@ -26,18 +26,18 @@ var DefaultCompletionCommand CommandRequirementLike = NewCompletionCommand() var DefaultCompletionRequestCommandName = "__complete" // CompletionInstallOptions configures the standard completion install command. -// Shell is bash, nu, powershell or zsh. Directory overrides the shell's default +// Shell is bash, fish, nu, powershell or zsh. Directory overrides the shell's default // installation path; for PowerShell and Zsh, it also skips startup-file setup. // These are ordinary command options, parsed and validated through the usual CLI // lifecycle together with the selected application and command scopes. type CompletionInstallOptions struct { - Shell string `argument:"shell" enum:"bash|nu|powershell|zsh" required:"true" description:"Shell to configure."` + Shell string `argument:"shell" enum:"bash|fish|nu|powershell|zsh" required:"true" description:"Shell to configure."` Directory string `argument:"directory" type:"completion-directory" description:"Installation directory; omitted uses the shell's default."` } // NewCompletionCommand builds a fresh, ordinary command tree named "completion". -// It supports "completion " and -// "completion install [directory]". WithName, WithAliases, +// It supports "completion " and +// "completion install [directory]". WithName, WithAliases, // handlers and all other ordinary CommandRequirement configuration are available. // Parent requirements, parsing, // profiles, initialization, execution, cleanup and depth limits apply normally. @@ -47,7 +47,7 @@ type CompletionInstallOptions struct { // CompletionApplicationFromContext, which custom management commands may also use. func NewCompletionCommand() *CommandRequirement[NoOptions] { command := NewSimpleCommand("completion", "Manage shell completion.").WithHandler(completionGroupHelp) - for _, shell := range []CompletionShell{CompletionBash, CompletionNushell, CompletionPowerShell, CompletionZsh} { + for _, shell := range []CompletionShell{CompletionBash, CompletionFish, CompletionNushell, CompletionPowerShell, CompletionZsh} { command.WithCommand(NewSimpleCommand(string(shell), "Print the "+string(shell)+" adapter."). WithHandler(func(ctx context.Context, _ NoOptions) error { application, found := CompletionApplicationFromContext(ctx) diff --git a/completion_command_test.go b/completion_command_test.go index 8b77c6f..baebc86 100644 --- a/completion_command_test.go +++ b/completion_command_test.go @@ -70,18 +70,18 @@ func TestDefaultCompletionCommandIsAnOrdinaryCompiledCommand(t *testing.T) { assert.False(t, found, "request entry is not registered as a command") definition.WithName("changed") assert.Equal(t, "shell-setup", app.CompletionCommandName()) - for _, shell := range []CompletionShell{CompletionBash, CompletionNushell, CompletionPowerShell, CompletionZsh} { + for _, shell := range []CompletionShell{CompletionBash, CompletionFish, CompletionNushell, CompletionPowerShell, CompletionZsh} { var output, diagnostics bytes.Buffer require.NoError(t, app.ParseAndExecute(ContextWithStdio(nil, nil, &output, &diagnostics), nil, "shells", string(shell))) assert.Contains(t, output.String(), "__suggest") assert.Empty(t, diagnostics.String(), "ancestor group handler must not print help for a selected child") } - assert.Equal(t, []string{"initialize", "close", "initialize", "close", "initialize", "close", "initialize", "close"}, events) + assert.Equal(t, []string{"initialize", "close", "initialize", "close", "initialize", "close", "initialize", "close", "initialize", "close"}, events) var help bytes.Buffer require.NoError(t, app.ParseAndExecute(ContextWithStdio(nil, nil, &help, &help), nil, "shell-setup", "--help")) assert.Contains(t, help.String(), "tool shell-setup") assert.Contains(t, help.String(), "install") - assert.Len(t, events, 8, "ordinary --help skips initialization") + assert.Len(t, events, 10, "ordinary --help skips initialization") var output bytes.Buffer directory := t.TempDir() require.NoError(t, app.ParseAndExecute(ContextWithStdio(nil, nil, &output, &output), nil, "shell-setup", "install", "bash", directory)) @@ -182,7 +182,7 @@ func TestNormalCompletionCommandCompletesItsOwnSchema(t *testing.T) { }{ {[]string{"shell-s"}, []string{"shell-setup"}, false}, {[]string{"__s"}, []string{}, false}, - {[]string{"shell-setup", ""}, []string{"bash", "install", "nu", "powershell", "zsh"}, false}, + {[]string{"shell-setup", ""}, []string{"bash", "fish", "install", "nu", "powershell", "zsh"}, false}, {[]string{"shell-setup", "install", "n"}, []string{"nu"}, false}, {[]string{"shell-setup", "install", "bash", "dir"}, []string{}, true}, } { @@ -194,7 +194,7 @@ func TestNormalCompletionCommandCompletesItsOwnSchema(t *testing.T) { renamed := NewSimpleApplication("tool").WithCompletion(true). WithCompletionCommand(NewCompletionCommand().WithName("shell-admin")).WithCompletionRequestCommandName("__suggest").MustCreate() defer func() { assert.NoError(t, renamed.Close()) }() - for _, shell := range []CompletionShell{CompletionBash, CompletionNushell, CompletionPowerShell, CompletionZsh} { + for _, shell := range []CompletionShell{CompletionBash, CompletionFish, CompletionNushell, CompletionPowerShell, CompletionZsh} { before, err := app.CompletionScript(shell) require.NoError(t, err) after, err := renamed.CompletionScript(shell) diff --git a/completion_fish.fish b/completion_fish.fish new file mode 100644 index 0000000..667a02a --- /dev/null +++ b/completion_fish.fish @@ -0,0 +1,38 @@ +function __@FUNCTION@ + # Only the executable is taken from Fish's token output. The raw process and + # cursor prefix preserve empty/multiline arguments and words after the cursor. + set -l program + for token in (commandline -opc) + if contains -- "$token" command exec; or string match -qr '^[A-Za-z_][A-Za-z0-9_]*=' -- "$token" + continue + end + set program "$token" + break + end + test -n "$program"; or return + set -l process (commandline -p | string collect --allow-empty --no-trim-newlines) + set -l prefix (commandline -cp | string collect --allow-empty --no-trim-newlines) + set -l response ("$program" @FISHREQUEST@ 1 fish "$prefix" "$process" 2>/dev/null | string split0) + test (count $response) -ge 6; and test "$response[1]" = 1; or return + set -l i 7 + while test $i -lt (count $response) + set -l next (math $i + 1) + set -l description (string replace --all --regex '[\t\r\n]' ' ' -- "$response[$next]" | string collect --allow-empty) + printf '%s\t%s\0' "$response[$i]" "$description" + set i (math $i + 2) + end + if test "$response[2]" != 0 + set -l search (string escape -- "$response[6]") + for path in (complete -C "'' $search") + set -l fields (string split --max 1 \t -- "$path") + if test "$response[2]" = 2; and not string match -q '*/' -- "$fields[1]" + continue + end + printf '%s%s\0' "$response[5]" "$path" + end + end +end +complete --command @FISHCOMMAND@ --erase +# The core already sorts unless KeepOrder is set. A single rule needs one query, +# with no persistent result cache or condition-dependent repeat invocations. +complete --command @FISHCOMMAND@ --no-files --keep-order --arguments '(__@FUNCTION@ | string split0)' diff --git a/completion_fish.go b/completion_fish.go new file mode 100644 index 0000000..556fdc6 --- /dev/null +++ b/completion_fish.go @@ -0,0 +1,13 @@ +package cli + +import ( + _ "embed" + "strings" +) + +//go:embed completion_fish.fish +var fishCompletionScript string + +func quoteFish(value string) string { + return "'" + strings.NewReplacer("\\", "\\\\", "'", "\\'").Replace(value) + "'" +} diff --git a/completion_fish_input.go b/completion_fish_input.go new file mode 100644 index 0000000..d203cdb --- /dev/null +++ b/completion_fish_input.go @@ -0,0 +1,174 @@ +package cli + +import ( + "fmt" + "strconv" + "strings" +) + +// Fish supplies the native process boundaries. This best-effort lexer removes +// literal quoting/escapes, retaining expressions as data without evaluating them. +func fishCompletionRequest(line string, point int) (CompletionRequest, error) { + if point < 0 || point > len(line) { + return CompletionRequest{}, fmt.Errorf("invalid Fish completion cursor") + } + var words []string + current, offset := -1, 0 + for i := 0; i < len(line); { + if strings.ContainsRune(";|&\n", rune(line[i])) { + if i >= point { + break + } + words, current = nil, -1 + i++ + continue + } + if strings.ContainsRune(" \t\r", rune(line[i])) { + if i >= point && current < 0 { + current, offset = len(words), 0 + words = append(words, "") + } + i++ + continue + } + start, prefix := i, 0 + var word strings.Builder + var quote byte + for i < len(line) { + char := line[i] + if quote == 0 && strings.ContainsRune(" \t\r\n;|&", rune(char)) { + break + } + switch { + case char == '\\' && i+1 < len(line) && (quote == 0 || strings.ContainsRune("\\'", rune(line[i+1])) && quote == '\'' || strings.ContainsRune("\\\"$\n", rune(line[i+1])) && quote == '"'): + value, size := fishCompletionEscape(line[i:], quote) + word.WriteString(value) + i += size + case (char == '\'' || char == '"') && (quote == 0 || quote == char): + if quote == 0 { + quote = char + } else { + quote = 0 + } + i++ + case char == '(' && quote == 0: + end := fishCompletionExpressionEnd(line, i) + if point >= i && point < end { + prefix = word.Len() + point - i + } + word.WriteString(line[i:end]) + i = end + default: + word.WriteByte(char) + i++ + } + if i <= point { + prefix = word.Len() + } + } + if point >= start && point <= i { + current, offset = len(words), prefix + } + words = append(words, word.String()) + } + if current < 0 { + current, offset = len(words), 0 + words = append(words, "") + } + command := 0 + for command < len(words) && (words[command] == "command" || words[command] == "exec" || fishCompletionAssignment(words[command])) { + command++ + } + command++ // Exclude the executable as well as decorators/leading assignments. + if current < command || command > len(words) { + return CompletionRequest{}, fmt.Errorf("fish completion has no argument target") + } + return CompletionRequest{Words: words[command:], Word: current - command, Offset: offset}, nil +} + +func fishCompletionEscape(input string, quote byte) (string, int) { + char := input[1] + if char == '\n' { + return "", 2 + } + if quote != 0 { + return string(char), 2 + } + if index := strings.IndexByte("abefnrtv", char); index >= 0 { + return string("\a\b\x1b\f\n\r\t\v"[index]), 2 + } + if char == 'c' && len(input) >= 3 { + return string(input[2] & 0x1f), 3 + } + base, begin, limit := 16, 2, 0 + switch char { + case 'x', 'X': + limit = 2 + case 'u': + limit = 4 + case 'U': + limit = 8 + default: + if char >= '0' && char <= '7' { + base, begin, limit = 8, 1, 3 + } + } + end := begin + for end < len(input) && end < begin+limit { + if _, err := strconv.ParseUint(input[end:end+1], base, 8); err != nil { + break + } + end++ + } + if end > begin { + if value, err := strconv.ParseUint(input[begin:end], base, 32); err == nil { + if char == 'x' || char == 'X' || base == 8 { + return string([]byte{byte(value)}), end + } + return string(rune(value)), end + } + } + return string(char), 2 +} + +func fishCompletionExpressionEnd(line string, start int) int { + depth := 0 + var quote byte + for i := start; i < len(line); i++ { + char := line[i] + if char == '\\' { + i++ + continue + } + if char == '\'' || char == '"' { + if quote == 0 { + quote = char + } else if quote == char { + quote = 0 + } + } else if quote == 0 { + if char == '(' { + depth++ + } else if char == ')' { + depth-- + if depth == 0 { + return i + 1 + } + } + } + } + return len(line) +} + +func fishCompletionAssignment(word string) bool { + name, _, found := strings.Cut(word, "=") + if !found || name == "" { + return false + } + for i, char := range name { + if char != '_' && !(char >= 'a' && char <= 'z') && !(char >= 'A' && char <= 'Z') && !(i > 0 && char >= '0' && char <= '9') { + return false + } + } + return true +} diff --git a/completion_fish_test.go b/completion_fish_test.go new file mode 100644 index 0000000..31338f2 --- /dev/null +++ b/completion_fish_test.go @@ -0,0 +1,93 @@ +package cli + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/echocat/cli4g/codec" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFishCompletionInputPreservesLiteralWordsAndCursor(t *testing.T) { + for _, test := range []struct { + line string + want CompletionRequest + }{ + {"tool ", completionAtEnd("")}, + {"tool --format=ya", completionAtEnd("--format=ya")}, + {`tool --format 'two wo`, completionAtEnd("--format", "two wo")}, + {`tool --format="two wo`, completionAtEnd("--format=two wo")}, + {`tool --context '' --namespace `, completionAtEnd("--context", "", "--namespace", "")}, + {`tool --context 'a\'b' --namespace `, completionAtEnd("--context", "a'b", "--namespace", "")}, + {`tool --context "a\"b" --namespace `, completionAtEnd("--context", `a"b`, "--namespace", "")}, + {`tool --context a\ b --namespace `, completionAtEnd("--context", "a b", "--namespace", "")}, + {`tool --context a\nb --namespace `, completionAtEnd("--context", "a\nb", "--namespace", "")}, + {`tool --context '\n' --namespace `, completionAtEnd("--context", `\n`, "--namespace", "")}, + {`tool --context "\n" --namespace `, completionAtEnd("--context", `\n`, "--namespace", "")}, + {`tool --context \u00fcber --namespace `, completionAtEnd("--context", "über", "--namespace", "")}, + {`tool --context \U0001f600 --namespace `, completionAtEnd("--context", "😀", "--namespace", "")}, + {`tool --context \x61\141\cb --namespace `, completionAtEnd("--context", "aa\x02", "--namespace", "")}, + {"tool --context a\\\nb --namespace ", completionAtEnd("--context", "ab", "--namespace", "")}, + {"tool --context 'a\nb' --namespace ", completionAtEnd("--context", "a\nb", "--namespace", "")}, + {`tool --context (echo "a b") --namespace `, completionAtEnd("--context", `(echo "a b")`, "--namespace", "")}, + {`tool --context (echo (echo a)) --namespace `, completionAtEnd("--context", `(echo (echo a))`, "--namespace", "")}, + {`tool --context $cluster --namespace `, completionAtEnd("--context", "$cluster", "--namespace", "")}, + {`command tool --context cluster-b --namespace cl`, completionAtEnd("--context", "cluster-b", "--namespace", "cl")}, + {`CONTEXT=cluster-b exec tool --namespace cl`, completionAtEnd("--namespace", "cl")}, + {"ignored | tool --format ü", completionAtEnd("--format", "ü")}, + } { + t.Run(test.line, func(t *testing.T) { + request, err := fishCompletionRequest(test.line, len(test.line)) + require.NoError(t, err) + assert.Equal(t, test.want, request) + }) + } + line := "tool --namespace üsuffix --context über" + request, err := fishCompletionRequest(line, len("tool --namespace ü")) + require.NoError(t, err) + assert.Equal(t, CompletionRequest{Words: []string{"--namespace", "üsuffix", "--context", "über"}, Word: 1, Offset: 2}, request) + _, err = fishCompletionRequest(line, -1) + require.Error(t, err) + _, err = fishCompletionRequest(line, len(line)+1) + require.Error(t, err) + _, err = fishCompletionRequest("tool", 4) + require.Error(t, err) +} + +func TestFishCompletionProtocolPreservesNewlinesAndSuppressesInvalidRequests(t *testing.T) { + var input codec.CompletionInput + app := NewSimpleApplication("tool").WithCompletion(true). + WithInitializeHandler(func(context.Context, NoOptions) error { panic("completion must skip initialization") }). + WithFlag(NewFlag(codec.WithCompletionHandler(codec.NewString(), func(_ context.Context, value codec.CompletionInput) (codec.CompletionResult, error) { + input = value + return codec.CompletionResult{Handled: true, NoSpace: true, KeepOrder: true, Candidates: []codec.CompletionCandidate{ + {Value: "a\nb-value", Description: "First\nsecond"}, {Value: "a\nb\tunsupported"}, + }}, nil + }), "value")).MustCreate() + defer func() { assert.NoError(t, app.Close()) }() + var output, diagnostics bytes.Buffer + ctx := ContextWithStdio(nil, nil, &output, &diagnostics) + line := "tool --value 'a\nb' --later" + prefix := "tool --value 'a\nb" + require.NoError(t, app.ParseAndExecute(ctx, nil, "__complete", "1", "fish", prefix+"\n", line+"\n")) + assert.Equal(t, []string{"--value", "a\nb", "--later"}, input.Words) + assert.Equal(t, "a\nb", input.Prefix) + assert.Equal(t, []string{"1", "0", "1", "1", "", "", "a\nb-value", "First\nsecond", ""}, strings.Split(output.String(), "\x00")) + assert.Empty(t, diagnostics.String()) + output.Reset() + // A real newline immediately before the cursor survives the separate printing + // newline appended by Fish's commandline builtin. + require.NoError(t, app.ParseAndExecute(ctx, nil, "__complete", "1", "fish", "tool --value 'a\n\n", line+"\n")) + assert.Equal(t, "a\n", input.Prefix) + assert.Equal(t, "b", input.Suffix) + assert.Contains(t, output.String(), "a\nb-value\x00") + for _, args := range [][]string{{"1", "fish"}, {"1", "fish", "different\n", "tool --value a\n"}, {"1", "fish", "tool\n", "tool\n"}} { + output.Reset() + require.NoError(t, app.ParseAndExecute(ctx, nil, append([]string{"__complete"}, args...)...)) + assert.Equal(t, "1\x000\x000\x000\x00\x00\x00", output.String()) + assert.Empty(t, diagnostics.String()) + } +} diff --git a/completion_protocol.go b/completion_protocol.go index 7c4f1a7..d288976 100644 --- a/completion_protocol.go +++ b/completion_protocol.go @@ -43,6 +43,13 @@ import ( // 1 files, 2 directories), NoSpace (0/1), KeepOrder (0/1), FilePrefix, FileInput, // then alternating candidate values and descriptions. Zsh handles quoting and // native paths. Empty words are preserved as native process arguments. +// - __complete 1 fish PREFIX LINE: LINE is Fish's complete current process; +// PREFIX is its raw portion before the cursor. Both include commandline's +// final printing newline, which the receiver removes exactly once. This +// preserves actual trailing newlines and derives UTF-8 byte offsets directly. +// Simple Fish quotes/escapes are decoded without evaluating expressions. +// Output uses the same NUL fields as zsh. Fish omits candidates containing +// tabs, which its completion value/description interface cannot represent. // // New transports may be added without changing existing transports. Query errors // become authoritative empty responses with no diagnostics; direct Complete @@ -68,8 +75,17 @@ func (this *Application[O]) queryCompletion(ctx context.Context, opts *ParseOpts output = io.Discard } switch CompletionShell(args[1]) { - case CompletionZsh: - if len(args) >= 4 { + case CompletionZsh, CompletionFish: + if CompletionShell(args[1]) == CompletionFish && len(args) == 4 { + prefix, line := strings.TrimSuffix(args[2], "\n"), strings.TrimSuffix(args[3], "\n") + if strings.HasPrefix(line, prefix) { + if request, err := fishCompletionRequest(line, len(prefix)); err == nil { + if completed, err := this.Complete(ctx, opts, request); err == nil { + result = completed + } + } + } + } else if CompletionShell(args[1]) == CompletionZsh && len(args) >= 4 { word, err := strconv.Atoi(args[2]) words := args[4:] if err == nil && word >= 0 && word < len(words) && strings.HasPrefix(words[word], args[3]) { @@ -89,7 +105,7 @@ func (this *Application[O]) queryCompletion(ctx context.Context, opts *ParseOpts return err } for _, candidate := range result.Candidates { - if strings.ContainsRune(candidate.Value, 0) { + if strings.ContainsRune(candidate.Value, 0) || CompletionShell(args[1]) == CompletionFish && strings.ContainsRune(candidate.Value, '\t') { continue } if _, err := fmt.Fprintf(output, "%s%c%s%c", candidate.Value, 0, strings.ReplaceAll(candidate.Description, "\x00", " "), 0); err != nil { diff --git a/completion_protocol_test.go b/completion_protocol_test.go index f2e1ed0..98c29e4 100644 --- a/completion_protocol_test.go +++ b/completion_protocol_test.go @@ -53,7 +53,7 @@ func TestCompletionProtocolRejectsMalformedRequestsQuietly(t *testing.T) { func TestCompletionInstallationIsIdempotentAndProtectsExistingFiles(t *testing.T) { app := NewSimpleApplication("tool").WithCompletionRequestCommandName(DefaultCompletionRequestCommandName).MustCreate() defer func() { assert.NoError(t, app.Close()) }() - for _, shell := range []CompletionShell{CompletionBash, CompletionNushell, CompletionPowerShell, CompletionZsh} { + for _, shell := range []CompletionShell{CompletionBash, CompletionFish, CompletionNushell, CompletionPowerShell, CompletionZsh} { directory := t.TempDir() path, err := app.InstallCompletion(nil, shell, directory) require.NoError(t, err) @@ -74,7 +74,7 @@ func TestCompletionInstallationIsIdempotentAndProtectsExistingFiles(t *testing.T directory, err := completionDirectory(context.Background(), CompletionBash) require.NoError(t, err) assert.Equal(t, filepath.Join(os.Getenv("BASH_COMPLETION_USER_DIR"), "completions"), directory) - _, err = app.CompletionScript("fish") + _, err = app.CompletionScript("unsupported-shell") require.Error(t, err) _, err = NewSimpleApplication("../bad").MustCreate().InstallCompletion(nil, CompletionBash, t.TempDir()) require.Error(t, err) diff --git a/completion_shell.go b/completion_shell.go index 8b10fa4..89c7e28 100644 --- a/completion_shell.go +++ b/completion_shell.go @@ -16,6 +16,7 @@ type CompletionShell string const ( CompletionBash CompletionShell = "bash" // Bash 4.4+; bash-completion enables on-demand loading. + CompletionFish CompletionShell = "fish" // Fish 3.6+ with command-specific completion autoloading. CompletionNushell CompletionShell = "nu" // Nushell 0.114+ with command-wide completers. CompletionPowerShell CompletionShell = "powershell" // Windows PowerShell 5.1+ and PowerShell 7+. CompletionZsh CompletionShell = "zsh" // Zsh 5.8+ with its native completion system. @@ -57,6 +58,20 @@ const completionScriptHeader = "# Generated by cli4g shell completion protocol v // and honors NoSpace/KeepOrder; _files provides native path completion. No global // widgets, styles, aliases or external completers are replaced. // +// Fish autoloads Name.fish when the command is first completed, or users can +// source the adapter. It replaces only Name's completion definitions. Each query +// sends the complete current process and its cursor prefix; simple Fish quoting +// and escapes are decoded without evaluating substitutions, variables or globs. +// Empty/multiline arguments and tokens after the cursor are retained. Fish quotes +// literal candidates, displays descriptions, preserves the engine's sorted or +// KeepOrder result, and completes paths natively. Fish controls space insertion: +// punctuation such as "=" or "/" suppresses a space, but its public completion API +// cannot force NoSpace for arbitrary values. Its native suffix matching may reject +// suggestions that conflict with text after the cursor inside the same token. +// Candidate tabs cannot be represented by Fish's value/description interface and +// are omitted; multiline values use NUL-framed records, and descriptions are +// rendered on one line. Other shells' transport formats are independent. +// // Bash recognizes simple quotes, escapes, and separators but deliberately does // not evaluate shell substitutions or expansions while collecting dependency // values. Applications requiring already-expanded values may call Complete with @@ -74,6 +89,8 @@ func (this *Application[O]) CompletionScript(shell CompletionShell) (string, err switch shell { case CompletionBash: template = bashCompletionScript + case CompletionFish: + template = fishCompletionScript case CompletionNushell: template = nushellCompletionScript case CompletionPowerShell: @@ -81,7 +98,7 @@ func (this *Application[O]) CompletionScript(shell CompletionShell) (string, err case CompletionZsh: template = zshCompletionScript default: - return "", NewValidationError("supported completion shells are bash, nu, powershell and zsh") + return "", NewValidationError("supported completion shells are bash, fish, nu, powershell and zsh") } header := completionScriptHeader if shell == CompletionPowerShell { @@ -99,6 +116,8 @@ func (this *Application[O]) CompletionScript(shell CompletionShell) (string, err "@PSEXE@", quotePowerShell(strings.TrimSuffix(this.Name(), ".exe")+".exe"), "@PSREQUEST@", quotePowerShell(this.CompletionRequestCommandName()), "@ZSHAUTOLOAD@", "'"+strings.ReplaceAll("_"+this.Name(), "'", "'\\''")+"'", + "@FISHCOMMAND@", quoteFish(this.Name()), + "@FISHREQUEST@", quoteFish(this.CompletionRequestCommandName()), ).Replace(template), nil } @@ -138,6 +157,14 @@ func (this *Application[O]) CompletionScript(shell CompletionShell) (string, err // content and completion styles are preserved. An explicit directory writes only // the autoload file, for a packager's fpath or manual setup. Source it after // compinit to activate it in an existing session. +// +// Fish installs Name.fish in the installed fish's user configuration directory +// (__fish_config_dir/completions, normally XDG_CONFIG_HOME/fish/completions or +// ~/.config/fish/completions). Discovery runs with --no-config only during +// installation. Fish loads the file on demand; config.fish and universal variables +// are not modified. An explicit directory needs no installed fish and supports +// packaging/custom fish_complete_path entries. Source the file for an existing +// session whose command completions were already loaded. func (this *Application[O]) InstallCompletion(ctx context.Context, shell CompletionShell, directory string) (string, error) { if ctx == nil { ctx = context.Background() @@ -176,6 +203,8 @@ func (this *Application[O]) InstallCompletion(ctx context.Context, shell Complet name := this.Name() + ".bash" if shell == CompletionNushell { name = "cli4g-" + this.Name() + ".nu" + } else if shell == CompletionFish { + name = this.Name() + ".fish" } else if shell == CompletionPowerShell { name = "cli4g-" + this.Name() + ".ps1" } else if shell == CompletionZsh { @@ -245,6 +274,17 @@ func replaceCompletionFile(ctx context.Context, path string, data []byte, mode o } func completionDirectory(ctx context.Context, shell CompletionShell) (string, error) { + if shell == CompletionFish { + output, err := exec.CommandContext(ctx, "fish", "--no-config", "-c", "printf '%s' $__fish_config_dir").Output() + if err != nil { + return "", fmt.Errorf("could not discover Fish completion directory: %w", err) + } + path := strings.TrimSpace(string(output)) + if !filepath.IsAbs(path) || strings.ContainsAny(path, "\r\n\x00") { + return "", fmt.Errorf("fish returned no absolute configuration directory") + } + return filepath.Join(path, "completions"), nil + } if shell == CompletionNushell { output, err := exec.CommandContext(ctx, "nu", "-n", "-c", "$nu.user-autoload-dirs | first").Output() if err != nil { diff --git a/doc.go b/doc.go index 92c61c7..b610da1 100644 --- a/doc.go +++ b/doc.go @@ -138,6 +138,7 @@ // Users install a native adapter once, without editing their shell configuration: // // tool completion install bash +// tool completion install fish // tool completion install nu // tool completion install powershell // tool completion install zsh @@ -189,6 +190,28 @@ // ZLE settings control whether completion operates inside a word (COMPLETE_IN_WORD) // and how menu selection inserts spaces. The adapter retains those settings. // +// Fish 3.6+ autoloads Name.fish from its user completion directory, normally +// XDG_CONFIG_HOME/fish/completions or ~/.config/fish/completions. The installer +// discovers __fish_config_dir with fish --no-config, only during installation. +// It leaves config.fish and universal variables untouched. Source the returned +// file to refresh an existing session that already loaded command completions. +// Explicit directories support packaging and custom fish_complete_path entries. +// Loading the adapter starts no application process; only Name's completion +// definitions are replaced, with no changes to global widgets or other commands. +// +// Each Fish query transmits the complete current process and its cursor prefix, +// preserving empty/multiline arguments, simple quotes/escapes, and arguments after +// the cursor. Variables, globs and substitutions remain literal dependency data; +// they are never evaluated by the collector. Fish quotes inserted candidates, +// displays descriptions, completes paths natively, and preserves KeepOrder (the +// engine sorts other candidate results). Values use NUL-framed records; tabs in +// candidate values are omitted because Fish reserves the tab for descriptions, +// and multiline descriptions are displayed on one line. Fish controls whitespace: +// it omits spaces after punctuation such as "=" and "/", but its public completion +// API cannot force NoSpace on arbitrary values. Fish also retains native word- +// suffix matching when completing inside a token. See [Application.CompletionScript] +// and [CompletionProtocolVersion] for the adapter and transport contracts. +// // ApplicationRequirement.CompletionCommand is an ordinary CommandRequirementLike. // Nil means absent. Create compiles it alongside Commands without modifying the // builder list or shared default. It uses the same parsing, inherited inputs, diff --git a/docs/development.md b/docs/development.md index 5b7c3b1..de7bd9b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -19,7 +19,7 @@ mise run check:full | `mise run test --coverage` | Go and example coverage | | `mise run test:matrix` | Minimum and stable Go versions | | `mise run test:module` | Test the distributable module archive without nested modules or Git metadata | -| `mise run test:shells` | Mandatory interactive Bash/Nushell/PowerShell/Zsh E2E tests in Linux containers | +| `mise run test:shells` | Mandatory interactive Bash/Fish/Nushell/PowerShell/Zsh E2E tests in Linux containers | | `mise run test:fuzz` | Bounded parser fuzzing | | `mise run test:benchmarks` | Check comparison adapters and their module | | `mise run bench` | Measure CLI lifecycle comparisons and update the results page | @@ -39,6 +39,7 @@ Windows, start the runtime's Linux VM first. mise run test:shells mise run test:shells --engine podman mise run test:shells --engine docker +mise run test:shells --coverage ``` The runner cross-builds static Linux fixture binaries with the project's Go @@ -49,15 +50,16 @@ The test containers have no network access; image building needs registry/packag access. Containers and temporary files are cleaned up; image layers remain cached. The Containerfiles pin Bash **5.2.37**, Nushell **0.114.1**, and PowerShell **7.5.0** -images by manifest digest and Zsh **5.9** through Alpine package `zsh=5.9-r5`. +images by manifest digest, Fish **4.0.2** through Alpine package `fish=4.0.2-r0`, +and Zsh **5.9** through `zsh=5.9-r5`. PowerShell runs separately on Ubuntu (amd64) or Azure Linux (arm64), keeping its native .NET environment. The runner cross-compiles a Go test binary with the `shell_e2e` build tag. Its Go PTY driver uses Linux terminal ioctls from the existing `golang.org/x/sys` dependency and embedded shell setup scripts. It installs adapters through the -real CLI command, starts interactive Bash/Readline, Nu/Reedline, PowerShell/PSReadLine, and Zsh/ZLE, +real CLI command, starts interactive Bash/Readline, Fish, Nu/Reedline, PowerShell/PSReadLine, and Zsh/ZLE, presses Tab, and reads the line-editor buffer. It verifies on-demand Bash loading, -Nu/Zsh autoload, PowerShell profile loading/preservation, quotes, Unicode, paths, context-dependent values, real `compopt` +Fish/Nu/Zsh autoload, PowerShell profile loading/preservation, quotes, Unicode, paths, context-dependent values, real `compopt` NoSpace/KeepOrder behavior, cursor edits, and binary replacement without adapter reinstallation. Initialization markers prove that requests bypass normal startup. PowerShell tests also execute completed arguments in both Standard and Legacy @@ -66,8 +68,17 @@ Windows PowerShell 5.1 itself is not part of this Linux E2E suite. Zsh tests cover native menus/KeepOrder, NoSpace, mid-word and Unicode cursor edits, directory-only completion, preserved startup content/styles, existing compinit, non-exported custom ZDOTDIR settings, and sourcing the generated autoload file. +Fish tests cover descriptions/order in the native completion API and actual pager, +empty/multiline values, escapes, non-evaluated substitutions, native suffix matching, +directory-only paths, command decorators, preserved config, and binary replacement. Missing/wrong-version shells and missing runtimes are errors, never skips. +`--coverage` instruments the actual fixture binaries, collects their Go runtime +coverage from both stopped containers using the engine's copy command, and merges +it into `profile.shells.cov` with Go's `covdata` tooling. CI includes this artifact +in the same Coveralls upload as the Go matrix and example profiles, so native +shell execution contributes to the reported library coverage. + The lighter `TestNativeShellCompletion` Go test still uses locally available shells and can skip unavailable ones. The separate container suite is the required CI guarantee and also runs in the dependency-update quality gate. @@ -93,6 +104,7 @@ complete repository. - Go coverage: `profile.cov`. - Example-binary coverage: `profile.examples.cov`, including library calls. +- Shell-fixture coverage: `profile.shells.cov`, including the real adapter queries and installation paths. - `CLI4G_CI=1` enables race/verbose/coverage for Go tests and binary coverage for examples; Go tests then write `profile.cov.full`. @@ -100,7 +112,7 @@ complete repository. - Go linting: Linux, macOS, Windows. - Runtime tests: minimum/stable Go on each OS, including example binaries. -- Shell E2E: a required Linux job using rootless Podman and pinned Bash/Nushell/PowerShell/Zsh versions. +- Shell E2E: a required Linux job using rootless Podman and pinned Bash/Fish/Nushell/PowerShell/Zsh versions. - Shared checks: once on Linux; formats, module archives, workflows, example code, benchmark-adapter correctness, fuzzing, and vulnerabilities. Timing measurements are run separately on an idle machine, not in CI. diff --git a/internal/shelltestcmd/Containerfile b/internal/shelltestcmd/Containerfile index 1474018..3c3679a 100644 --- a/internal/shelltestcmd/Containerfile +++ b/internal/shelltestcmd/Containerfile @@ -1,11 +1,11 @@ -# Bash 5.2.37, Nushell 0.114.1 and Zsh 5.9 / Alpine 3.22; static Go test binaries. +# Bash 5.2.37, Fish 4.0.2, Nushell 0.114.1 and Zsh 5.9 / Alpine 3.22; static Go tests. # Pin image manifests so rebuilding cannot silently change the tested shells. FROM ghcr.io/nushell/nushell:0.114.1-alpine@sha256:278ba1af47dfba2754be3f86d395f45a7c850f8a011f8ed921fa4093642257f3 AS nu FROM docker.io/library/bash:5.2.37@sha256:a54fb4422b18f05dd3107c36f39d67b26334fda7ec89f4126052b45e228e2f15 -RUN apk add --no-cache bash-completion ncurses-terminfo-base zsh=5.9-r5 +RUN apk add --no-cache bash-completion ncurses-terminfo-base fish=4.0.2-r0 zsh=5.9-r5 COPY --from=nu /usr/bin/nu /usr/local/bin/nu COPY fixture-initial fixture-updated /opt/fixtures/ COPY shell-e2e /opt/tests/shell-e2e -RUN bash --version && nu --version && zsh --version +RUN bash --version && fish --version && nu --version && zsh --version ENTRYPOINT ["/opt/tests/shell-e2e", "-test.v", "-test.run=^TestContainerShellCompletion$", "-test.timeout=2m"] diff --git a/internal/shelltestcmd/config.fish b/internal/shelltestcmd/config.fish new file mode 100644 index 0000000..3682468 --- /dev/null +++ b/internal/shelltestcmd/config.fish @@ -0,0 +1,17 @@ +set -g fish_greeting +set -g fish_autosuggestion_enabled 0 +function fish_prompt + printf 'E2E> ' +end +function fish_right_prompt +end +fish_default_key_bindings +function __e2e_dump + printf '\n__CLI4G_BUFFER__' + commandline --current-buffer | cli4g-completion-test __quote-stdin --trim-newline + printf '__CLI4G_END__\n' + commandline --replace '' + commandline --function execute +end +bind \co __e2e_dump +printf '__CLI4G_READY__\n' diff --git a/internal/shelltestcmd/container_linux_test.go b/internal/shelltestcmd/container_linux_test.go index ffa6588..eb950a7 100644 --- a/internal/shelltestcmd/container_linux_test.go +++ b/internal/shelltestcmd/container_linux_test.go @@ -13,7 +13,7 @@ import ( "testing" ) -//go:embed bash.sh config.nu powershell.ps1 zsh.zsh +//go:embed bash.sh config.fish config.nu powershell.ps1 zsh.zsh var shellAssets embed.FS type shellEnvironment struct { @@ -26,6 +26,9 @@ type shellEnvironment struct { func newShellEnvironment(t *testing.T) *shellEnvironment { t.Helper() + if coverage := os.Getenv("GOCOVERDIR"); coverage != "" { + must(t, os.MkdirAll(coverage, 0700)) + } root := t.TempDir() bin := filepath.Join(root, "bin") must(t, os.MkdirAll(bin, 0700)) @@ -106,6 +109,20 @@ func (environment *shellEnvironment) start(shell string) *terminal { terminal.prompt() return terminal } + if shell == "fish" { + setup, err := shellAssets.ReadFile("config.fish") + must(environment.t, err) + profile := filepath.Join(environment.root, "config", "fish", "config.fish") + file, err := os.OpenFile(profile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + must(environment.t, err) + _, err = file.Write(setup) + must(environment.t, err) + must(environment.t, file.Close()) + terminal := startTerminal(environment.t, "fish", []string{"--interactive", "--private"}, environment.env, environment.root) + terminal.expect("__CLI4G_READY__\r?\n") + terminal.prompt() + return terminal + } path := filepath.Join(environment.root, "config", "nushell", "config.nu") must(environment.t, os.MkdirAll(filepath.Dir(path), 0700)) config, err := shellAssets.ReadFile("config.nu") @@ -131,10 +148,12 @@ func TestContainerShellCompletion(t *testing.T) { must(t, err) zsh, err := exec.Command("zsh", "--version").CombinedOutput() must(t, err) - if !strings.Contains(string(bash), "version 5.2.37") || strings.TrimSpace(string(nu)) != "0.114.1" || !strings.HasPrefix(string(zsh), "zsh 5.9 ") { - t.Fatalf("unexpected shell versions: %q, %q, %q", bash, nu, zsh) + fish, err := exec.Command("fish", "--version").CombinedOutput() + must(t, err) + if !strings.Contains(string(bash), "version 5.2.37") || strings.TrimSpace(string(nu)) != "0.114.1" || !strings.HasPrefix(string(zsh), "zsh 5.9 ") || strings.TrimSpace(string(fish)) != "fish, version 4.0.2" { + t.Fatalf("unexpected shell versions: %q, %q, %q, %q", bash, nu, zsh, fish) } - t.Log("Testing Bash 5.2.37, Nushell 0.114.1 and Zsh 5.9 in actual terminals") + t.Log("Testing Bash 5.2.37, Fish 4.0.2, Nushell 0.114.1 and Zsh 5.9 in actual terminals") t.Run("bash_readline", func(t *testing.T) { environment := newShellEnvironment(t) terminal := environment.start("bash") @@ -210,4 +229,5 @@ func TestContainerShellCompletion(t *testing.T) { }) t.Run("zsh_zle", testZshCompletion) t.Run("zsh_custom_zdotdir", testZshCustomStartup) + t.Run("fish_reader", testFishCompletion) } diff --git a/internal/shelltestcmd/fish_linux_test.go b/internal/shelltestcmd/fish_linux_test.go new file mode 100644 index 0000000..bee208d --- /dev/null +++ b/internal/shelltestcmd/fish_linux_test.go @@ -0,0 +1,138 @@ +//go:build linux && shell_e2e + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +func testFishCompletion(t *testing.T) { + environment := newShellEnvironment(t) + profile := filepath.Join(environment.root, "config", "fish", "config.fish") + must(t, os.MkdirAll(filepath.Dir(profile), 0700)) + const userProfile = "set -gx CLI4G_PROFILE_SENTINEL kept\n" + must(t, os.WriteFile(profile, []byte(userProfile), 0600)) + must(t, os.WriteFile(filepath.Join(environment.root, "nope-file"), nil, 0600)) + terminal := environment.start("fish") + name := filepath.Base(environment.program) + quote := func(value string) string { + return "'" + strings.NewReplacer("\\", "\\\\", "'", "\\'").Replace(value) + "'" + } + for _, value := range [][2]string{ + {" --format ya", " --format yaml "}, {" --format=ya", " --format=yaml "}, {" --format ü", " --format über "}, + {` --format "two wo`, ` --format "two words" `}, {" get po", " get pods "}, + {" --context cluster-b --namespace cl", " --context cluster-b --namespace cluster-b-namespace "}, + {" --input=config.y", " --input=config.yaml "}, {` --input="space fi`, ` --input="space file.yaml" `}, + {" --input=colon:fi", " --input=colon:file.yaml "}, {" --selector en", " --selector env="}, + {" --format nope", " --format nope"}, + } { + if got := terminal.complete(name + value[0]); got != name+value[1] { + t.Errorf("complete %q: got %q, want %q", value[0], got, name+value[1]) + } + } + line := name + " --namespace cl --context cluster-b" + if got := terminal.buffer(line, "\t", len(name+" --namespace cl")); got != name+" --namespace cluster-b-namespace --context cluster-b" { + t.Errorf("cursor completion: %q", got) + } + line = name + " --namespace ü --context über" + if got := terminal.buffer(line, "\t", len([]rune(name+" --namespace ü"))); got != name+" --namespace über-namespace --context über" { + t.Errorf("Unicode cursor completion: %q", got) + } + line = name + " --namespace cluster-a-namespace --context cluster-b" + if got := terminal.buffer(line, "\t", len(name+" --namespace cl")); got != line { + t.Errorf("Fish must retain a conflicting word suffix: %q", got) + } + line = name + " --namespace cl-namespace --context cluster-b" + if got := terminal.buffer(line, "\t", len(name+" --namespace cl")); got != name+" --namespace cluster-b-namespace --context cluster-b" { + t.Errorf("compatible mid-word suffix completion: %q", got) + } + if got := terminal.complete("command " + name + " --format ya"); got != "command "+name+" --format yaml " { + t.Errorf("command decorator: %q", got) + } + if got := terminal.complete("CONTEXT=ignored " + name + " --format ya"); got != "CONTEXT=ignored "+name+" --format yaml " { + t.Errorf("environment prefix: %q", got) + } + for _, value := range []struct{ keys, want string }{ + {"\t\t", "zulu "}, {"\t\t\t", "alpha "}, + } { + if got := terminal.buffer(name+" --ordered ", value.keys, -1); got != name+" --ordered "+value.want { + t.Errorf("KeepOrder: %q", got) + } + } + for _, value := range [][2]string{ + {`'two words'`, "two words"}, {`'a\'b'`, "a'b"}, {`'$(not-executed)'`, "$(not-executed)"}, + {`''`, ""}, {`a\nb`, "a\nb"}, {`\u00fcber`, "über"}, + } { + completed := terminal.complete(name + " --context " + value[0] + " --namespace ") + var argv []string + must(t, json.Unmarshal(environment.output("fish", "--no-config", "-c", strings.Replace(completed, name, name+" __echo-args", 1)), &argv)) + if !slices.Equal(argv, []string{"--context", value[1], "--namespace", value[1] + "-namespace"}) { + t.Errorf("literal arguments: %q (buffer %q)", argv, completed) + } + } + substitutionMarker := filepath.Join(environment.root, "substitution-was-executed") + expression := "(touch " + quote(substitutionMarker) + ")" + substitution := terminal.complete(name + " --context " + expression + " --namespace ") + if _, err := os.Stat(substitutionMarker); !os.IsNotExist(err) { + t.Fatalf("completion evaluated a command substitution: %v", err) + } + _, candidate, found := strings.Cut(substitution, " --namespace ") + if !found || strings.TrimSpace(candidate) == "" { + t.Fatalf("missing literal substitution completion: %q", substitution) + } + var literal []string + must(t, json.Unmarshal(environment.output("fish", "--no-config", "-c", name+" __echo-args "+candidate), &literal)) + if !slices.Equal(literal, []string{expression + "-namespace"}) { + t.Errorf("literal substitution candidate: %q", literal) + } + must(t, os.Mkdir(filepath.Join(environment.root, "directory name"), 0700)) + must(t, os.WriteFile(filepath.Join(environment.root, "directory-file"), nil, 0600)) + completed := terminal.complete(name + " shell-setup install fish directory") + var args []string + must(t, json.Unmarshal(environment.output("fish", "--no-config", "-c", strings.Replace(completed, name, name+" __echo-args", 1)), &args)) + if len(args) != 4 || filepath.Base(strings.TrimSuffix(args[3], "/")) != "directory name" { + t.Errorf("directory completion: %q (buffer %q)", args, completed) + } + // Sourcing works outside autoload too, and preserves native ordering and + // descriptions in Fish's own completion API. + path := filepath.Join(environment.root, "config", "fish", "completions", name+".fish") + ordered := environment.output("fish", "--no-config", "-c", "source "+quote(path)+"; complete -C "+quote(name+" --ordered ")) + var values []string + for _, row := range strings.Split(strings.TrimSuffix(string(ordered), "\n"), "\n") { + value, _, _ := strings.Cut(row, "\t") + values = append(values, value) + } + if !slices.Equal(values, []string{"zulu", "alpha"}) { + t.Errorf("native ordering: %q", ordered) + } + described := environment.output("fish", "--no-config", "-c", "source "+quote(path)+"; complete -C "+quote(name+" --context cluster-b --namespace cl")) + if !strings.Contains(string(described), "cluster-b-namespace\tNamespace in the selected cluster.") { + t.Errorf("native description: %q", described) + } + if got := terminal.complete(name + " --ini"); got != name+" --initial " { + t.Errorf("initial binary completion: %q", got) + } + environment.updateBinary() + if got := terminal.complete(name + " --up"); got != name+" --updated " { + t.Errorf("updated binary completion: %q", got) + } + environment.assertRequestsSkipInitialization() + before, err := os.ReadFile(profile) + must(t, err) + environment.output(environment.program, "shell-admin", "install", "fish") + after, err := os.ReadFile(profile) + must(t, err) + if string(before) != string(after) || !strings.HasPrefix(string(after), userProfile) { + t.Fatal("installation changed the Fish config") + } + for _, diagnostic := range []string{"Invalid index value", "Expansion error", "Unknown command"} { + if strings.Contains(string(terminal.transcript), diagnostic) { + t.Errorf("Fish emitted a diagnostic: %q", diagnostic) + } + } +} diff --git a/internal/shelltestcmd/main.go b/internal/shelltestcmd/main.go index d59b1fb..4faa732 100644 --- a/internal/shelltestcmd/main.go +++ b/internal/shelltestcmd/main.go @@ -38,6 +38,7 @@ func run(parent context.Context, args []string, output, diagnostics io.Writer) e flags := flag.NewFlagSet("shell-e2e", flag.ContinueOnError) flags.SetOutput(diagnostics) engine := flags.String("engine", "auto", "container engine: auto (Podman first), podman or docker") + coverage := flags.Bool("coverage", false, "collect fixture coverage in profile.shells.cov") if err := flags.Parse(args); err != nil { return err } @@ -81,8 +82,14 @@ func run(parent context.Context, args []string, output, diagnostics io.Writer) e } ldflags := "-X main.variant=" + variant + " -X main.completionCommand=" + public + " -X main.completionRequestCommand=__suggest" env := append(exampleutil.NativeEnv(), "GOOS=linux", "GOARCH="+runtime.GOARCH, "CGO_ENABLED=0") - if err := command(ctx, root, env, output, diagnostics, "go", "build", "-mod=readonly", "-ldflags", ldflags, - "-o", filepath.Join(directory, "fixture-"+variant), "./internal/testdata/completion"); err != nil { + build := []string{"build", "-mod=readonly", "-ldflags", ldflags} + if *coverage { + // The main package installs Go's runtime coverage hooks; the exported + // report below selects only the actual library packages. + build = append(build, "-cover", "-covermode=atomic", "-coverpkg=github.com/echocat/cli4g/internal/testdata/completion,github.com/echocat/cli4g,github.com/echocat/cli4g/codec") + } + build = append(build, "-o", filepath.Join(directory, "fixture-"+variant), "./internal/testdata/completion") + if err := command(ctx, root, env, output, diagnostics, "go", build...); err != nil { return err } } @@ -91,15 +98,43 @@ func run(parent context.Context, args []string, output, diagnostics io.Writer) e "-o", filepath.Join(directory, "shell-e2e"), "./internal/shelltestcmd"); err != nil { return err } + var coverageDirectories []string for _, file := range []string{"Containerfile", "Containerfile.powershell"} { - if err := runContainer(ctx, selected, directory, file, output, diagnostics); err != nil { + coverageDirectory := "" + if *coverage { + coverageDirectory = filepath.Join(directory, "coverage-"+file) + coverageDirectories = append(coverageDirectories, coverageDirectory) + } + if err := runContainer(ctx, selected, directory, file, coverageDirectory, output, diagnostics); err != nil { + return err + } + } + if *coverage { + merged := filepath.Join(directory, "coverage-merged") + if err := os.Mkdir(merged, 0700); err != nil { + return err + } + env := exampleutil.NativeEnv() + if err := command(ctx, root, env, output, diagnostics, "go", "tool", "covdata", "merge", "-pcombine", "-i="+strings.Join(coverageDirectories, ","), "-o="+merged); err != nil { + return err + } + profile := filepath.Join(root, "profile.shells.cov") + if err := command(ctx, root, env, output, diagnostics, "go", "tool", "covdata", "textfmt", "-pkg=github.com/echocat/cli4g,github.com/echocat/cli4g/codec", "-i="+merged, "-o="+profile); err != nil { + return err + } + data, err := os.ReadFile(profile) + if err != nil { return err } + if len(strings.Split(strings.TrimSpace(string(data)), "\n")) < 2 { + return fmt.Errorf("shell E2E produced no coverage data") + } + _, _ = fmt.Fprintln(output, "Shell fixture coverage:", profile) } return nil } -func runContainer(ctx context.Context, selected, directory, file string, output, diagnostics io.Writer) error { +func runContainer(ctx context.Context, selected, directory, file, coverageDirectory string, output, diagnostics io.Writer) error { idFile := filepath.Join(directory, file+".image-id") args := []string{"build", "--platform", "linux/" + runtime.GOARCH, "--file", filepath.Join(directory, file), "--iidfile", idFile} if file == "Containerfile.powershell" && runtime.GOARCH == "arm64" { @@ -121,7 +156,34 @@ func runContainer(ctx context.Context, selected, directory, file string, output, }() // Run by image ID rather than a mutable tag, so concurrent checkouts cannot // accidentally execute each other's test binaries. Runtime tests need no network. - return command(ctx, "", nil, output, diagnostics, selected, "run", "--rm", "--network=none", "--name", name, strings.TrimSpace(string(image))) + args = []string{"run", "--network=none", "--name", name} + if coverageDirectory != "" { + args = append(args, "--env", "GOCOVERDIR=/opt/coverage") + } else { + args = append(args, "--rm") + } + args = append(args, strings.TrimSpace(string(image))) + if err := command(ctx, "", nil, output, diagnostics, selected, args...); err != nil { + return err + } + if coverageDirectory != "" { + if err := command(ctx, "", nil, output, diagnostics, selected, "cp", name+":/opt/coverage", coverageDirectory); err != nil { + return err + } + entries, err := os.ReadDir(coverageDirectory) + if err != nil { + return err + } + var metadata, counters bool + for _, entry := range entries { + metadata = metadata || strings.HasPrefix(entry.Name(), "covmeta.") + counters = counters || strings.HasPrefix(entry.Name(), "covcounters.") + } + if !metadata || !counters { + return fmt.Errorf("shell fixtures produced no usable runtime coverage: %s", file) + } + } + return nil } func chooseEngine(value string, lookup func(string) (string, error)) (string, error) { diff --git a/internal/testdata/completion/main.go b/internal/testdata/completion/main.go index c1901df..b7fb9d9 100644 --- a/internal/testdata/completion/main.go +++ b/internal/testdata/completion/main.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "context" "encoding/json" "io" @@ -39,6 +40,9 @@ func main() { if err != nil { panic(err) } + if len(os.Args) > 2 && os.Args[2] == "--trim-newline" { + data = bytes.TrimSuffix(data, []byte{'\n'}) + } if err := json.NewEncoder(os.Stdout).Encode(string(data)); err != nil { panic(err) } diff --git a/mise.toml b/mise.toml index eb0c5d5..910bfc2 100644 --- a/mise.toml +++ b/mise.toml @@ -96,14 +96,15 @@ env.GOWORK = "off" run = 'go test -count=1 -v ./internal/integration -run "^TestModuleArchive$" -args -module-archive' [tasks."test:shells"] -description = "Run mandatory Bash/Nushell/PowerShell/Zsh terminal E2E tests in Podman (preferred) or Docker" +description = "Run mandatory Bash/Fish/Nushell/PowerShell/Zsh terminal E2E tests in Podman (preferred) or Docker" usage = ''' flag "--engine " help="Container engine: auto, podman or docker" default="auto" +flag "--coverage" help="Collect real shell fixture coverage in profile.shells.cov" default=#false ''' env.GOWORK = "off" shell = "{% if os() == 'windows' %}pwsh -Command{% else %}sh -o errexit -c{% endif %}" run = ''' -go run ./internal/shelltestcmd --engine {% if os() == 'windows' %}'{{usage.engine | replace(from="'", to="''")}}'{% else %}{{usage.engine | quote}}{% endif %}{% if os() == 'windows' %}; exit $LASTEXITCODE{% endif %} +go run ./internal/shelltestcmd --engine {% if os() == 'windows' %}'{{usage.engine | replace(from="'", to="''")}}'{% else %}{{usage.engine | quote}}{% endif %}{% if usage.coverage %} --coverage{% endif %}{% if os() == 'windows' %}; exit $LASTEXITCODE{% endif %} ''' [tasks."test:benchmarks"] From 013b93cf19c45427f508123ba2a9b3981871c7f4 Mon Sep 17 00:00:00 2001 From: Gregor Noczinski Date: Tue, 20 Oct 2026 20:09:07 +0200 Subject: [PATCH 05/14] Validate Fish numeric escape ranges --- completion_fish_input.go | 38 +++++++++++++++++++++++++++----------- completion_fish_test.go | 18 +++++++++++++++++- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/completion_fish_input.go b/completion_fish_input.go index d203cdb..08e2322 100644 --- a/completion_fish_input.go +++ b/completion_fish_input.go @@ -4,6 +4,7 @@ import ( "fmt" "strconv" "strings" + "unicode/utf8" ) // Fish supplies the native process boundaries. This best-effort lexer removes @@ -41,7 +42,10 @@ func fishCompletionRequest(line string, point int) (CompletionRequest, error) { } switch { case char == '\\' && i+1 < len(line) && (quote == 0 || strings.ContainsRune("\\'", rune(line[i+1])) && quote == '\'' || strings.ContainsRune("\\\"$\n", rune(line[i+1])) && quote == '"'): - value, size := fishCompletionEscape(line[i:], quote) + value, size, err := fishCompletionEscape(line[i:], quote) + if err != nil { + return CompletionRequest{}, err + } word.WriteString(value) i += size case (char == '\'' || char == '"') && (quote == 0 || quote == char): @@ -86,19 +90,19 @@ func fishCompletionRequest(line string, point int) (CompletionRequest, error) { return CompletionRequest{Words: words[command:], Word: current - command, Offset: offset}, nil } -func fishCompletionEscape(input string, quote byte) (string, int) { +func fishCompletionEscape(input string, quote byte) (string, int, error) { char := input[1] if char == '\n' { - return "", 2 + return "", 2, nil } if quote != 0 { - return string(char), 2 + return string(char), 2, nil } if index := strings.IndexByte("abefnrtv", char); index >= 0 { - return string("\a\b\x1b\f\n\r\t\v"[index]), 2 + return string("\a\b\x1b\f\n\r\t\v"[index]), 2, nil } if char == 'c' && len(input) >= 3 { - return string(input[2] & 0x1f), 3 + return string(input[2] & 0x1f), 3, nil } base, begin, limit := 16, 2, 0 switch char { @@ -121,14 +125,26 @@ func fishCompletionEscape(input string, quote byte) (string, int) { end++ } if end > begin { - if value, err := strconv.ParseUint(input[begin:end], base, 32); err == nil { - if char == 'x' || char == 'X' || base == 8 { - return string([]byte{byte(value)}), end + value, err := strconv.ParseUint(input[begin:end], base, 32) + if err != nil { + return "", 0, fmt.Errorf("invalid Fish numeric escape") + } + if char == 'x' || char == 'X' || base == 8 { + if value > 0xff { + return "", 0, fmt.Errorf("invalid Fish byte escape") } - return string(rune(value)), end + return string([]byte{byte(value)}), end, nil + } + if value > utf8.MaxRune { + return "", 0, fmt.Errorf("invalid Fish Unicode escape") + } + decoded := rune(value) + if !utf8.ValidRune(decoded) { + return "", 0, fmt.Errorf("invalid Fish Unicode escape") } + return string(decoded), end, nil } - return string(char), 2 + return string(char), 2, nil } func fishCompletionExpressionEnd(line string, start int) int { diff --git a/completion_fish_test.go b/completion_fish_test.go index 31338f2..9fe1d03 100644 --- a/completion_fish_test.go +++ b/completion_fish_test.go @@ -29,6 +29,8 @@ func TestFishCompletionInputPreservesLiteralWordsAndCursor(t *testing.T) { {`tool --context "\n" --namespace `, completionAtEnd("--context", `\n`, "--namespace", "")}, {`tool --context \u00fcber --namespace `, completionAtEnd("--context", "über", "--namespace", "")}, {`tool --context \U0001f600 --namespace `, completionAtEnd("--context", "😀", "--namespace", "")}, + {`tool --context \U0010ffff --namespace `, completionAtEnd("--context", "\U0010ffff", "--namespace", "")}, + {`tool --context \377 --namespace `, completionAtEnd("--context", string([]byte{0xff}), "--namespace", "")}, {`tool --context \x61\141\cb --namespace `, completionAtEnd("--context", "aa\x02", "--namespace", "")}, {"tool --context a\\\nb --namespace ", completionAtEnd("--context", "ab", "--namespace", "")}, {"tool --context 'a\nb' --namespace ", completionAtEnd("--context", "a\nb", "--namespace", "")}, @@ -57,6 +59,16 @@ func TestFishCompletionInputPreservesLiteralWordsAndCursor(t *testing.T) { require.Error(t, err) } +func TestFishCompletionRejectsOutOfRangeEscapes(t *testing.T) { + for _, escape := range []string{`\400`, `\777`, `\uD800`, `\uDFFF`, `\U00110000`, `\U80000000`, `\UFFFFFFFF`} { + t.Run(escape, func(t *testing.T) { + line := "tool --context " + escape + " --namespace " + _, err := fishCompletionRequest(line, len(line)) + require.Error(t, err) + }) + } +} + func TestFishCompletionProtocolPreservesNewlinesAndSuppressesInvalidRequests(t *testing.T) { var input codec.CompletionInput app := NewSimpleApplication("tool").WithCompletion(true). @@ -84,7 +96,11 @@ func TestFishCompletionProtocolPreservesNewlinesAndSuppressesInvalidRequests(t * assert.Equal(t, "a\n", input.Prefix) assert.Equal(t, "b", input.Suffix) assert.Contains(t, output.String(), "a\nb-value\x00") - for _, args := range [][]string{{"1", "fish"}, {"1", "fish", "different\n", "tool --value a\n"}, {"1", "fish", "tool\n", "tool\n"}} { + for _, args := range [][]string{ + {"1", "fish"}, {"1", "fish", "different\n", "tool --value a\n"}, {"1", "fish", "tool\n", "tool\n"}, + {"1", "fish", "tool --value \\777\n", "tool --value \\777\n"}, + {"1", "fish", "tool --value \\UFFFFFFFF\n", "tool --value \\UFFFFFFFF\n"}, + } { output.Reset() require.NoError(t, app.ParseAndExecute(ctx, nil, append([]string{"__complete"}, args...)...)) assert.Equal(t, "1\x000\x000\x000\x00\x00\x00", output.String()) From 4d29bf579126f3ba939b7ce929c0fb2e654ad2f3 Mon Sep 17 00:00:00 2001 From: Gregor Noczinski Date: Tue, 20 Oct 2026 20:43:17 +0200 Subject: [PATCH 06/14] Bound CI parallelism and cancel superseded PR runs --- .github/workflows/ci.yml | 10 ++++++++++ docs/development.md | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb498db..8648d4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,11 +17,20 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + GOMAXPROCS: '4' + GOFLAGS: '-p=2' + jobs: lint: name: Lint env: MISE_ENV: ci-lint + GOMAXPROCS: ${{ matrix.os == 'macos-latest' && '2' || '4' }} strategy: fail-fast: false matrix: @@ -69,6 +78,7 @@ jobs: env: MISE_ENV: ${{ matrix.go.mise-env }} CLI4G_CI: '1' + GOMAXPROCS: ${{ matrix.os == 'macos-latest' && '2' || '4' }} steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 diff --git a/docs/development.md b/docs/development.md index de7bd9b..bdd5f4f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -119,6 +119,10 @@ complete repository. - Coverage: one Coveralls upload containing every matrix profile. - CodeQL: separate manual-build analysis. +New runs automatically cancel superseded runs of the same pull request. Go +package builds use `GOFLAGS=-p=2`; Go processes use `GOMAXPROCS=4`, or `2` on macOS. +This bounds simultaneous compiler and analysis work on the hosted runners. + Tool requests are in `mise.toml`; lockfiles select exact versions and checksums. The weekly dependency workflow verifies updates with `check:full` before opening a draft PR. Tool updates require releases at least seven days old. From 8249c07292277ec138416bcb3540c9251afa8745 Mon Sep 17 00:00:00 2001 From: Gregor Noczinski Date: Tue, 20 Oct 2026 20:48:42 +0200 Subject: [PATCH 07/14] Run macOS CI on Intel and cross-build ARM64 --- .github/workflows/ci.yml | 12 ++++++++---- docs/development.md | 6 ++++-- mise.toml | 7 +++++++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8648d4c..8a65bd1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,11 +30,11 @@ jobs: name: Lint env: MISE_ENV: ci-lint - GOMAXPROCS: ${{ matrix.os == 'macos-latest' && '2' || '4' }} + GOMAXPROCS: ${{ startsWith(matrix.os, 'macos-') && '2' || '4' }} strategy: fail-fast: false matrix: - os: [ ubuntu-latest, macos-latest, windows-latest ] + os: [ ubuntu-latest, macos-26-intel, windows-latest ] runs-on: ${{ matrix.os }} steps: - name: Checkout code @@ -73,12 +73,12 @@ jobs: mise-env: ci-minimum - version: stable mise-env: ci-stable - os: [ ubuntu-latest, macos-latest, windows-latest ] + os: [ ubuntu-latest, macos-26-intel, windows-latest ] runs-on: ${{ matrix.os }} env: MISE_ENV: ${{ matrix.go.mise-env }} CLI4G_CI: '1' - GOMAXPROCS: ${{ matrix.os == 'macos-latest' && '2' || '4' }} + GOMAXPROCS: ${{ startsWith(matrix.os, 'macos-') && '2' || '4' }} steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -103,6 +103,10 @@ jobs: - name: Test run: mise run test:go + - name: Cross-build Darwin ARM64 + if: runner.os == 'macOS' + run: mise run test:go:darwin-arm64 + - name: Build and smoke-test examples run: mise run test:examples:examples diff --git a/docs/development.md b/docs/development.md index bdd5f4f..5f27091 100644 --- a/docs/development.md +++ b/docs/development.md @@ -18,6 +18,7 @@ mise run check:full | `mise run test:go --race` | Go tests with race detection | | `mise run test --coverage` | Go and example coverage | | `mise run test:matrix` | Minimum and stable Go versions | +| `mise run test:go:darwin-arm64` | Cross-build the root module for Darwin ARM64 | | `mise run test:module` | Test the distributable module archive without nested modules or Git metadata | | `mise run test:shells` | Mandatory interactive Bash/Fish/Nushell/PowerShell/Zsh E2E tests in Linux containers | | `mise run test:fuzz` | Bounded parser fuzzing | @@ -110,8 +111,9 @@ complete repository. ## CI -- Go linting: Linux, macOS, Windows. -- Runtime tests: minimum/stable Go on each OS, including example binaries. +- Go linting: Linux, macOS Intel, Windows. +- Runtime tests: minimum/stable Go on each OS, including example binaries; macOS uses `macos-26-intel`. +- Darwin ARM64: an additional cross-build in both macOS Go-version jobs; native ARM64 execution is outside this matrix. - Shell E2E: a required Linux job using rootless Podman and pinned Bash/Fish/Nushell/PowerShell/Zsh versions. - Shared checks: once on Linux; formats, module archives, workflows, example code, benchmark-adapter correctness, fuzzing, and vulnerabilities. Timing measurements diff --git a/mise.toml b/mise.toml index 910bfc2..b82492d 100644 --- a/mise.toml +++ b/mise.toml @@ -86,6 +86,13 @@ depends = [ description = "Check that all packages compile" run = "go build ./..." +[tasks."test:go:darwin-arm64"] +description = "Cross-build all root-module packages for Darwin ARM64" +env.GOOS = "darwin" +env.GOARCH = "arm64" +env.CGO_ENABLED = "0" +run = "go build ./..." + [tasks."test:go:code"] extends = "go_test" description = "Test all Go packages" From 464e180ff7a9dd493a2985601335de2e973bc39c Mon Sep 17 00:00:00 2001 From: Gregor Noczinski Date: Wed, 21 Oct 2026 09:41:07 +0200 Subject: [PATCH 08/14] Merge shell coverage and preserve erased bool flag semantics --- .github/workflows/ci.yml | 34 +++-- .gitignore | 2 + codec/completion_lifecycle_test.go | 85 ++++++++++++ codec/raw.go | 3 + completion_argument_dependencies_test.go | 51 +++++++ docs/development.md | 11 +- internal/coveragemerge/main.go | 170 +++++++++++++++++++++++ internal/coveragemerge/main_test.go | 62 +++++++++ internal/shelltestcmd/main.go | 2 +- internal/shelltestcmd/runner_test.go | 27 ++++ mise.toml | 2 +- 11 files changed, 436 insertions(+), 13 deletions(-) create mode 100644 codec/completion_lifecycle_test.go create mode 100644 completion_argument_dependencies_test.go create mode 100644 internal/coveragemerge/main.go create mode 100644 internal/coveragemerge/main_test.go create mode 100644 internal/shelltestcmd/runner_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a65bd1..8a9212b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,7 @@ jobs: env: MISE_ENV: ci-lint GOMAXPROCS: ${{ startsWith(matrix.os, 'macos-') && '2' || '4' }} + GOMEMLIMIT: ${{ startsWith(matrix.os, 'macos-') && '4GiB' || 'off' }} strategy: fail-fast: false matrix: @@ -155,7 +156,9 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: tests-shells-${{ github.run_number }} - path: profile.shells.cov + path: | + profile.shells.cov + profile.shell-runner.cov retention-days: 1 if-no-files-found: error @@ -195,6 +198,13 @@ jobs: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Install mise + uses: jdx/mise-action@9b200bcecbfd6303e69a4a261b2e09e3757440a7 # v5.2.0 + with: + version: 2026.9.18 + install: true + install_args: --locked + - name: Download coverage profiles uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: @@ -224,19 +234,23 @@ jobs: done done - shell_profiles=(coverage/tests-shells-*/profile.shells.cov) - if [[ ${#shell_profiles[@]} -eq 0 ]]; then + shell_directories=(coverage/tests-shells-*) + if [[ ${#shell_directories[@]} -eq 0 ]]; then echo "::error::No shell E2E coverage artifact was downloaded." exit 1 fi - for profile in "${shell_profiles[@]}"; do - if [[ ! -s "$profile" ]] || [[ $(wc -l < "$profile") -le 1 ]]; then - echo "::error::Missing or empty shell coverage profile: $profile" - exit 1 - fi - profiles+=("$profile") + for directory in "${shell_directories[@]}"; do + for filename in profile.shells.cov profile.shell-runner.cov; do + profile="$directory/$filename" + if [[ ! -s "$profile" ]] || [[ $(wc -l < "$profile") -le 1 ]]; then + echo "::error::Missing or empty shell coverage profile: $profile" + exit 1 + fi + profiles+=("$profile") + done done - printf 'files=%s\n' "${profiles[*]}" >> "$GITHUB_OUTPUT" + mise exec -- go run ./internal/coveragemerge -output profile.merged.cov "${profiles[@]}" + printf 'files=%s\n' 'profile.merged.cov' >> "$GITHUB_OUTPUT" - name: Send coverage uses: coverallsapp/github-action@8d6379e14d29928660c4ba802d8e85393440b329 # v2 diff --git a/.gitignore b/.gitignore index 79a3b71..542b74c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ profile.cov profile.cov.full profile.examples.cov profile.shells.cov +profile.shell-runner.cov +profile.merged.cov /mise.local.toml /mise.local.lock /.mise/locks/mise.local/ diff --git a/codec/completion_lifecycle_test.go b/codec/completion_lifecycle_test.go new file mode 100644 index 0000000..4088c3b --- /dev/null +++ b/codec/completion_lifecycle_test.go @@ -0,0 +1,85 @@ +package codec_test + +import ( + "context" + "reflect" + "testing" + + "github.com/echocat/cli4g/codec" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCompletionDecoratorPreservesCumulativeParsingAndIndependentRequirements(t *testing.T) { + calls := 0 + wrapped := codec.WithCompletionHandler(codec.NewStrings(), func(context.Context, codec.CompletionInput) (codec.CompletionResult, error) { + calls++ + return codec.CompletionResult{}, nil + }) + ctx := context.Background() + require.True(t, codec.IsCumulative(wrapped)) + first, err := wrapped.ParseRequirement(ctx, codec.UsageFlag, "first") + require.NoError(t, err) + values, err := wrapped.(codec.Cumulative[[]string]).ParseRequirementInto(ctx, codec.UsageFlag, first, "second") + require.NoError(t, err) + assert.Equal(t, []string{"first", "second"}, values) + cloned, err := codec.CloneRequirement(ctx, wrapped, values) + require.NoError(t, err) + cloned[0] = "changed" + assert.Equal(t, []string{"first", "second"}, values) + assert.Zero(t, calls, "normal parsing and cloning do not invoke completion") + raw := wrapped.ToRaw(ctx) + require.True(t, codec.IsCumulative(raw)) + previous, err := raw.ParseRequirement(ctx, codec.UsageFlag, "third") + require.NoError(t, err) + combined, err := raw.(codec.Cumulative[any]).ParseRequirementInto(ctx, codec.UsageFlag, previous, "fourth") + require.NoError(t, err) + assert.Equal(t, []string{"third", "fourth"}, combined) + assert.Zero(t, calls) +} + +func TestCompletionDecoratorPreservesErasedBoolAndPlainStringBinding(t *testing.T) { + ctx := context.Background() + invoked := 0 + handler := func(context.Context, codec.CompletionInput) (codec.CompletionResult, error) { + invoked++ + return codec.CompletionResult{Handled: true}, nil + } + boolean, err := codec.Snapshot(ctx, codec.WithCompletionHandler(codec.NewBool().ToRaw(ctx), handler)) + require.NoError(t, err) + require.True(t, boolean.(interface{ IsBoolFlag() bool }).IsBoolFlag()) + value, err := boolean.ParseRequirement(ctx, codec.UsageFlag) + require.NoError(t, err) + assert.Equal(t, true, value) + registry, err := codec.NewRegistry(ctx, []codec.Registration{codec.NewRegistration(codec.WithCompletionHandler(codec.NewString(), handler))}) + require.NoError(t, err) + bound, found, err := registry.Find(ctx, reflect.TypeFor[string](), "") + require.NoError(t, err) + require.True(t, found) + value, err = bound.ParseRequirement(ctx, codec.UsageFlag, "normal value") + require.NoError(t, err) + assert.Equal(t, "normal value", value) + _, found, err = registry.Find(ctx, reflect.TypeFor[int](), "") + require.NoError(t, err) + assert.False(t, found) + assert.Zero(t, invoked) +} + +func TestCompletionDecoratorHonorsCancellationAndOwnsReturnedCandidates(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + wrapped := codec.WithCompletionHandler(codec.NewString(), func(context.Context, codec.CompletionInput) (codec.CompletionResult, error) { + cancel() + return codec.CompletionResult{Handled: true, Candidates: []codec.CompletionCandidate{{Value: "stale"}}}, nil + }) + result, err := codec.Complete(ctx, wrapped, codec.CompletionInput{}) + require.ErrorIs(t, err, context.Canceled) + assert.Empty(t, result.Candidates) + shared := []codec.CompletionCandidate{{Value: "original"}} + wrapped = codec.WithCompletionHandler(codec.NewString(), func(context.Context, codec.CompletionInput) (codec.CompletionResult, error) { + return codec.CompletionResult{Handled: true, Candidates: shared}, nil + }) + result, err = codec.Complete(nil, wrapped, codec.CompletionInput{}) + require.NoError(t, err) + result.Candidates[0].Value = "changed" + assert.Equal(t, "original", shared[0].Value) +} diff --git a/codec/raw.go b/codec/raw.go index 24e7ecc..d421a26 100644 --- a/codec/raw.go +++ b/codec/raw.go @@ -183,6 +183,9 @@ func (this raw[V, R]) RequirementType() reflect.Type { } func (this raw[V, R]) IsBoolFlag() bool { + if reflect.TypeFor[V]().Kind() == reflect.Bool { + return true + } trait, ok := this.source.(interface{ IsBoolFlag() bool }) return ok && trait.IsBoolFlag() } diff --git a/completion_argument_dependencies_test.go b/completion_argument_dependencies_test.go new file mode 100644 index 0000000..51f1631 --- /dev/null +++ b/completion_argument_dependencies_test.go @@ -0,0 +1,51 @@ +package cli + +import ( + "context" + "testing" + + "github.com/echocat/cli4g/codec" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCompletionReadsPositionalDependenciesWithoutInitialization(t *testing.T) { + var value codec.CompletionValue + app := NewSimpleApplication("tool"). + WithArgument(NewArgument(codec.NewString(), "region").WithDefault("east")). + WithFlag(NewFlag(codec.WithCompletionHandler(codec.NewString(), func(ctx context.Context, input codec.CompletionInput) (codec.CompletionResult, error) { + var found bool + var err error + value, found, err = input.Effective.Argument(nil, "region") + require.NoError(t, err) + require.True(t, found) + _, found, err = input.Effective.Argument(ctx, "absent") + require.NoError(t, err) + assert.False(t, found) + _, found, err = input.Effective.Flag(nil, "absent") + require.NoError(t, err) + assert.False(t, found) + canceled, cancel := context.WithCancel(ctx) + cancel() + _, _, err = input.Effective.Argument(canceled, "region") + require.ErrorIs(t, err, context.Canceled) + _, _, err = input.Effective.Flag(canceled, "absent") + require.ErrorIs(t, err, context.Canceled) + return codec.CompletionResult{Handled: true, Candidates: []codec.CompletionCandidate{{Value: "result"}}}, nil + }), "output")). + WithInitializeHandler(func(context.Context, NoOptions) error { panic("completion must not initialize options") }).MustCreate() + defer func() { assert.NoError(t, app.Close()) }() + _, err := app.Complete(nil, nil, completionAtEnd("--output", "")) + require.NoError(t, err) + assert.Equal(t, codec.CompletionDefault, value.Source) + assert.Equal(t, []string{"east"}, value.Values) + request := CompletionRequest{Words: []string{"--output", "", "west"}, Word: 1} + _, err = app.Complete(nil, nil, request) + require.NoError(t, err) + assert.Equal(t, codec.CompletionCLI, value.Source) + assert.Equal(t, []string{"west"}, value.Values) + value.Values[0] = "caller mutation" + _, err = app.Complete(nil, nil, request) + require.NoError(t, err) + assert.Equal(t, []string{"west"}, value.Values) +} diff --git a/docs/development.md b/docs/development.md index 5f27091..762c9c1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -74,12 +74,18 @@ empty/multiline values, escapes, non-evaluated substitutions, native suffix matc directory-only paths, command decorators, preserved config, and binary replacement. Missing/wrong-version shells and missing runtimes are errors, never skips. -`--coverage` instruments the actual fixture binaries, collects their Go runtime +`--coverage` measures host-side orchestration in `profile.shell-runner.cov`, +instruments the actual fixture binaries, collects their Go runtime coverage from both stopped containers using the engine's copy command, and merges it into `profile.shells.cov` with Go's `covdata` tooling. CI includes this artifact in the same Coveralls upload as the Go matrix and example profiles, so native shell execution contributes to the reported library coverage. +Before upload, `internal/coveragemerge` unions the Go, example and shell profiles +into `profile.merged.cov`. A block hit in any run remains covered; independent +zero-hit reports cannot clear it. Output uses Go's set mode and canonical module +paths, including nested example files, so Coveralls receives each source once. + The lighter `TestNativeShellCompletion` Go test still uses locally available shells and can skip unavailable ones. The separate container suite is the required CI guarantee and also runs in the dependency-update quality gate. @@ -106,6 +112,8 @@ complete repository. - Go coverage: `profile.cov`. - Example-binary coverage: `profile.examples.cov`, including library calls. - Shell-fixture coverage: `profile.shells.cov`, including the real adapter queries and installation paths. +- Shell-runner coverage: `profile.shell-runner.cov`, including real container orchestration. +- Combined CI coverage: `profile.merged.cov`, the union uploaded to Coveralls. - `CLI4G_CI=1` enables race/verbose/coverage for Go tests and binary coverage for examples; Go tests then write `profile.cov.full`. @@ -124,6 +132,7 @@ complete repository. New runs automatically cancel superseded runs of the same pull request. Go package builds use `GOFLAGS=-p=2`; Go processes use `GOMAXPROCS=4`, or `2` on macOS. This bounds simultaneous compiler and analysis work on the hosted runners. +macOS lint additionally uses Go's soft heap budget `GOMEMLIMIT=4GiB`. Tool requests are in `mise.toml`; lockfiles select exact versions and checksums. The weekly dependency workflow verifies updates with `check:full` before opening diff --git a/internal/coveragemerge/main.go b/internal/coveragemerge/main.go new file mode 100644 index 0000000..62a7f2b --- /dev/null +++ b/internal/coveragemerge/main.go @@ -0,0 +1,170 @@ +// Command coveragemerge unions Go coverage profiles before reporting them. +// A covered block stays covered across platforms, examples and shell E2E runs. +// Output uses set mode: hit counts represent execution at least once, not the +// sum of independent test runs. Relative example paths are restored to module +// paths so the Coveralls Go parser does not strip their directory components. +package main + +import ( + "bufio" + "cmp" + "errors" + "flag" + "fmt" + "io" + "os" + "path" + "regexp" + "slices" + "strconv" + "strings" +) + +const modulePath = "github.com/echocat/cli4g" + +var coverageLine = regexp.MustCompile(`^(.+\.go):([0-9]+)\.([0-9]+),([0-9]+)\.([0-9]+) ([0-9]+) ([0-9]+)$`) + +type position struct { + file string + startLine, startCol, endLine, endCol int +} + +type block struct { + statements int + hit bool +} + +func main() { + if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(args []string, output, diagnostics io.Writer) error { + flags := flag.NewFlagSet("coveragemerge", flag.ContinueOnError) + flags.SetOutput(diagnostics) + destination := flags.String("output", "profile.merged.cov", "merged Go set-mode profile") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() == 0 { + return fmt.Errorf("at least one coverage profile is required") + } + blocks := make(map[position]block) + for _, name := range flags.Args() { + file, err := os.Open(name) + if err != nil { + return err + } + readErr := readProfile(file, blocks) + closeErr := file.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return fmt.Errorf("%s: %w", name, err) + } + } + file, err := os.Create(*destination) + if err != nil { + return err + } + writeErr := writeProfile(file, blocks) + closeErr := file.Close() + if err := errors.Join(writeErr, closeErr); err != nil { + return err + } + _, err = fmt.Fprintf(output, "Merged %d coverage profiles into %s (%d unique blocks).\n", flags.NArg(), *destination, len(blocks)) + return err +} + +func readProfile(input io.Reader, blocks map[position]block) error { + scanner := bufio.NewScanner(input) + scanner.Buffer(make([]byte, 4096), 1<<20) + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return err + } + return fmt.Errorf("empty coverage profile") + } + switch strings.TrimSpace(scanner.Text()) { + case "mode: set", "mode: count", "mode: atomic": + default: + return fmt.Errorf("unsupported coverage mode") + } + count := 0 + for scanner.Scan() { + line := strings.TrimSuffix(scanner.Text(), "\r") + if line == "" { + continue + } + fields := coverageLine.FindStringSubmatch(line) + if fields == nil { + return fmt.Errorf("invalid coverage block") + } + name, err := sourcePath(fields[1]) + if err != nil { + return err + } + var numbers [5]int + for i := range numbers { + numbers[i], err = strconv.Atoi(fields[i+2]) + if err != nil { + return fmt.Errorf("invalid coverage coordinates: %w", err) + } + } + pos := position{name, numbers[0], numbers[1], numbers[2], numbers[3]} + if pos.startLine < 1 || pos.startCol < 1 || pos.endCol < 1 || pos.endLine < pos.startLine || pos.endLine == pos.startLine && pos.endCol < pos.startCol { + return fmt.Errorf("invalid coverage coordinates") + } + hits, err := strconv.ParseUint(fields[7], 10, 64) + if err != nil { + return fmt.Errorf("invalid coverage counter: %w", err) + } + current := blocks[pos] + // Go patch versions can count composite-literal statements differently + // for the same source span. Retain the largest count and union its hits. + blocks[pos] = block{statements: max(current.statements, numbers[4]), hit: current.hit || hits > 0} + count++ + } + if err := scanner.Err(); err != nil { + return err + } + if count == 0 { + return fmt.Errorf("coverage profile has no blocks") + } + return nil +} + +func sourcePath(name string) (string, error) { + name = strings.ReplaceAll(name, "\\", "/") + name = strings.TrimPrefix(name, modulePath+"/") + name = path.Clean(strings.TrimPrefix(name, "./")) + if name == "." || name == ".." || strings.HasPrefix(name, "../") || path.IsAbs(name) || strings.Contains(name, ":") { + return "", fmt.Errorf("coverage source is outside the module: %s", name) + } + return modulePath + "/" + name, nil +} + +func writeProfile(output io.Writer, blocks map[position]block) error { + positions := make([]position, 0, len(blocks)) + for pos := range blocks { + positions = append(positions, pos) + } + slices.SortFunc(positions, func(a, b position) int { + return cmp.Or(cmp.Compare(a.file, b.file), cmp.Compare(a.startLine, b.startLine), cmp.Compare(a.startCol, b.startCol), cmp.Compare(a.endLine, b.endLine), cmp.Compare(a.endCol, b.endCol)) + }) + writer := bufio.NewWriter(output) + if _, err := fmt.Fprintln(writer, "mode: set"); err != nil { + return err + } + for _, pos := range positions { + value := blocks[pos] + hit := 0 + if value.hit { + hit = 1 + } + if _, err := fmt.Fprintf(writer, "%s:%d.%d,%d.%d %d %d\n", pos.file, pos.startLine, pos.startCol, pos.endLine, pos.endCol, value.statements, hit); err != nil { + return err + } + } + return writer.Flush() +} diff --git a/internal/coveragemerge/main_test.go b/internal/coveragemerge/main_test.go new file mode 100644 index 0000000..895f09f --- /dev/null +++ b/internal/coveragemerge/main_test.go @@ -0,0 +1,62 @@ +package main + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMergeRetainsHitsAcrossIndependentProfilesAndNormalizesExamples(t *testing.T) { + profiles := []string{ + "mode: atomic\n" + modulePath + "/app.go:2.1,3.2 2 7\n" + modulePath + "/app.go:5.1,5.9 1 0\n", + "mode: count\n" + modulePath + "/app.go:2.1,3.2 1 0\n./_examples/first/main.go:1.1,2.1 1 3\n", + "mode: set\n" + modulePath + "/app.go:5.1,5.9 1 1\n" + modulePath + "/_examples/first/main.go:1.1,2.1 1 0\n", + } + want := "mode: set\n" + modulePath + "/_examples/first/main.go:1.1,2.1 1 1\n" + modulePath + "/app.go:2.1,3.2 2 1\n" + modulePath + "/app.go:5.1,5.9 1 1\n" + for _, order := range [][]int{{0, 1, 2}, {2, 1, 0}} { + blocks := make(map[position]block) + for _, i := range order { + require.NoError(t, readProfile(strings.NewReader(profiles[i]), blocks)) + } + var result bytes.Buffer + require.NoError(t, writeProfile(&result, blocks)) + assert.Equal(t, want, result.String()) + } +} + +func TestReadRejectsInvalidProfiles(t *testing.T) { + for _, input := range []string{ + "", "mode: unknown\napp.go:1.1,2.1 1 1\n", "mode: atomic\n", "mode: set\ninvalid\n", + "mode: set\napp.go:0.1,2.1 1 1\n", "mode: set\napp.go:2.1,1.1 1 1\n", + "mode: set\napp.go:1.3,1.2 1 1\n", "mode: set\n../outside.go:1.1,2.1 1 1\n", + "mode: set\n/app.go:1.1,2.1 1 1\n", "mode: set\nC:\\app.go:1.1,2.1 1 1\n", + "mode: set\napp.go:999999999999999999999.1,2.1 1 1\n", "mode: set\napp.go:1.1,2.1 1 18446744073709551616\n", + } { + t.Run(input, func(t *testing.T) { + require.Error(t, readProfile(strings.NewReader(input), make(map[position]block))) + }) + } +} + +func TestCLIProducesOneValidProfileAndRejectsMissingInputs(t *testing.T) { + directory := t.TempDir() + input := filepath.Join(directory, "input.cov") + output := filepath.Join(directory, "merged.cov") + require.NoError(t, os.WriteFile(input, []byte("mode: atomic\r\ncodec\\value.go:1.1,2.1 1 5\r\n"), 0600)) + var messages bytes.Buffer + require.NoError(t, run([]string{"-output", output, input}, &messages, io.Discard)) + contents, err := os.ReadFile(output) + require.NoError(t, err) + assert.Equal(t, "mode: set\n"+modulePath+"/codec/value.go:1.1,2.1 1 1\n", string(contents)) + assert.Contains(t, messages.String(), "1 unique blocks") + require.Error(t, run(nil, io.Discard, io.Discard)) + require.Error(t, run([]string{"-unknown"}, io.Discard, io.Discard)) + require.Error(t, run([]string{filepath.Join(directory, "missing.cov")}, io.Discard, io.Discard)) + require.Error(t, run([]string{"-output", directory, input}, io.Discard, io.Discard)) +} diff --git a/internal/shelltestcmd/main.go b/internal/shelltestcmd/main.go index 4faa732..c9edc81 100644 --- a/internal/shelltestcmd/main.go +++ b/internal/shelltestcmd/main.go @@ -129,7 +129,7 @@ func run(parent context.Context, args []string, output, diagnostics io.Writer) e if len(strings.Split(strings.TrimSpace(string(data)), "\n")) < 2 { return fmt.Errorf("shell E2E produced no coverage data") } - _, _ = fmt.Fprintln(output, "Shell fixture coverage:", profile) + _, _ = fmt.Fprintln(output, "Shell fixture profile:", profile) } return nil } diff --git a/internal/shelltestcmd/runner_test.go b/internal/shelltestcmd/runner_test.go new file mode 100644 index 0000000..5a24235 --- /dev/null +++ b/internal/shelltestcmd/runner_test.go @@ -0,0 +1,27 @@ +package main + +import ( + "flag" + "io" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +var shellEngine = flag.String("shell-engine", "", "run real container E2E tests with this engine") + +// TestRunShellE2E measures the actual host-side orchestration as well as the +// instrumented fixture binaries. Ordinary package tests require no containers. +func TestRunShellE2E(t *testing.T) { + if *shellEngine == "" { + t.Skip("enable the real shell suite with -shell-engine") + } + require.NoError(t, run(t.Context(), []string{"--engine", *shellEngine, "--coverage"}, os.Stdout, os.Stderr)) +} + +func TestRunRejectsInvalidArgumentsBeforeStartingContainers(t *testing.T) { + for _, args := range [][]string{{"--unknown"}, {"unexpected"}, {"--engine", "unsupported"}} { + require.Error(t, run(t.Context(), args, io.Discard, io.Discard)) + } +} diff --git a/mise.toml b/mise.toml index b82492d..21c04dd 100644 --- a/mise.toml +++ b/mise.toml @@ -111,7 +111,7 @@ flag "--coverage" help="Collect real shell fixture coverage in profile.shells.co env.GOWORK = "off" shell = "{% if os() == 'windows' %}pwsh -Command{% else %}sh -o errexit -c{% endif %}" run = ''' -go run ./internal/shelltestcmd --engine {% if os() == 'windows' %}'{{usage.engine | replace(from="'", to="''")}}'{% else %}{{usage.engine | quote}}{% endif %}{% if usage.coverage %} --coverage{% endif %}{% if os() == 'windows' %}; exit $LASTEXITCODE{% endif %} +{% if usage.coverage %}go test -count=1 -v -timeout=20m -covermode=atomic "-coverprofile=profile.shell-runner.cov" ./internal/shelltestcmd -run '^TestRunShellE2E$' -args -shell-engine {% else %}go run ./internal/shelltestcmd --engine {% endif %}{% if os() == 'windows' %}'{{usage.engine | replace(from="'", to="''")}}'{% else %}{{usage.engine | quote}}{% endif %}{% if os() == 'windows' %}; exit $LASTEXITCODE{% endif %} ''' [tasks."test:benchmarks"] From 6b673b3e0591fb42c6bcc6bbfd9efb9fb5712b77 Mon Sep 17 00:00:00 2001 From: Gregor Noczinski Date: Wed, 21 Oct 2026 11:32:39 +0200 Subject: [PATCH 09/14] Quote Bash completion candidates as literal arguments --- completion_bash.bash | 78 +++++++++++++ completion_bash.go | 6 + completion_bash_input.go | 15 +++ completion_bash_test.go | 52 +++++++++ completion_protocol.go | 7 +- completion_protocol_test.go | 20 ++++ completion_shell.go | 38 +------ completion_shell_test.go | 4 +- doc.go | 10 ++ internal/shelltestcmd/bash_linux_test.go | 105 ++++++++++++++++++ internal/shelltestcmd/container_linux_test.go | 4 +- internal/testdata/completion/main.go | 3 + 12 files changed, 302 insertions(+), 40 deletions(-) create mode 100644 completion_bash.bash create mode 100644 completion_bash.go create mode 100644 completion_bash_test.go create mode 100644 internal/shelltestcmd/bash_linux_test.go diff --git a/completion_bash.bash b/completion_bash.bash new file mode 100644 index 0000000..bcc3caf --- /dev/null +++ b/completion_bash.bash @@ -0,0 +1,78 @@ +_@FUNCTION@_quote_context() { + # COMP_POINT is a byte offset, independently of the interactive locale. + # Only inspect syntax; never evaluate the command line or provider values. + local LC_ALL=C line="${COMP_LINE:-}" char next i + quote= + for ((i=0; i<${COMP_POINT:-0}; i++)); do + char="${line:i:1}" + next="${line:i+1:1}" + if [[ "$char" == \\ && "$quote" != "'" ]] && + [[ -z "$quote" || "$quote" == ansi || "$next" == \\ || "$next" == '"' || + "$next" == '$' || "$next" == '`' || "$next" == $'\n' ]]; then + ((i++)) + elif [[ -n "$quote" && ( "$char" == "$quote" || ( "$quote" == ansi && "$char" == "'" ) ) ]]; then + quote= + elif [[ -z "$quote" && "$char" == '$' && "$next" == "'" ]]; then + quote=ansi + ((i++)) + elif [[ -z "$quote" && ( "$char" == "'" || "$char" == '"' ) ]]; then + quote="$char" + fi + done +} + +_@FUNCTION@() { + local command="$1" field mode path candidate quote i + local -a response=() + while IFS= read -r -d '' field; do + response+=("$field") + done < <("$command" @BASHREQUEST@ 1 bash "${COMP_POINT:-0}" "${COMP_LINE:-}" "${2:-}" 2>/dev/null) + COMPREPLY=() + ((${#response[@]} >= 7)) && [[ "${response[0]}" == 1 ]] || return 0 + [[ "${response[2]}" == 1 ]] && compopt -o nospace + [[ "${response[3]}" == 1 ]] && compopt -o nosort + for ((i=7; i<${#response[@]}; i++)); do + COMPREPLY+=("${response[i]}") + done + mode="${response[1]}" + if [[ "$mode" != 0 ]]; then + local action=-f + [[ "$mode" == 2 ]] && action=-d + while IFS= read -r path; do + candidate="${response[4]}$path" + candidate="${candidate#"${response[6]}"}" + if [[ -d "$path" ]]; then + candidate="${candidate%/}/" + compopt -o nospace + fi + COMPREPLY+=("$candidate") + done < <(compgen "$action" -- "${response[5]}") + fi + # Readline's filename quoting can leave leading $ and backticks active. + # Supply literal shell syntax ourselves, preserving an unfinished quote. + _@FUNCTION@_quote_context + # The request lexer supports simple quotes, not ANSI-C escape decoding. + # Do not insert single-quote syntax inside an active $'...' expression. + if [[ "$quote" == ansi ]]; then + COMPREPLY=() + return 0 + fi + for i in "${!COMPREPLY[@]}"; do + candidate="${COMPREPLY[i]}" + case "$quote" in + "'") candidate="'${candidate//\'/\'\\\'\'}'" ;; + '"') + candidate="${candidate//\\/\\\\}" + candidate="${candidate//\$/\\\$}" + candidate="${candidate//\`/\\\`}" + candidate="${candidate//\"/\\\"}" + candidate="${candidate//\!/\"\'\!\'\"}" + candidate="\"$candidate\"" + ;; + *) printf -v candidate '%q' "$candidate" ;; + esac + COMPREPLY[i]="$candidate" + done + return 0 +} +complete -o noquote -F _@FUNCTION@ -- @BASHCOMMAND@ diff --git a/completion_bash.go b/completion_bash.go new file mode 100644 index 0000000..4934238 --- /dev/null +++ b/completion_bash.go @@ -0,0 +1,6 @@ +package cli + +import _ "embed" + +//go:embed completion_bash.bash +var bashCompletionScript string diff --git a/completion_bash_input.go b/completion_bash_input.go index adcbd7f..5f51675 100644 --- a/completion_bash_input.go +++ b/completion_bash_input.go @@ -5,6 +5,21 @@ import ( "strings" ) +// Readline may retain balanced quotes in CURRENT even though the CLI scanner +// has removed them. Reuse the non-evaluating lexer before aligning word breaks. +func bashCompletionReplacementPrefix(prefix, current string) string { + if !strings.HasSuffix(prefix, current) { + line := "_ " + current + if request, err := bashCompletionRequest(line, len(line)); err == nil && len(request.Words) == 1 { + current = request.Words[0] + } + } + if strings.HasSuffix(prefix, current) { + return prefix[:len(prefix)-len(current)] + } + return "" +} + // Bash owns expansion and execution. This lexer only removes simple quoting and // identifies the simple command surrounding the cursor; it never evaluates code. func bashCompletionRequest(line string, point int) (CompletionRequest, error) { diff --git a/completion_bash_test.go b/completion_bash_test.go new file mode 100644 index 0000000..461627a --- /dev/null +++ b/completion_bash_test.go @@ -0,0 +1,52 @@ +package cli + +import ( + "os/exec" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBashCompletionQuoteContextDoesNotEvaluateInput(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("Bash is not installed") + } + app := NewSimpleApplication("tool").WithCompletion(true).MustCreate() + defer func() { assert.NoError(t, app.Close()) }() + script, err := app.CompletionScript(CompletionBash) + require.NoError(t, err) + for _, test := range []struct{ line, want string }{ + {`tool --value plain`, ""}, + {`tool --value '`, "'"}, + {`tool --value "`, `"`}, + {`tool --value ''`, ""}, + {`tool --value ""`, ""}, + {`tool --value \'`, ""}, + {`tool --value "a\"`, `"`}, + {`tool --value "a\q"`, ""}, + {`tool --value '$HOME`, "'"}, + {`tool --value "$(printf literal)`, `"`}, + {`tool --value "\` + "`" + `literal`, `"`}, + {`tool --context 'über😀' --value "`, `"`}, + {`tool --value $'`, "ansi"}, + {`tool --value $'a\'`, "ansi"}, + {`tool $'a\'' --value "`, `"`}, + {`tool --value \$'`, "'"}, + } { + t.Run(test.line, func(t *testing.T) { + command := exec.Command("bash", "--noprofile", "--norc", "-c", script+` +COMP_LINE="$1" +COMP_POINT="$2" +[[ $(complete -p tool) =~ -F\ ([^\ ]+) ]] +quote= +"${BASH_REMATCH[1]}_quote_context" +printf '%s' "$quote" +`, "--", test.line, strconv.Itoa(len(test.line))) + output, err := command.CombinedOutput() + require.NoError(t, err, "%s", output) + assert.Equal(t, test.want, string(output)) + }) + } +} diff --git a/completion_protocol.go b/completion_protocol.go index d288976..ecbcfff 100644 --- a/completion_protocol.go +++ b/completion_protocol.go @@ -133,11 +133,10 @@ func (this *Application[O]) queryCompletion(ctx context.Context, opts *ParseOpts replacementPrefix := "" if err == nil && len(request.Words) > request.Word && request.Word >= 0 { prefix := request.Words[request.Word][:request.Offset] - if len(args) == 5 && strings.HasSuffix(prefix, args[4]) { - trim := prefix[:len(prefix)-len(args[4])] - replacementPrefix = trim + if len(args) == 5 { + replacementPrefix = bashCompletionReplacementPrefix(prefix, args[4]) for i := range result.Candidates { - result.Candidates[i].Value = strings.TrimPrefix(result.Candidates[i].Value, trim) + result.Candidates[i].Value = strings.TrimPrefix(result.Candidates[i].Value, replacementPrefix) } } } diff --git a/completion_protocol_test.go b/completion_protocol_test.go index 98c29e4..d21fba5 100644 --- a/completion_protocol_test.go +++ b/completion_protocol_test.go @@ -147,3 +147,23 @@ func TestBashCompletionInputQuotesSeparatorsUnicodeAndCursor(t *testing.T) { }) } } + +func TestBashCompletionReplacementPrefixRetainsLiteralWordBreaks(t *testing.T) { + for _, test := range []struct{ prefix, current, want string }{ + {"--format=ya", "ya", "--format="}, + {"--format=", "", "--format="}, + {"--format=", "''", "--format="}, + {"--format=", `""`, "--format="}, + {"--format=two wo", `'two wo'`, "--format="}, + {"--format=a'b", `"a'b"`, "--format="}, + {"--format=a'b", "a'b", "--format="}, + {"--format=$(printf literal)", `'$(printf literal)'`, "--format="}, + {"colon:fi", "fi", "colon:"}, + {"--format=ü", "ü", "--format="}, + {"--format=two wo", "wrong", ""}, + } { + t.Run(test.current, func(t *testing.T) { + assert.Equal(t, test.want, bashCompletionReplacementPrefix(test.prefix, test.current)) + }) + } +} diff --git a/completion_shell.go b/completion_shell.go index 89c7e28..3f30c32 100644 --- a/completion_shell.go +++ b/completion_shell.go @@ -39,6 +39,11 @@ const completionScriptHeader = "# Generated by cli4g shell completion protocol v // external completer. Its parameter accepts both legacy span lists and newer // named place records. It quotes literal candidates and preserves native Nu path // quoting; Nu controls whitespace insertion. Bash honors NoSpace and KeepOrder. +// Bash explicitly quotes provider values and native paths as literal arguments; +// Readline does not reinterpret suggestions as filenames or shell expansions. +// Simple unfinished single/double quotes and Readline word breaks are retained. +// Completion inside an ANSI-C quoted word is suppressed rather than treating its +// backslash escapes as simple single-quote syntax. // // PowerShell registers a native argument completer, including the .exe spelling. // Dot-source the script or use InstallCompletion for persistent profile loading. @@ -312,39 +317,6 @@ func completionDirectory(ctx context.Context, shell CompletionShell) (string, er return filepath.Join(data, "bash-completion", "completions"), nil } -const bashCompletionScript = ` -_@FUNCTION@() { - local command="$1" field mode path candidate i - local -a response=() - while IFS= read -r -d '' field; do - response+=("$field") - done < <("$command" @BASHREQUEST@ 1 bash "${COMP_POINT:-0}" "${COMP_LINE:-}" "${2:-}" 2>/dev/null) - COMPREPLY=() - ((${#response[@]} >= 7)) && [[ "${response[0]}" == 1 ]] || return 0 - [[ "${response[2]}" == 1 ]] && compopt -o nospace - [[ "${response[3]}" == 1 ]] && compopt -o nosort - for ((i=7; i<${#response[@]}; i++)); do - COMPREPLY+=("${response[i]}") - done - mode="${response[1]}" - if [[ "$mode" != 0 ]]; then - local action=-f - [[ "$mode" == 2 ]] && action=-d - while IFS= read -r path; do - candidate="${response[4]}$path" - candidate="${candidate#"${response[6]}"}" - if [[ -d "$path" ]]; then - candidate="${candidate%/}/" - compopt -o nospace - fi - COMPREPLY+=("$candidate") - done < <(compgen "$action" -- "${response[5]}") - fi - return 0 -} -complete -o filenames -F _@FUNCTION@ -- @BASHCOMMAND@ -` - const nushellCompletionScript = ` module @FUNCTION@ { def suggest [place] { diff --git a/completion_shell_test.go b/completion_shell_test.go index 9544538..fba31ad 100644 --- a/completion_shell_test.go +++ b/completion_shell_test.go @@ -89,13 +89,13 @@ if ((${#COMPREPLY[@]})); then printf '%s\0' "${COMPREPLY[@]}"; fi t.Run("bash", func(t *testing.T) { assertBash(t, name+" --format=ya", "ya", []string{"yaml"}) assertBash(t, name+" --format ü", "ü", []string{"über"}) - assertBash(t, name+" --format 'two wo", "two wo", []string{"two words"}) + assertBash(t, name+" --format 'two wo", "two wo", []string{"'two words'"}) assertBash(t, name+" --context cluster-b --namespace cl", "cl", []string{"cluster-b-namespace"}) assertBash(t, name+" get po", "po", []string{"pods"}) assertBash(t, name+" --initial", "--initial", []string{"--initial"}) assertBash(t, name+" --format nope", "nope", []string{}) assertBash(t, name+" --input=config", "config", []string{"config.yaml"}) - assertBash(t, name+` --input="space fi`, "space fi", []string{"space file.yaml"}) + assertBash(t, name+` --input="space fi`, "space fi", []string{`"space file.yaml"`}) if runtime.GOOS != "windows" { assertBash(t, name+" --input=colon:fi", "fi", []string{"file.yaml"}) } diff --git a/doc.go b/doc.go index b610da1..17e7756 100644 --- a/doc.go +++ b/doc.go @@ -159,6 +159,16 @@ // // tool completion bash // +// Bash quotes suggestions as literal arguments, including values containing +// dollar signs, backticks, quotes and whitespace. This applies to native path +// suggestions as well. Readline still controls menus and word replacement; it +// does not expand suggestion values or treat non-path suggestions as directories. +// Simple single/double quotes are supported; completion inside an ANSI-C quoted +// word is suppressed because the request lexer does not decode its escapes. +// After an adapter implementation update, rerun the install command and source +// the generated file (or start a new shell). Ordinary application schema updates +// do not require reinstalling adapters. +// // An explicit PowerShell installation directory writes only the adapter and does // not edit profiles. PowerShell sends literal AST values and UTF-8 byte cursor // offsets through an ASCII-safe Base64/JSON transport, including on Windows diff --git a/internal/shelltestcmd/bash_linux_test.go b/internal/shelltestcmd/bash_linux_test.go new file mode 100644 index 0000000..6eae90c --- /dev/null +++ b/internal/shelltestcmd/bash_linux_test.go @@ -0,0 +1,105 @@ +//go:build linux && shell_e2e + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +// Exercise the actual Readline buffer and native argument passing, not only +// COMPREPLY: Readline's filename quoting alone does not make values literal. +func testBashLiteralCompletion(t *testing.T) { + for _, candidate := range []string{ + "", "plain", "two words", "a'b", `a"b`, `a\b`, "über😀", "a\nb", "a\tb", + "$HOME", "${HOME}", "$(printf literal)", "`printf literal`", "a;literal", + "!literal", "*?[literal]", "~", "#literal", "(literal)", "trailing\\", "ends'", `ends"`, + } { + t.Run(candidate, func(t *testing.T) { + environment := newShellEnvironment(t) + environment.env = append(environment.env, "CLI4G_COMPLETION_LITERAL="+candidate) + terminal := environment.start("bash") + name := filepath.Base(environment.program) + for _, prefix := range []string{"", "'", `"`, "''", `""`} { + // The fixture's echo mode executes only after completion. The + // request still goes through the real adapter and CLI scanner. + completed := terminal.complete(name + " __echo-args --namespace " + prefix) + assertBashCompletedArguments(t, terminal, completed, "--namespace", candidate) + completed = terminal.complete(name + " __echo-args --namespace=" + prefix) + assertBashCompletedArguments(t, terminal, completed, "--namespace="+candidate) + } + for _, quote := range []string{"", "'", `"`} { + before := name + " __echo-args --namespace " + quote + line := before + quote + " --context unchanged" + completed := terminal.buffer(line, "\t", len([]rune(before))) + assertBashCompletedArguments(t, terminal, completed, "--namespace", candidate, "--context", "unchanged") + } + line := name + " __echo-args --namespace $'" + if completed := terminal.complete(line); completed != line { + t.Errorf("unsupported ANSI-C quote must not receive simple-quote syntax: %q", completed) + } + // A completed ANSI-C quote in an earlier word must not disable + // completion of the current, normally quoted argument. + completed := terminal.complete(name + " __echo-args --context $'earlier' --namespace ") + assertBashCompletedArguments(t, terminal, completed, "--context", "earlier", "--namespace", candidate) + environment.assertRequestsSkipInitialization() + }) + } +} + +func testBashLiteralPaths(t *testing.T) { + for _, name := range []string{"literal-$HOME", "literal-`name`", "literal-a'b", `literal-a"b`, `literal-a\b`, "literal-two words", "literal-!name"} { + t.Run(name, func(t *testing.T) { + environment := newShellEnvironment(t) + must(t, os.WriteFile(filepath.Join(environment.root, name), nil, 0600)) + terminal := environment.start("bash") + program := filepath.Base(environment.program) + for _, quote := range []string{"", "'", `"`} { + for _, flag := range []string{"--input ", "--input="} { + completed := terminal.complete(program + " __echo-args " + flag + quote + "literal-") + want := []string{"--input", name} + if flag == "--input=" { + want = []string{"--input=" + name} + } + assertBashCompletedArguments(t, terminal, completed, want...) + } + } + environment.assertRequestsSkipInitialization() + }) + } + t.Run("directory_and_child", func(t *testing.T) { + environment := newShellEnvironment(t) + const directory = "literal directory" + const child = "child file.yaml" + must(t, os.Mkdir(filepath.Join(environment.root, directory), 0700)) + must(t, os.WriteFile(filepath.Join(environment.root, directory, child), nil, 0600)) + terminal := environment.start("bash") + program := filepath.Base(environment.program) + for _, quote := range []string{"", "'", `"`} { + completed := terminal.complete(program + " __echo-args --input " + quote + "literal") + if strings.HasSuffix(completed, " ") { + t.Errorf("directory completion must not append a space: %q", completed) + } + assertBashCompletedArguments(t, terminal, completed, "--input", directory+"/") + completed = terminal.complete(completed + "chil") + assertBashCompletedArguments(t, terminal, completed, "--input", directory+"/"+child) + } + environment.assertRequestsSkipInitialization() + }) +} + +func assertBashCompletedArguments(t *testing.T, terminal *terminal, completed string, want ...string) { + t.Helper() + terminal.send("\r") + matched := terminal.expect("\r?\n(\\[.*?\\])\r?\n") + terminal.prompt() + var argv []string + must(t, json.Unmarshal([]byte(matched[1]), &argv)) + if !slices.Equal(argv, want) { + t.Errorf("literal completion changed arguments: want=%q got=%q buffer=%q", want, argv, completed) + } +} diff --git a/internal/shelltestcmd/container_linux_test.go b/internal/shelltestcmd/container_linux_test.go index eb950a7..9d09275 100644 --- a/internal/shelltestcmd/container_linux_test.go +++ b/internal/shelltestcmd/container_linux_test.go @@ -163,7 +163,7 @@ func TestContainerShellCompletion(t *testing.T) { {` --format "two wo`, ` --format "two words" `}, {" get po", " get pods "}, {" --context cluster-b --namespace cl", " --context cluster-b --namespace cluster-b-namespace "}, {" --input=config", " --input=config.yaml "}, {` --input="space fi`, ` --input="space file.yaml" `}, - {" --input=colon:fi", " --input=colon:file.yaml "}, {" --selector en", ` --selector env\=`}, + {" --input=colon:fi", " --input=colon:file.yaml "}, {" --selector en", " --selector env="}, } { // Keep one terminal alive across all requests: this also checks that // the line editor and loaded completion options reset correctly. @@ -207,6 +207,8 @@ func TestContainerShellCompletion(t *testing.T) { } environment.assertRequestsSkipInitialization() }) + t.Run("bash_literal_arguments", testBashLiteralCompletion) + t.Run("bash_literal_paths", testBashLiteralPaths) t.Run("nushell_reedline_and_binary_update", func(t *testing.T) { environment := newShellEnvironment(t) terminal := environment.start("nu") diff --git a/internal/testdata/completion/main.go b/internal/testdata/completion/main.go index b7fb9d9..b174e04 100644 --- a/internal/testdata/completion/main.go +++ b/internal/testdata/completion/main.go @@ -23,6 +23,9 @@ func (source namespaceCodec) ToRaw(ctx context.Context) codec.Codec[any, any] { } func (source namespaceCodec) Complete(ctx context.Context, input codec.CompletionInput) (codec.CompletionResult, error) { + if value, exists := os.LookupEnv("CLI4G_COMPLETION_LITERAL"); exists { + return codec.CompletionResult{Handled: true, Candidates: []codec.CompletionCandidate{{Value: value}}}, ctx.Err() + } cluster, _, err := input.Effective.Flag(ctx, "context") if err != nil { return codec.CompletionResult{}, err From 742f8812c481e63e582bcacbc46e7d694549decd Mon Sep 17 00:00:00 2001 From: Gregor Noczinski Date: Wed, 21 Oct 2026 12:04:08 +0200 Subject: [PATCH 10/14] Document and test Nushell cursor context limitations --- _examples/completion/README.md | 4 +- completion_protocol.go | 5 +- completion_protocol_test.go | 20 +++++ completion_shell.go | 12 ++- doc.go | 15 ++++ internal/shelltestcmd/container_linux_test.go | 1 + internal/shelltestcmd/nushell_linux_test.go | 87 +++++++++++++++++++ 7 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 internal/shelltestcmd/nushell_linux_test.go diff --git a/_examples/completion/README.md b/_examples/completion/README.md index ca96ae8..91a9797 100644 --- a/_examples/completion/README.md +++ b/_examples/completion/README.md @@ -42,6 +42,8 @@ ship completion install nu # ship --context production --namespace payments deploy ch ``` +Nu 0.114.1 supplies only input before the cursor to the completer. Put dependency flags before the value you complete; later arguments stay in the editor but cannot affect suggestions. + In **Windows PowerShell 5.1+ or PowerShell 7+**: ```powershell @@ -71,7 +73,7 @@ The standard handler completes `deploy`, flag names, the `development`/`producti - Namespace suggestions depend on the context and the selected service. - Service suggestions depend on the context and namespace. -cli4g supplies the complete input context, filters prefixes, and handles shell output. The callbacks return plain values; the same inventory is used by the normal deployment handler. A real client lookup would use the supplied cancellation context. Installed adapters continue to work after binary updates. +cli4g supplies the input context available from the shell, filters prefixes, and handles shell output. The callbacks return plain values; the same inventory is used by the normal deployment handler. A real client lookup would use the supplied cancellation context. Installed adapters continue to work after binary updates. The public setup command is the ordinary `DefaultCompletionCommand`, initially named `completion`. It uses the same lifecycle and parent requirements as `deploy`. Applications can replace it with `WithCompletionCommand(...)`, including a fresh `NewCompletionCommand().WithName("shells")`. Only the separate request entry (initially `__complete`) bypasses normal validation and initialization. Configure it with `WithCompletionRequestCommandName`; empty disables it. diff --git a/completion_protocol.go b/completion_protocol.go index ecbcfff..2bc2131 100644 --- a/completion_protocol.go +++ b/completion_protocol.go @@ -29,7 +29,10 @@ import ( // - __complete 1 nu: stdin contains exactly one JSON CompletionRequest, limited to // 1 MiB. An optional raw=true additionally removes simple Nu literal quoting // from span tokens; it never evaluates expressions. stdout is a JSON -// codec.CompletionResult. Input excludes the executable. +// codec.CompletionResult. Input excludes the executable. The Nu 0.114.1 +// adapter receives only arguments before the cursor; dependency flags must +// precede the value being completed. The protocol itself accepts later words +// when supplied by a caller. // - __complete 1 powershell: the same JSON request/result as nu, without raw // token normalization, each wrapped in standard Base64. The decoded request // is limited to 1 MiB. ASCII-only transport preserves Unicode even with diff --git a/completion_protocol_test.go b/completion_protocol_test.go index d21fba5..846839b 100644 --- a/completion_protocol_test.go +++ b/completion_protocol_test.go @@ -50,6 +50,26 @@ func TestCompletionProtocolRejectsMalformedRequestsQuietly(t *testing.T) { } } +func TestNushellProtocolRetainsDependenciesAfterCursorWhenSupplied(t *testing.T) { + provider := codec.WithCompletionHandler(codec.NewString(), func(ctx context.Context, input codec.CompletionInput) (codec.CompletionResult, error) { + value, _, err := input.Effective.Flag(ctx, "context") + result := codec.CompletionResult{Handled: true} + for _, name := range value.Values { + result.Candidates = append(result.Candidates, codec.CompletionCandidate{Value: name + "-namespace"}) + } + return result, err + }) + app := NewSimpleApplication("tool").WithCompletion(true). + WithFlag(NewFlag(codec.NewString(), "context")).WithFlag(NewFlag(provider, "namespace")).MustCreate() + defer func() { assert.NoError(t, app.Close()) }() + var output bytes.Buffer + ctx := ContextWithStdio(nil, strings.NewReader(`{"words":["--namespace","cl","--context","cluster-b"],"word":1,"offset":2,"raw":true}`), &output, nil) + require.NoError(t, app.ParseAndExecute(ctx, nil, DefaultCompletionRequestCommandName, "1", "nu")) + var result codec.CompletionResult + require.NoError(t, json.Unmarshal(output.Bytes(), &result)) + assert.Equal(t, []string{"cluster-b-namespace"}, completionTexts(result)) +} + func TestCompletionInstallationIsIdempotentAndProtectsExistingFiles(t *testing.T) { app := NewSimpleApplication("tool").WithCompletionRequestCommandName(DefaultCompletionRequestCommandName).MustCreate() defer func() { assert.NoError(t, app.Close()) }() diff --git a/completion_shell.go b/completion_shell.go index 3f30c32..b3c641f 100644 --- a/completion_shell.go +++ b/completion_shell.go @@ -38,7 +38,14 @@ const completionScriptHeader = "# Generated by cli4g shell completion protocol v // signature. It preserves native execution and does not replace the user's global // external completer. Its parameter accepts both legacy span lists and newer // named place records. It quotes literal candidates and preserves native Nu path -// quoting; Nu controls whitespace insertion. Bash honors NoSpace and KeepOrder. +// quoting; Nu controls whitespace insertion. Nu 0.114.1 supplies only the command +// prefix before the cursor: dependency flags must precede the value being completed. +// Later arguments remain in the editor but are unavailable to the provider. The +// adapter does not read commandline/get-cursor, whose REPL state can be stale +// during completion, or change global keybindings to capture a full buffer. This +// shell limitation does not restrict Complete callers supplying all input words. +// +// Bash honors NoSpace and KeepOrder. // Bash explicitly quotes provider values and native paths as literal arguments; // Readline does not reinterpret suggestions as filenames or shell expansions. // Simple unfinished single/double quotes and Readline word breaks are retained. @@ -323,6 +330,9 @@ module @FUNCTION@ { let words = if (($place | describe) | str starts-with "record") { $place.command } else { $place } if ($words | length) < 2 { return [] } let args = ($words | skip 1) + # Nu 0.114.1 truncates command-wide spans at the cursor, so the last + # supplied word is current. Later dependency flags are not available. + # Do not reconstruct them from commandline: its REPL state can be stale. let current = ($args | last) let request = {words: $args, word: (($args | length) - 1), offset: ($current | str length --utf-8-bytes), raw: true} let response = try { diff --git a/doc.go b/doc.go index 17e7756..99331d9 100644 --- a/doc.go +++ b/doc.go @@ -169,6 +169,21 @@ // the generated file (or start a new shell). Ordinary application schema updates // do not require reinstalling adapters. // +// Nushell's command-wide completion input, verified with Nu 0.114.1, stops at the +// cursor. Dependencies must therefore precede the value being completed: +// +// tool --context cluster-b --namespace cl +// +// Completing an earlier argument preserves later arguments in the editor, but +// those later arguments are not visible to the provider. For example, completing +// --namespace before a later --context cannot use that context; an available +// environment/default dependency is used instead, or the dependency is absent. +// The adapter does not reconstruct missing input from commandline/get-cursor, +// whose REPL state can be stale during completion, and does not modify global +// keybindings to capture the buffer. This is a native Nu input limitation, not +// a restriction of [Application.Complete] or its protocol: callers supplying +// words after the cursor retain the full dependency context. +// // An explicit PowerShell installation directory writes only the adapter and does // not edit profiles. PowerShell sends literal AST values and UTF-8 byte cursor // offsets through an ASCII-safe Base64/JSON transport, including on Windows diff --git a/internal/shelltestcmd/container_linux_test.go b/internal/shelltestcmd/container_linux_test.go index 9d09275..0085b60 100644 --- a/internal/shelltestcmd/container_linux_test.go +++ b/internal/shelltestcmd/container_linux_test.go @@ -229,6 +229,7 @@ func TestContainerShellCompletion(t *testing.T) { } environment.assertRequestsSkipInitialization() }) + t.Run("nushell_cursor_context", testNushellCursorContext) t.Run("zsh_zle", testZshCompletion) t.Run("zsh_custom_zdotdir", testZshCustomStartup) t.Run("fish_reader", testFishCompletion) diff --git a/internal/shelltestcmd/nushell_linux_test.go b/internal/shelltestcmd/nushell_linux_test.go new file mode 100644 index 0000000..7e9e928 --- /dev/null +++ b/internal/shelltestcmd/nushell_linux_test.go @@ -0,0 +1,87 @@ +//go:build linux && shell_e2e + +package main + +import ( + "encoding/json" + "path/filepath" + "slices" + "strings" + "testing" +) + +func testNushellCursorContext(t *testing.T) { + environment := newShellEnvironment(t) + terminal := environment.start("nu") + name := filepath.Base(environment.program) + // Nu 0.114.1 passes only the command prefix to command-wide completers. + // These version-specific cases document that native boundary, not a limit + // of the Go completion API. Revisit them if Nu starts supplying later words. + // Reuse one terminal so stale commandline snapshots cannot pass unnoticed. + for _, test := range []struct { + name, before, after, want string + wantArgs []string + }{ + { + name: "dependency_before_cursor", before: " --context cluster-b --namespace cl", after: " --format yaml", + want: " --context cluster-b --namespace cluster-b-namespace --format yaml", + }, + { + name: "inline_dependency", before: " --context=cluster-b --namespace=cl", after: " --format yaml", + want: " --context=cluster-b --namespace=cluster-b-namespace --format yaml", + }, + { + name: "unicode_cursor", before: " --context über --namespace ü", after: " --format yaml", + want: " --context über --namespace über-namespace --format yaml", + wantArgs: []string{"--context", "über", "--namespace", "über-namespace", "--format", "yaml"}, + }, + { + name: "quoted_dependency", before: " --context 'cluster b' --namespace cl", after: " --format 'two words'", + want: ` --context 'cluster b' --namespace "cluster b-namespace" --format 'two words'`, + wantArgs: []string{"--context", "cluster b", "--namespace", "cluster b-namespace", "--format", "two words"}, + }, + { + name: "later_dependency_unavailable", before: " --namespace ", after: " --context cluster-b", + want: " --namespace production --context cluster-b", + }, + { + name: "later_dependency_does_not_match_prefix", before: " --namespace cl", after: " --context cluster-b", + want: " --namespace cl --context cluster-b", + }, + { + name: "prefix_uses_available_context", before: " --namespace pr", after: " --context cluster-b", + want: " --namespace production --context cluster-b", + }, + { + name: "inline_later_dependency", before: " --namespace=", after: " --context cluster-b", + want: " --namespace=production --context cluster-b", + }, + { + name: "unicode_later_dependency", before: " --namespace ü", after: " --context über", + want: " --namespace ü --context über", + }, + { + name: "quoted_later_dependency", before: " --namespace cl", after: " --context 'cluster b'", + want: " --namespace cl --context 'cluster b'", + }, + { + name: "fresh_dependency_in_same_session", before: " --context cluster-a --namespace cl", after: " --format json", + want: " --context cluster-a --namespace cluster-a-namespace --format json", + }, + } { + line := name + test.before + test.after + completed := terminal.buffer(line, "\t", len([]rune(name+test.before))) + if strings.TrimRight(completed, " ") != name+test.want { + t.Errorf("%s: got=%q want=%q", test.name, completed, name+test.want) + } + if test.wantArgs != nil { + echoed := strings.Replace(completed, name, name+" __echo-args", 1) + var args []string + must(t, json.Unmarshal(environment.output("nu", "-n", "-c", echoed), &args)) + if !slices.Equal(args, test.wantArgs) { + t.Errorf("%s native arguments: got=%q want=%q", test.name, args, test.wantArgs) + } + } + } + environment.assertRequestsSkipInitialization() +} From 24d11d9e6adb10898a011469efd3bdb734cad4a9 Mon Sep 17 00:00:00 2001 From: Gregor Noczinski Date: Wed, 21 Oct 2026 12:26:32 +0200 Subject: [PATCH 11/14] Preserve literal PowerShell stop-parsing completion values --- completion_powershell.ps1 | 8 ++ completion_shell.go | 3 + doc.go | 7 +- .../shelltestcmd/powershell_linux_test.go | 2 + .../powershell_literal_linux_test.go | 82 +++++++++++++++++++ 5 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 internal/shelltestcmd/powershell_literal_linux_test.go diff --git a/completion_powershell.ps1 b/completion_powershell.ps1 index b2791e8..41d3763 100644 --- a/completion_powershell.ps1 +++ b/completion_powershell.ps1 @@ -10,6 +10,11 @@ Register-ArgumentCompleter -CommandName @(@PSCOMMAND@, @PSEXE@) -ScriptBlock { $element.NestedExpressions.Count -eq 0)) { return [string]$element.Value } + # Decode only our fixed literal wrapper, never evaluate arbitrary ASTs. + if ($element -is [System.Management.Automation.Language.ArrayExpressionAst] -and + $element.Extent.Text -ceq "@('--%')") { + return '--%' + } return [string]$element.Extent.Text } function Read-Prefix([string]$text) { @@ -21,6 +26,9 @@ Register-ArgumentCompleter -CommandName @(@PSCOMMAND@, @PSEXE@) -ScriptBlock { return $text } function Quote-Literal([string]$value) { + # The native binder removes even a quoted scalar --% and changes how + # later arguments are passed. A fixed singleton array avoids that path. + if ($value -ceq '--%') { return "@('--%')" } if ($value -match '^[-\p{L}\p{N}_./:\\=+]+$') { return $value } # Legacy native argument passing strips unescaped embedded double quotes. if ($PSVersionTable.PSVersion -lt [version]'7.3' -or $PSNativeCommandArgumentPassing -eq 'Legacy') { diff --git a/completion_shell.go b/completion_shell.go index b3c641f..cb49e7e 100644 --- a/completion_shell.go +++ b/completion_shell.go @@ -59,6 +59,9 @@ const completionScriptHeader = "# Generated by cli4g shell completion protocol v // context-aware providers. Candidates are quoted as literal native arguments and // carry descriptions; path completion uses PowerShell's own filename completer // (filesystem enumeration for literal Unix colon paths). +// The reserved literal --% uses a fixed singleton-array expression so native +// binding does not drop it or change later arguments. That exact generated AST +// form is decoded as literal data in subsequent requests, without evaluation. // Returned order is retained; the user's PSReadLine completion binding controls // space insertion, so NoSpace does not change that binding. Scripts use UTF-8 with // a BOM for Windows PowerShell 5.1 and an ASCII-only request/response transport. diff --git a/doc.go b/doc.go index 99331d9..fcab649 100644 --- a/doc.go +++ b/doc.go @@ -190,7 +190,12 @@ // PowerShell 5.1's legacy native pipeline. Tokens after the cursor remain available // for dependencies. Variables and expressions are not evaluated to collect values. // Candidates include descriptions and use literal quoting, including legacy native -// argument escaping. Paths normally use PowerShell's filename completer; literal +// argument escaping. PowerShell removes even a quoted scalar --% during native +// argument binding, so this literal value uses the fixed expression @('--%'). +// Its singleton array passes exactly one native argument without enabling verbatim +// handling of later arguments. The collector recognizes only that exact generated +// array form as literal --%; other expressions remain unevaluated dependency data. +// Paths normally use PowerShell's filename completer; literal // Unix colon filenames use filesystem enumeration because the provider interprets // colons as drives. Candidate order is preserved. The user's PSReadLine binding // determines whitespace insertion; the adapter does not change that binding. diff --git a/internal/shelltestcmd/powershell_linux_test.go b/internal/shelltestcmd/powershell_linux_test.go index 1900cec..552006c 100644 --- a/internal/shelltestcmd/powershell_linux_test.go +++ b/internal/shelltestcmd/powershell_linux_test.go @@ -106,6 +106,8 @@ func TestContainerPowerShellCompletion(t *testing.T) { } } } + t.Run("literal_stop_parsing_marker", testPowerShellStopParsingLiteral) + t.Run("literal_stop_parsing_dependency", testPowerShellStopParsingDependency) if got := terminal.complete(name + " --ini"); got != name+" --initial" { t.Errorf("initial binary completion: %q", got) } diff --git a/internal/shelltestcmd/powershell_literal_linux_test.go b/internal/shelltestcmd/powershell_literal_linux_test.go new file mode 100644 index 0000000..dbd097c --- /dev/null +++ b/internal/shelltestcmd/powershell_literal_linux_test.go @@ -0,0 +1,82 @@ +//go:build linux && shell_e2e + +package main + +import ( + "encoding/json" + "path/filepath" + "slices" + "strings" + "testing" +) + +func testPowerShellStopParsingLiteral(t *testing.T) { + environment := newShellEnvironment(t) + environment.env = append(environment.env, "CLI4G_COMPLETION_LITERAL=--%", "CLI4G_MARKER_SENTINEL=must-not-expand") + terminal := environment.start("powershell") + name := filepath.Base(environment.program) + for _, mode := range []string{"Standard", "Legacy"} { + terminal.send("$PSNativeCommandArgumentPassing = '" + mode + "'\r") + terminal.prompt() + for _, test := range []struct { + before, after string + want []string + }{ + {" --namespace ", "", []string{"--namespace", "--%"}}, + {" --namespace ", " --context 'two words'", []string{"--namespace", "--%", "--context", "two words"}}, + {" --namespace ", " --context '%CLI4G_MARKER_SENTINEL%'", []string{"--namespace", "--%", "--context", "%CLI4G_MARKER_SENTINEL%"}}, + {" --namespace '", "' --context 'two words'", []string{"--namespace", "--%", "--context", "two words"}}, + {" --namespace=", " --context 'two words'", []string{"--namespace=--%", "--context", "two words"}}, + } { + line := name + test.before + test.after + completed := terminal.buffer(line, "\t", len(name+test.before)) + assertPowerShellCompletedArguments(t, environment, mode, completed, test.want...) + } + // Exercise actual Tab followed by Enter as well as executing the dumped + // completion text. The fixture's echo mode never runs business handlers. + terminal.send(name + " __echo-args --namespace \t --context 'two words'\r") + matched := terminal.expect("(\\[\"--namespace\"[^\r\n]*\\])\r?\n") + terminal.prompt() + var args []string + must(t, json.Unmarshal([]byte(matched[1]), &args)) + if want := []string{"--namespace", "--%", "--context", "two words"}; !slices.Equal(args, want) { + t.Errorf("Tab/Enter (%s): got=%q want=%q", mode, args, want) + } + } + environment.assertRequestsSkipInitialization() +} + +func testPowerShellStopParsingDependency(t *testing.T) { + environment := newShellEnvironment(t) + terminal := environment.start("powershell") + name := filepath.Base(environment.program) + for _, mode := range []string{"Standard", "Legacy"} { + terminal.send("$PSNativeCommandArgumentPassing = '" + mode + "'\r") + terminal.prompt() + for _, test := range []struct { + before, after string + want []string + }{ + {" --context @('--%') --namespace ", "", []string{"--context", "--%", "--namespace", "--%-namespace"}}, + {" --namespace ", " --context @('--%')", []string{"--namespace", "--%-namespace", "--context", "--%"}}, + {" --context '@(''--%'')' --namespace ", "", []string{"--context", "@('--%')", "--namespace", "@('--%')-namespace"}}, + } { + line := name + test.before + test.after + completed := terminal.buffer(line, "\t", len(name+test.before)) + assertPowerShellCompletedArguments(t, environment, mode, completed, test.want...) + } + } + environment.assertRequestsSkipInitialization() +} + +func assertPowerShellCompletedArguments(t *testing.T, environment *shellEnvironment, mode, completed string, want ...string) { + t.Helper() + name := filepath.Base(environment.program) + echoed := strings.Replace(completed, name, name+" __echo-args", 1) + command := "$PSNativeCommandArgumentPassing = '" + mode + "'\n" + echoed + var args []string + must(t, json.Unmarshal(environment.output("pwsh", "-NoLogo", "-NoProfile", "-Command", command), &args)) + if !slices.Equal(args, want) { + t.Errorf("native arguments (%s): got=%q want=%q buffer=%q", mode, args, want, completed) + } +} From cb7f8e99cfe5106b865927c59e8be6f8046c6b47 Mon Sep 17 00:00:00 2001 From: Gregor Noczinski Date: Wed, 21 Oct 2026 13:12:28 +0200 Subject: [PATCH 12/14] Make Bash completion tests portable across hosts and locales --- completion_bash_test.go | 37 +++++++++++++++++++++++++++---------- completion_shell_test.go | 40 +++++++++++++++++++++++++--------------- 2 files changed, 52 insertions(+), 25 deletions(-) diff --git a/completion_bash_test.go b/completion_bash_test.go index 461627a..239f657 100644 --- a/completion_bash_test.go +++ b/completion_bash_test.go @@ -1,7 +1,9 @@ package cli import ( + "os" "os/exec" + "path/filepath" "strconv" "testing" @@ -9,14 +11,35 @@ import ( "github.com/stretchr/testify/require" ) -func TestBashCompletionQuoteContextDoesNotEvaluateInput(t *testing.T) { - if _, err := exec.LookPath("bash"); err != nil { +func bashForCompletionTest(t *testing.T) string { + t.Helper() + bash, err := exec.LookPath("bash") + if err != nil { t.Skip("Bash is not installed") } + if err := exec.Command(bash, "--noprofile", "--norc", "-c", "((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4)))").Run(); err != nil { + t.Skip("Bash 4.4 or newer is required") + } + return bash +} + +func TestBashCompletionQuoteContextDoesNotEvaluateInput(t *testing.T) { + bash := bashForCompletionTest(t) app := NewSimpleApplication("tool").WithCompletion(true).MustCreate() defer func() { assert.NoError(t, app.Close()) }() script, err := app.CompletionScript(CompletionBash) require.NoError(t, err) + // A script file avoids Windows/MSYS argument decoding of a large -c string. + // Pass test inputs through the environment to preserve quotes/backslashes too. + path := filepath.Join(t.TempDir(), "quote-context.bash") + require.NoError(t, os.WriteFile(path, []byte(script+` +COMP_LINE="$CLI4G_TEST_LINE" +COMP_POINT="$CLI4G_TEST_POINT" +[[ $(complete -p tool) =~ -F\ ([^\ ]+) ]] || exit 1 +quote= +"${BASH_REMATCH[1]}_quote_context" || exit 1 +printf '%s' "$quote" +`), 0600)) for _, test := range []struct{ line, want string }{ {`tool --value plain`, ""}, {`tool --value '`, "'"}, @@ -36,14 +59,8 @@ func TestBashCompletionQuoteContextDoesNotEvaluateInput(t *testing.T) { {`tool --value \$'`, "'"}, } { t.Run(test.line, func(t *testing.T) { - command := exec.Command("bash", "--noprofile", "--norc", "-c", script+` -COMP_LINE="$1" -COMP_POINT="$2" -[[ $(complete -p tool) =~ -F\ ([^\ ]+) ]] -quote= -"${BASH_REMATCH[1]}_quote_context" -printf '%s' "$quote" -`, "--", test.line, strconv.Itoa(len(test.line))) + command := exec.Command(bash, "--noprofile", "--norc", path) + command.Env = append(os.Environ(), "CLI4G_TEST_LINE="+test.line, "CLI4G_TEST_POINT="+strconv.Itoa(len(test.line))) output, err := command.CombinedOutput() require.NoError(t, err, "%s", output) assert.Equal(t, test.want, string(output)) diff --git a/completion_shell_test.go b/completion_shell_test.go index fba31ad..cd2aa37 100644 --- a/completion_shell_test.go +++ b/completion_shell_test.go @@ -46,29 +46,39 @@ func TestNativeShellCompletion(t *testing.T) { require.NoError(t, err) paths[shell] = path } - assertBash := func(t *testing.T, line, current string, want []string) { - t.Helper() - if _, err := exec.LookPath("bash"); err != nil { - t.Skip("Bash is not installed") - } - if err := exec.Command("bash", "-c", "((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4)))").Run(); err != nil { - t.Skip("Bash 4.4 or newer is required") - } - command := exec.Command("bash", "--noprofile", "--norc", "-c", ` -source "$1" + bashDriver := filepath.Join(directory, "complete.bash") + require.NoError(t, os.WriteFile(bashDriver, []byte(`source "$1" compopt() { :; } COMP_LINE="$2" COMP_POINT="$5" -[[ $(complete -p cli4g-completion-test) =~ -F\ ([^\ ]+) ]] +[[ $(complete -p cli4g-completion-test) =~ -F\ ([^\ ]+) ]] || exit 1 "${BASH_REMATCH[1]}" "$3" "$4" "" if ((${#COMPREPLY[@]})); then printf '%s\0' "${COMPREPLY[@]}"; fi -`, "--", paths[CompletionBash], line, binary, current, strconv.Itoa(len(line))) +`), 0600)) + assertBash := func(t *testing.T, line, current string, want []string) { + t.Helper() + bash := bashForCompletionTest(t) + command := exec.Command(bash, "--noprofile", "--norc", bashDriver, paths[CompletionBash], line, binary, current, strconv.Itoa(len(line))) command.Dir = directory + // Exercise %q's C-locale escaping regardless of the host locale. The + // mandatory terminal suite separately covers the UTF-8 locale. + command.Env = append(os.Environ(), "LC_ALL=C") output, err := command.CombinedOutput() require.NoError(t, err, "%s", output) values := []string{} if len(output) > 0 { - values = strings.Split(strings.TrimSuffix(string(output), "\x00"), "\x00") + quoted := strings.Split(strings.TrimSuffix(string(output), "\x00"), "\x00") + // These controlled fixture suggestions are shell syntax. Execute them + // through the echo fixture to compare native arguments, not %q's + // locale-dependent spelling (for example über vs $'\303\274ber'). + arguments := filepath.Join(directory, "completion-arguments.bash") + require.NoError(t, os.WriteFile(arguments, []byte("exec \"$1\" __echo-args "+strings.Join(quoted, " ")+"\n"), 0600)) + command = exec.Command(bash, "--noprofile", "--norc", arguments, binary) + command.Dir = directory + command.Env = append(os.Environ(), "LC_ALL=C") + output, err = command.CombinedOutput() + require.NoError(t, err, "%s", output) + require.NoError(t, json.Unmarshal(output, &values), "%s", output) } assert.Equal(t, want, values) } @@ -89,13 +99,13 @@ if ((${#COMPREPLY[@]})); then printf '%s\0' "${COMPREPLY[@]}"; fi t.Run("bash", func(t *testing.T) { assertBash(t, name+" --format=ya", "ya", []string{"yaml"}) assertBash(t, name+" --format ü", "ü", []string{"über"}) - assertBash(t, name+" --format 'two wo", "two wo", []string{"'two words'"}) + assertBash(t, name+" --format 'two wo", "two wo", []string{"two words"}) assertBash(t, name+" --context cluster-b --namespace cl", "cl", []string{"cluster-b-namespace"}) assertBash(t, name+" get po", "po", []string{"pods"}) assertBash(t, name+" --initial", "--initial", []string{"--initial"}) assertBash(t, name+" --format nope", "nope", []string{}) assertBash(t, name+" --input=config", "config", []string{"config.yaml"}) - assertBash(t, name+` --input="space fi`, "space fi", []string{`"space file.yaml"`}) + assertBash(t, name+` --input="space fi`, "space fi", []string{"space file.yaml"}) if runtime.GOOS != "windows" { assertBash(t, name+" --input=colon:fi", "fi", []string{"file.yaml"}) } From 47778beea7e34733800b5bee47f1c0bad676e258 Mon Sep 17 00:00:00 2001 From: Gregor Noczinski Date: Wed, 21 Oct 2026 14:12:54 +0200 Subject: [PATCH 13/14] Update shell completion capability rating --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4859f9b..05f6f29 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ Qualitative ratings for the [compared versions](_benchmarks): | Lifecycle and resource cleanup | ★★★ | ★★☆ | ★★☆ | ★★☆ | — | | Compile once, isolated concurrent parses | ★★★ | — | — | — | — | | Per-parse schema profiles | ★★★ | — | — | — | — | -| Shell completion | ★★☆ | ★★★ | ★★★ | — | — | +| Shell completion | ★★★ | ★★★ | ★★★ | — | — | | Setup/compile + first invocation speed[^performance] | ★★☆ | ★★☆ | ★★★ | ★☆☆ | ★★★ | | Parse/execute speed on reuse[^performance] | ★★★ | ★★☆ | ★★★ | ★☆☆ | ★★★ | From 22fb7ff9dadb13db0942cfd0ed726e488d947ac6 Mon Sep 17 00:00:00 2001 From: Gregor Noczinski Date: Wed, 21 Oct 2026 14:14:13 +0200 Subject: [PATCH 14/14] Improve README.md --- README.md | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/README.md b/README.md index 05f6f29..f63b128 100644 --- a/README.md +++ b/README.md @@ -97,33 +97,6 @@ includes rollback after partial initialization failures; hooks alone need applic See [benchmarks and methodology](docs/benchmarks.md) for timings, allocations, code size, and cognitive complexity. -## Shell completion - -Bash **4.4+**, Fish **3.6+**, Nushell **0.114+**, PowerShell **5.1+**, and Zsh **5.8+** -adapters query the current binary, so binary updates need no completion reinstall. -Install once with: - -```shell -your-app completion install bash # Requires a configured bash-completion loader -your-app completion install fish # Autoloaded on the next completion in a new session -your-app completion install nu # Autoloaded at the next interactive Nu startup -your-app completion install powershell # Loaded through the user profiles on next startup -your-app completion install zsh # Configures fpath/autoloading for the next Zsh session -``` - -`WithCompletion(true)` selects `DefaultCompletionCommand` and -`DefaultCompletionRequestCommandName`. The public command is a normal command; -replace it with `WithCompletionCommand(...)`, or rename a fresh -`NewCompletionCommand().WithName(...)`. The special adapter entry is configured -independently with `WithCompletionRequestCommandName(...)`. Nil/empty disables -the respective part; `WithCompletion(false)` clears both. -Commands, flags, enums, and files work automatically. Use -[`codec.WithCompletionHandler`](https://pkg.go.dev/github.com/echocat/cli4g/codec#WithCompletionHandler) -for domain-specific values, or set a handler on an individual flag/argument. -See the [GoDoc guide](https://pkg.go.dev/github.com/echocat/cli4g#hdr-Shell_completion) -for API, installation, protocol, lifecycle, and performance details. -The [completion example](_examples/completion) shows this on the `ship` deployment tool's CLI. - ## Documentation - [Struct-tag/Annotations reference](docs/annotations.md)