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..8a9212b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,15 +17,25 @@ 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: ${{ startsWith(matrix.os, 'macos-') && '2' || '4' }} + GOMEMLIMIT: ${{ startsWith(matrix.os, 'macos-') && '4GiB' || 'off' }} 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 @@ -64,11 +74,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: ${{ startsWith(matrix.os, 'macos-') && '2' || '4' }} steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -93,6 +104,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 @@ -117,6 +132,36 @@ 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, 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 + profile.shell-runner.cov + retention-days: 1 + if-no-files-found: error + quality: name: Quality gate runs-on: ubuntu-latest @@ -147,16 +192,23 @@ jobs: coverage: name: Coverage - needs: test + needs: [ test, shell-e2e ] 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: Download coverage profiles uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - pattern: tests-go-* + pattern: tests-* path: coverage - name: Collect coverage profiles @@ -181,7 +233,24 @@ jobs: profiles+=("$profile") done done - printf 'files=%s\n' "${profiles[*]}" >> "$GITHUB_OUTPUT" + + shell_directories=(coverage/tests-shells-*) + if [[ ${#shell_directories[@]} -eq 0 ]]; then + echo "::error::No shell E2E coverage artifact was downloaded." + exit 1 + fi + 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 + 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 @@ -193,7 +262,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 +272,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/.gitignore b/.gitignore index 42a1690..542b74c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ 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/README.md b/README.md index 6d38197..f63b128 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] | ★★★ | ★★☆ | ★★★ | ★☆☆ | ★★★ | 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..91a9797 --- /dev/null +++ b/_examples/completion/README.md @@ -0,0 +1,80 @@ +# 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 +``` + +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 +ship completion install powershell +# Start a new PowerShell session, then type and press Tab: +# 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 +``` + +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. +- Service suggestions depend on the context and namespace. + +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. + +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_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/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..d421a26 100644 --- a/codec/raw.go +++ b/codec/raw.go @@ -172,9 +172,20 @@ 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 { + if reflect.TypeFor[V]().Kind() == reflect.Bool { + return true + } trait, ok := this.source.(interface{ IsBoolFlag() bool }) return ok && trait.IsBoolFlag() } 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_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/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 new file mode 100644 index 0000000..5f51675 --- /dev/null +++ b/completion_bash_input.go @@ -0,0 +1,89 @@ +package cli + +import ( + "fmt" + "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) { + 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_bash_test.go b/completion_bash_test.go new file mode 100644 index 0000000..239f657 --- /dev/null +++ b/completion_bash_test.go @@ -0,0 +1,69 @@ +package cli + +import ( + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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 '`, "'"}, + {`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", 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_command.go b/completion_command.go new file mode 100644 index 0000000..0ba3298 --- /dev/null +++ b/completion_command.go @@ -0,0 +1,153 @@ +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, 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|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, +// 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, 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) + 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..baebc86 --- /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, 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", "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, 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)) + 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", "fish", "install", "nu", "powershell", "zsh"}, 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, CompletionFish, CompletionNushell, CompletionPowerShell, CompletionZsh} { + 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_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..08e2322 --- /dev/null +++ b/completion_fish_input.go @@ -0,0 +1,190 @@ +package cli + +import ( + "fmt" + "strconv" + "strings" + "unicode/utf8" +) + +// 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, err := fishCompletionEscape(line[i:], quote) + if err != nil { + return CompletionRequest{}, err + } + 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, error) { + char := input[1] + if char == '\n' { + return "", 2, nil + } + if quote != 0 { + 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, nil + } + if char == 'c' && len(input) >= 3 { + return string(input[2] & 0x1f), 3, nil + } + 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 { + 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([]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, nil +} + +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..9fe1d03 --- /dev/null +++ b/completion_fish_test.go @@ -0,0 +1,109 @@ +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 \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", "")}, + {`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 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). + 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"}, + {"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()) + assert.Empty(t, diagnostics.String()) + } +} 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_powershell.go b/completion_powershell.go new file mode 100644 index 0000000..53f7461 --- /dev/null +++ b/completion_powershell.go @@ -0,0 +1,62 @@ +package cli + +import ( + "context" + "crypto/sha256" + _ "embed" + "encoding/base64" + "fmt" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +//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 { + 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" + return appendCompletionProfile(ctx, profile, marker, block) +} diff --git a/completion_powershell.ps1 b/completion_powershell.ps1 new file mode 100644 index 0000000..41d3763 --- /dev/null +++ b/completion_powershell.ps1 @@ -0,0 +1,111 @@ +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 + } + # 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) { + $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) { + # 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') { + $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_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 new file mode 100644 index 0000000..2bc2131 --- /dev/null +++ b/completion_protocol.go @@ -0,0 +1,238 @@ +package cli + +import ( + "bytes" + "context" + "encoding/base64" + "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. 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 +// 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. +// - __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 +// 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 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]) { + 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) || 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 { + return err + } + } + return nil + 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 { + replacementPrefix = bashCompletionReplacementPrefix(prefix, args[4]) + for i := range result.Candidates { + result.Candidates[i].Value = strings.TrimPrefix(result.Candidates[i].Value, replacementPrefix) + } + } + } + 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, CompletionPowerShell: + var request struct { + CompletionRequest + Raw bool `json:"raw,omitempty"` + } + if input := codec.StdinOfContext(ctx); input != nil { + 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 && readErr == nil && len(data) <= 1<<20 { + var trailing any + if decoder.Decode(&trailing) == io.EOF { + if request.Raw && CompletionShell(args[1]) == CompletionNushell { + unquoteNuCompletion(&request.CompletionRequest) + } + if completed, err := this.Complete(ctx, opts, request.CompletionRequest); err == nil { + result = completed + } + } + } + } + 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 +} + +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..846839b --- /dev/null +++ b/completion_protocol_test.go @@ -0,0 +1,189 @@ +package cli + +import ( + "bytes" + "context" + "encoding/base64" + "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 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()) }() + for _, shell := range []CompletionShell{CompletionBash, CompletionFish, 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.Contains(t, 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("unsupported-shell") + require.Error(t, err) + _, err = NewSimpleApplication("../bad").MustCreate().InstallCompletion(nil, CompletionBash, t.TempDir()) + 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 }). + 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) + }) + } +} + +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_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..cb49e7e --- /dev/null +++ b/completion_shell.go @@ -0,0 +1,367 @@ +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. + 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. +) + +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. 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. +// 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. +// 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). +// 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. +// +// 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. +// +// 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 +// 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 CompletionFish: + template = fishCompletionScript + case CompletionNushell: + template = nushellCompletionScript + case CompletionPowerShell: + template = powershellCompletionScript + case CompletionZsh: + template = zshCompletionScript + default: + return "", NewValidationError("supported completion shells are bash, fish, 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, + "@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()), + "@ZSHAUTOLOAD@", "'"+strings.ReplaceAll("_"+this.Name(), "'", "'\\''")+"'", + "@FISHCOMMAND@", quoteFish(this.Name()), + "@FISHREQUEST@", quoteFish(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. +// +// 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. +// +// 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. +// +// 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() + } + 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") + } + var profiles []string + var zshProfile 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 == "" && shell == CompletionZsh { + directory, zshProfile, err = zshCompletionInstallation(ctx) + if err != nil { + return "", err + } + } + if directory == "" { + directory, err = completionDirectory(ctx, shell) + if err != nil { + return "", err + } + } + 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 { + name = "_" + this.Name() + } + 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 + } + } + if zshProfile != "" { + if err := installZshProfile(ctx, zshProfile, directory, 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 nil + } + 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) { + 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 + } + file, err := os.CreateTemp(directory, ".cli4g-completion-*") + if err != nil { + return err + } + defer func() { _ = os.Remove(file.Name()) }() + _, writeErr := file.Write(data) + closeErr := file.Close() + if writeErr != nil { + return writeErr + } + if closeErr != nil { + return closeErr + } + if err := os.Chmod(file.Name(), mode); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + return os.Rename(file.Name(), path) +} + +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 { + 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 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) + # 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 { + $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..cd2aa37 --- /dev/null +++ b/completion_shell_test.go @@ -0,0 +1,154 @@ +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 + } + 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\ ([^\ ]+) ]] || exit 1 +"${BASH_REMATCH[1]}" "$3" "$4" "" +if ((${#COMPREPLY[@]})); then printf '%s\0' "${COMPREPLY[@]}"; fi +`), 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 { + 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) + } + 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/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/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..fcab649 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,204 @@ // 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 fish +// 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 +// 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 +// +// 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. +// +// 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 +// 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. 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. +// 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. +// +// 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, +// 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..762c9c1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -18,7 +18,9 @@ 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 | | `mise run test:benchmarks` | Check comparison adapters and their module | | `mise run bench` | Measure CLI lifecycle comparisons and update the results page | @@ -28,6 +30,66 @@ 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 +mise run test:shells --coverage +``` + +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 containers have no network access; image building needs registry/package +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, 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, Fish, Nu/Reedline, PowerShell/PSReadLine, and Zsh/ZLE, +presses Tab, and reads the line-editor buffer. It verifies on-demand Bash loading, +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 +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. +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` 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. + ## Examples and coverage Examples are independent modules under [`_examples`](../_examples). @@ -49,19 +111,29 @@ 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`. ## 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 are run separately on an idle machine, not in CI. - 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. +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 a draft PR. Tool updates require releases at least seven days old. 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/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/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..3c3679a --- /dev/null +++ b/internal/shelltestcmd/Containerfile @@ -0,0 +1,11 @@ +# 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 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 && fish --version && nu --version && zsh --version +ENTRYPOINT ["/opt/tests/shell-e2e", "-test.v", "-test.run=^TestContainerShellCompletion$", "-test.timeout=2m"] 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/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/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/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/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..0085b60 --- /dev/null +++ b/internal/shelltestcmd/container_linux_test.go @@ -0,0 +1,236 @@ +//go:build linux && shell_e2e + +package main + +import ( + "embed" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" +) + +//go:embed bash.sh config.fish config.nu powershell.ps1 zsh.zsh +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() + 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)) + 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 + } + 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 + } + 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 + } + 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") + 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) + zsh, err := exec.Command("zsh", "--version").CombinedOutput() + must(t, err) + 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, 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") + 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("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") + 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() + }) + 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/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 new file mode 100644 index 0000000..c9edc81 --- /dev/null +++ b/internal/shelltestcmd/main.go @@ -0,0 +1,214 @@ +// 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 Containerfile.powershell +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") + coverage := flags.Bool("coverage", false, "collect fixture coverage in profile.shells.cov") + 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", "Containerfile.powershell"} { + 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") + 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 + } + } + 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 + } + var coverageDirectories []string + for _, file := range []string{"Containerfile", "Containerfile.powershell"} { + 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 profile:", profile) + } + return nil +} + +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" { + 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) + "-" + strings.ToLower(file) + 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. + 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) { + 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/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() +} 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..552006c --- /dev/null +++ b/internal/shelltestcmd/powershell_linux_test.go @@ -0,0 +1,129 @@ +//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) + } + } + } + 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) + } + 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/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) + } +} 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/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/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/internal/testdata/completion/main.go b/internal/testdata/completion/main.go new file mode 100644 index 0000000..b174e04 --- /dev/null +++ b/internal/testdata/completion/main.go @@ -0,0 +1,105 @@ +package main + +import ( + "bytes" + "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) { + 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 + } + 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 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) + } + 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..21c04dd 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" @@ -95,6 +102,18 @@ 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/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 = ''' +{% 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"] description = "Check benchmark adapters, formatting, module files, vet, and lint" dir = "{{config_root}}/_benchmarks" @@ -209,7 +228,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 +284,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"]