|
| 1 | +# AGENTS.md |
| 2 | + |
| 3 | +Guide for agents working in the `carapace-shlex` repository — a multi-shell command-line lexer (fork of go-shlex) that splits and re-joins command lines while tracking quotation state for shell completion. |
| 4 | + |
| 5 | +## Commands |
| 6 | + |
| 7 | +```bash |
| 8 | +# Build everything (library + CLI) |
| 9 | +go build -v ./... |
| 10 | + |
| 11 | +# Run tests with coverage (matches CI) |
| 12 | +go test -v -coverprofile=profile.cov ./... |
| 13 | + |
| 14 | +# Formatting check enforced by CI — note the -s (simplify) flag |
| 15 | +gofmt -d -s . |
| 16 | + |
| 17 | +# Static analysis enforced by CI |
| 18 | +go install honnef.co/go/tools/cmd/staticcheck@latest && staticcheck ./... |
| 19 | + |
| 20 | +# Run the CLI directly (no build step needed) |
| 21 | +go run ./cmd/carapace-shlex --format bash --completion-context "echo foo | grep hel" |
| 22 | +go run ./cmd/carapace-shlex --format elvish --current-pipeline --words "bat | {|" |
| 23 | + |
| 24 | +# Test a single format |
| 25 | +go test -run TestElvish -v ./... |
| 26 | +``` |
| 27 | + |
| 28 | +Go 1.24.0. The CI image is `ghcr.io/carapace-sh/go:1.25.4`. Tags trigger GoReleaser builds (see `.goreleaser.yml`). |
| 29 | + |
| 30 | +## Repository Layout |
| 31 | + |
| 32 | +- **Root package `shlex`** — the library: tokenizer state machine (`shlex.go`), `Format` interface (`format.go`), per-shell formats (`format_<shell>.go`), token slice operations (`tokenslice.go`), wordbreak types (`wordbreak.go`), quoting helpers (`quote.go`), completion context (`completion.go`). |
| 33 | +- **`cmd/carapace-shlex/`** — a **separate Go module** (`cmd/go.mod`) that imports the library and wraps it as a cobra CLI. It depends on `carapace` and `carapace-bridge` for its own completion. |
| 34 | +- **`go.work`** — workspace including both the root module and `./cmd`. Contains a `replace` directive: `github.com/carapace-sh/carapace v1.11.0 => ../carapace`. This means local development expects a sibling `../carapace` checkout. The `cmd/go.mod` has its own `replace ... => ../` for the shlex library itself. |
| 35 | +- **`skills/shlex/`** — in-depth reference docs (architecture, cross-shell comparison, per-format references). Load these via the `shlex` skill when doing substantial format work. |
| 36 | +- **`plan.md`** — design doc for elvish lambda-pipe handling; useful context for the `PostProcessor` interface and `WORDBREAK_LAMBDA_PIPE`. |
| 37 | + |
| 38 | +## Architecture |
| 39 | + |
| 40 | +### Data flow |
| 41 | + |
| 42 | +``` |
| 43 | +command line string |
| 44 | + → SplitWith(s, format) or SplitForCompletion(s, format) |
| 45 | + → format.Classifier() (rune → rune class) |
| 46 | + → tokenizer.scanStream() (flat state machine, shared across all formats) |
| 47 | + → TokenSlice (typed tokens with Span + quotation State) |
| 48 | + → [optional] format.PostProcess(tokens) (post-pass reclassification) |
| 49 | + → CompletionContext (current word, prefix, quoting state, pipeline) |
| 50 | +``` |
| 51 | + |
| 52 | +### The tokenizer is a flat state machine |
| 53 | + |
| 54 | +The core state machine in `shlex.go` (`scanStream`) has **no nesting awareness**. It classifies runes one at a time into `TokenType`s (WORD/SPACE/COMMENT/WORDBREAK) and tracks quotation state via `LexerState`. Every shell format plugs into the **same** machine via the `Format` interface — there is no per-shell parser. |
| 55 | + |
| 56 | +**Consequence**: format-specific behavior that requires context the flat machine can't track (e.g. elvish `|` inside `{|params|}` being a parameter delimiter, not a pipeline pipe) is handled via the optional `PostProcessor` interface, which runs a post-pass over the `TokenSlice` after tokenization. Do **not** add nesting/brace tracking into `scanStream` — add a `PostProcess` method on the format instead. |
| 57 | + |
| 58 | +### The `Format` interface and its optional companions |
| 59 | + |
| 60 | +`format.go` defines: |
| 61 | +- **`Format`** (required): `Classifier`, `ClassifyOperator`, `KeywordOperators`, quote-behavior flags (`NonEscapingQuoteEscapes`, `NonEscapingQuoteBackslashEscapes`, `EscapeNotBareword`, `EscapeNotInEscapingQuote`, `EscapingQuoteEscapeChars`), `TripleQuoteSupport`, `RawPrefixSupport`, `QuoteWord`. |
| 62 | +- **Optional interfaces** (asserted via type assertion in `tokenizer.Next` / `SplitWith`): |
| 63 | + - `PostProcessor` — post-pass token reclassification (elvish, nushell) |
| 64 | + - `BlockCommenter` — multi-line block comments (PowerShell `<# #>`) |
| 65 | + - `StopParsingToken` — raw lexing mode after a token (PowerShell `--%`) |
| 66 | + - `LineContinuationEscaper` — escape+newline as line continuation (PowerShell backtick) |
| 67 | + - `EscapingQuoteUnescaper` — custom unescape inside double quotes beyond simple backslash-dropping |
| 68 | + |
| 69 | +When adding a new format, implement `Format` plus whichever optional interfaces apply. No-op returns (e.g. `KeywordOperators() nil`) are the norm for formats that don't need a feature. |
| 70 | + |
| 71 | +### Token model |
| 72 | + |
| 73 | +```go |
| 74 | +type Token struct { |
| 75 | + Type TokenType // WORD_TOKEN, WORDBREAK_TOKEN, etc. |
| 76 | + Value string // dequoted value |
| 77 | + RawValue string // raw source text including quotes/escapes |
| 78 | + Span Span // rune offsets {Start, End} — NOT byte offsets |
| 79 | + State LexerState // quotation state after this token |
| 80 | + WordbreakType WordbreakType // operator type for WORDBREAK_TOKENs |
| 81 | + WordbreakIndex int // index of last opening quote in Value (prefix boundary) |
| 82 | +} |
| 83 | +``` |
| 84 | + |
| 85 | +`Span` offsets are **rune offsets**, not byte offsets — relevant when inspecting multi-byte input. `TokenSlice.Words()` merges tokens by `Span` adjacency (End==Start), so quote-openers, wordbreaks, and word fragments that touch get merged into one word. |
| 86 | + |
| 87 | +### WordbreakType drives TokenSlice operations |
| 88 | + |
| 89 | +`WordbreakType.IsPipelineDelimiter()` and `IsRedirect()` determine how `Pipelines()`, `CurrentPipeline()`, `FilterRedirects()`, and `WordbreakPrefix()` behave. When adding a new operator type, decide deliberately whether it should split pipelines or be filtered as a redirect — `WORDBREAK_LAMBDA_PIPE` intentionally returns false for both so elvish lambda parameter lists don't break pipeline splitting. |
| 90 | + |
| 91 | +`FilterRedirects()` has a special case: a numeric token (e.g. `2`) immediately adjoining a redirect operator (e.g. `>`) is filtered out as the fd prefix. Don't break this when touching redirect logic. |
| 92 | + |
| 93 | +### Public API surface |
| 94 | + |
| 95 | +- `Split(s)` / `SplitWith(s, format)` → `TokenSlice, error` |
| 96 | +- `SplitForCompletion(s, format)` → `*CompletionContext` (never errors; returns empty context with `START_STATE` on failure) |
| 97 | +- `Join(s)` / `JoinWith(s, format)` → quoted string |
| 98 | +- `CompletionContext` — the completion-oriented API: `Words`, `CurrentWord`, `RawCurrentWord`, `Prefix`, `QuotingState`, `IsRedirect`, `InLambdaParams`, and `Pipeline` (raw token escape hatch) |
| 99 | + |
| 100 | +`SplitForCompletion` is the primary entry point for completion callers (carapace). It internally calls `SplitWith` then derives the context fields. `InLambdaParams` is detected via an odd count of `WORDBREAK_LAMBDA_PIPE` in the current pipeline (toggle heuristic — see limitations in `plan.md`). |
| 101 | + |
| 102 | +## Adding a New Shell Format |
| 103 | + |
| 104 | +1. Create `format_<shell>.go` implementing `Format` (+ optional interfaces as needed). |
| 105 | +2. Add a `<shell>QuoteWord` function in `quote.go` and reference it from the format's `QuoteWord` method. Quoting helpers are kept in `quote.go`, not in the format file. |
| 106 | +3. If the shell has operators that differ from the bash grammar, add a `<shell>WordbreakType` function in `wordbreak.go` (see `bashWordbreakType`, `tcshWordbreakType` as templates). |
| 107 | +4. Add the format to `formatFromFlag` in `cmd/carapace-shlex/cmd/root.go` and to the `--format` flag's completion values. |
| 108 | +5. Add the format to the table in `README.md`. |
| 109 | +6. Create `format_<shell>_test.go` (see existing test files for the pattern). |
| 110 | +7. If the shell needs behavior the flat state machine can't express, implement `PostProcessor` rather than modifying `scanStream`. |
| 111 | + |
| 112 | +## Testing Patterns |
| 113 | + |
| 114 | +Tests live alongside their format: `format_<shell>_test.go`. The established pattern: |
| 115 | + |
| 116 | +```go |
| 117 | +tokens, err := SplitWith(input, SomeFormat()) |
| 118 | +if err != nil { t.Fatal(err) } |
| 119 | +words := tokens.Words().Strings() |
| 120 | +// assert on words, and on token State / WordbreakType for quoting/operator cases |
| 121 | +``` |
| 122 | + |
| 123 | +Tests assert on **dequoted `Value`** via `Words().Strings()`, and on `State` (e.g. `IN_WORD_STATE`, `QUOTING_STATE`) and `WordbreakType` for quotation/operator behavior. The `Equal` method on `Token` compares all fields — useful for golden-style tests. |
| 124 | + |
| 125 | +`completion_test.go` tests `SplitForCompletion` and the `CompletionContext` fields. `join_test.go` tests `JoinWith` roundtrips. `shlex_test.go` tests the core tokenizer/state machine. |
| 126 | + |
| 127 | +## Gotchas |
| 128 | + |
| 129 | +- **`go.work` replace expects `../carapace`** as a sibling checkout. The root `go.mod` also has the replace, so even non-workspace `go build` pulls the local carapace. CI uses the image `ghcr.io/carapace-sh/go` which has the dependency available. |
| 130 | +- **`cmd/carapace-shlex` is a separate module** with its own `go.mod`. Changes to the library's public API must be reflected in `cmd/go.mod`'s `require` (often via a replace to `../`). |
| 131 | +- **gofmt `-s` (simplify) is enforced**, not just plain gofmt. Run `gofmt -d -s .` before committing. |
| 132 | +- **`staticcheck` is enforced** in CI. Install and run it locally — it's not in the standard toolchain. |
| 133 | +- **`bufio.Reader` only supports one `UnreadRune`**. The triple-quote peek helpers (`checkTripleQuote`, `checkTripleClose`) handle this constraint by returning a `consumedRune` when the second peek fails to match — the first peeked rune can't be unread, so callers must add it to `RawValue`. Preserve this pattern when extending peek-based logic. |
| 134 | +- **`BashFormat().Classifier()` reads `COMP_WORDBREAKS`** from the environment at call time, not at init. Tests that assert on bash wordbreaks should set/unset `COMP_WORDBREAKS` explicitly or they inherit the ambient value. |
| 135 | +- **`plan.md` is a design doc, not implemented spec**. The elvish lambda-pipe `PostProcess` is implemented, but `InLambdaParams` uses a toggle heuristic that doesn't handle nested lambdas — documented as a known limitation in `plan.md`. |
| 136 | +- **Don't add comments to code** unless explaining *why* (and only when non-obvious). The codebase uses minimal comments; match that style. |
0 commit comments