diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index b747d97..654949b 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -39,7 +39,7 @@ jobs: fetch-depth: 0 - name: Verify release tag and checkout tagged commit - run: ./infra/release/verify-release-tag.sh + run: ./scripts/release/verify-release-tag.sh shell: bash - uses: actions/setup-node@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a19b97..e9025af 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,7 @@ jobs: - uses: voidzero-dev/setup-vp@v1 with: - node-version: 25.8.2 + node-version: 25.9.0 cache: true - uses: actions/setup-go@v6 @@ -58,7 +58,7 @@ jobs: - uses: voidzero-dev/setup-vp@v1 with: - node-version: 25.8.2 + node-version: 25.9.0 cache: true - uses: actions/setup-go@v6 @@ -100,7 +100,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG_PREFIX: v DEFAULT_BUMP: patch - run: ./infra/release/create-release-tag.sh + run: ./scripts/release/create-release-tag.sh # Publishing is invoked directly so this workflow does not rely on tag-push # fan-out from a workflow-created tag. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 44a6ecf..d73f1e7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -64,7 +64,7 @@ jobs: - uses: voidzero-dev/setup-vp@v1 name: Install Vite+ with: - node-version: 25.8.2 + node-version: 25.9.0 cache: true - uses: actions/setup-go@v6 @@ -97,7 +97,7 @@ jobs: - uses: voidzero-dev/setup-vp@v1 name: Install Vite+ with: - node-version: 25.8.2 + node-version: 25.9.0 cache: true - uses: actions/setup-go@v6 @@ -112,13 +112,13 @@ jobs: run: vp install --frozen-lockfile - name: Go - Run formatter tests with race detector and coverage - run: CGO_ENABLED=1 ./infra/task.sh with-env go -C packages/go/formatter test ./... -race -coverprofile=coverage.out -covermode=atomic -v + run: CGO_ENABLED=1 ./scripts/task.sh with-env go -C packages/go/formatter test ./... -race -coverprofile=coverage.out -covermode=atomic -v - name: Go - Run vet tests with race detector and coverage - run: CGO_ENABLED=1 ./infra/task.sh with-env go -C packages/go/vet test ./... -race -coverprofile=coverage.out -covermode=atomic -v + run: CGO_ENABLED=1 ./scripts/task.sh with-env go -C packages/go/vet test ./... -race -coverprofile=coverage.out -covermode=atomic -v - name: Go - Run driver tests with race detector and coverage - run: CGO_ENABLED=1 ./infra/task.sh with-env go -C packages/go/driver test ./... -race -coverprofile=coverage.out -covermode=atomic -v + run: CGO_ENABLED=1 ./scripts/task.sh with-env go -C packages/go/driver test ./... -race -coverprofile=coverage.out -covermode=atomic -v - uses: actions/upload-artifact@v4 name: Upload formatter coverage artifact @@ -176,12 +176,12 @@ jobs: args: check - name: Smoke test the self-contained binary - run: ./infra/test-binary-smoke.sh + run: ./scripts/test-binary-smoke.sh # Runs here rather than in `check`, which has no Go: the only tool # that can say whether this tree is formatted is fmtkit itself. - name: Check the repository is fmtkit-formatted - run: ./infra/task.sh self-check + run: ./scripts/task.sh self-check coverage: needs: test diff --git a/.gitignore b/.gitignore index 2df9875..c0d5ec1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ # TS toolchain assets staged per platform by stage-ts-assets.sh, next to the # package that embeds them. -/packages/go/driver/internal/embedded/bin/ +/packages/go/driver/internal/typescript/embedded/bin/ /storage/bin*/ /storage/dist/ /storage/dist-test/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index e1cd570..b606721 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -3,7 +3,7 @@ version: 2 project_name: fmtkit # Build output belongs under storage/ like every other artifact this repo -# produces; infra/lib/env.sh rejects a repo-root dist/. +# produces; scripts/lib/env.sh rejects a repo-root dist/. dist: storage/dist # Releases stage every platform; PR validation sets FMTKIT_STAGE_TARGETS=host @@ -13,7 +13,7 @@ env: before: hooks: - - ./packages/ts/infra/stage-ts-assets.sh {{ .Env.FMTKIT_STAGE_TARGETS }} + - ./packages/ts/toolchain/stage-ts-assets.sh {{ .Env.FMTKIT_STAGE_TARGETS }} builds: - id: fmtkit diff --git a/.nvmrc b/.nvmrc index 9af7122..e362e4c 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -25.8.2 +25.9.0 diff --git a/.oxlintrc.json b/.oxlintrc.json index 2a0fe58..c4be189 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,5 +1,5 @@ { - "$schema": "./node_modules/oxlint/configuration_schema.json", + "$schema": "https://cdn.jsdelivr.net/npm/oxlint@1.74.0/configuration_schema.json", "plugins": ["typescript", "oxc"], "categories": { "correctness": "error" diff --git a/Makefile b/Makefile index 59e2063..537ba51 100644 --- a/Makefile +++ b/Makefile @@ -17,13 +17,13 @@ help: ## Show the available targets @printf '\nVariables: ARGS\n' format: ## Run the formatter pipeline against ARGS - @./infra/task.sh format $(ARGS) + @./scripts/task.sh format $(ARGS) format-all: ## Run the formatter pipeline against the whole repository - @./infra/task.sh fmtkit format-all + @./scripts/task.sh fmtkit format-all check: ## Run the Go formatter in check mode against ARGS - @./infra/task.sh fmtkit check $(ARGS) + @./scripts/task.sh fmtkit check $(ARGS) version: ## Print the version the working tree builds as - @./infra/task.sh fmtkit version + @./scripts/task.sh fmtkit version diff --git a/README.md b/README.md index b5e5cde..5a0f9f4 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,45 @@ # fmtkit [![Go Reference](https://pkg.go.dev/badge/go.ollin.sh/fmtkit/driver.svg)](https://pkg.go.dev/go.ollin.sh/fmtkit/driver) -[![Go 1.26.4](https://img.shields.io/badge/go-1.26.4-00ADD8?logo=go&logoColor=white)](https://go.dev/doc/go1.26) +[![Go 1.26.5](https://img.shields.io/badge/go-1.26.5-00ADD8?logo=go&logoColor=white)](https://go.dev/doc/go1.26) [![Tests](https://github.com/oullin/fmtkit/actions/workflows/tests.yml/badge.svg)](https://github.com/oullin/fmtkit/actions/workflows/tests.yml) [![Release](https://github.com/oullin/fmtkit/actions/workflows/release.yml/badge.svg)](https://github.com/oullin/fmtkit/actions/workflows/release.yml) -[![Codecov](https://codecov.io/gh/oullin/fmtkit/graph/badge.svg?branch=main)](https://app.codecov.io/github/oullin/fmtkit) -`fmtkit` is a rule-driven formatter for Go. It enforces layout and structure that `gofmt` leaves alone — blank lines around control flow, type hoisting, declaration grouping — then hands off to `gofmt` and `goimports` for the final pass. +One formatter for a Go + TypeScript repository. `fmtkit` enforces the layout rules `gofmt` and `oxfmt` leave alone, blank lines around control flow, declaration ordering, class member order; then hands off to the standard formatters for the final pass. -## At a glance +## What it is -- AST-based spacing rules, then `gofmt` and `goimports`, in one deterministic pipeline. -- Runs `go vet ./...` automatically when invoked inside a Go module or workspace. -- Three output modes: `text` for humans, `json` for scripts, `agent` for CI and AI tools. -- One self-contained `fmtkit` binary (Homebrew or GitHub Releases), or a Go-only CLI (`fmtkit-go`). -- Engine in [`packages/go/formatter/engine`](packages/go/formatter/engine) is importable from Go. +A single self-contained binary that formats both halves of a full-stack repo: + +- **Go** — an AST-based spacing rule, then `gofmt` and `goimports`, plus an automatic `go vet ./...`. +- **TypeScript / Vue** — `oxlint --fix`, then `oxfmt`, then structural passes for blank lines, class member order, and fluent chains. Also formats the embedded TS blocks in Markdown and HTML. + +The TS toolchain is compiled with Bun and embedded in the binary, so there is **no Node.js requirement** and nothing to `npm install`. One download, one command, both languages. + +If you only want the Go half, `fmtkit-go` is a separate `go install`-able CLI, and the engine is importable as a library. + +## Why + +`gofmt` is deliberately conservative: it normalizes indentation and alignment, but it will never tell you that a `return` should be preceded by a blank line, or that your `type` declarations belong at the top of the file. `oxfmt` is the same story on the TS side, they own whitespace within a statement, not the rhythm between statements. + +That leaves a whole category of "style" that lives in review comments and team wikis, gets applied inconsistently, and produces diff noise when someone finally cleans it up. `fmtkit` moves those rules into the formatter, where they get applied the same way every time and stop being a thing people argue about. + +It is deliberately opinionated. There is one spacing rule with one shape, and the knobs are for turning things off, not for tuning them. + +## Who it's for + +- Teams with a **Go backend and a TS/Vue frontend in one repo** who are tired of running two toolchains with two config surfaces and two CI steps. +- Anyone who wants **more structure than `gofmt` provides** and would rather not hand-maintain it. +- **CI pipelines** that want a formatting gate with no daemon, no image pull, and no Node.js on the runner. +- **AI coding agents and scripts**, via the `json` and `agent` output modes. + +It is probably _not_ for you if you want a configurable style engine — fmtkit has opinions and only a few dials. ## Install -Two ways to run it. Pick the one that fits your workflow — both produce identical output. +Both routes produce identical output. Pick whichever fits. -**With Homebrew** (recommended: one self-contained binary with the full TS/Vue + Go pipeline — no Node.js required): +### Homebrew (recommended) ```bash brew tap oullin/fmtkit @@ -28,7 +47,11 @@ brew install --cask fmtkit fmtkit format . ``` -The binary embeds the TS toolchain (oxfmt, oxlint, oxc-parser and the support scripts, compiled with Bun) and extracts it to your user cache directory on first run. Homebrew casks are macOS-only; on Linux, download the same binary from GitHub Releases instead: +The binary embeds the TS toolchain (oxfmt, oxlint, oxc-parser and the support scripts) and extracts it to your user cache directory on first run. + +### Linux / GitHub Releases + +Homebrew casks are macOS-only. On Linux, grab the same binary directly: ```bash tag=$(curl -fsSLI -o /dev/null -w '%{url_effective}' https://github.com/oullin/fmtkit/releases/latest | sed 's#.*/##') @@ -36,59 +59,150 @@ curl -fsSL "https://github.com/oullin/fmtkit/releases/download/${tag}/fmtkit_${t sudo install -m 0755 fmtkit /usr/local/bin/fmtkit ``` -Archives are published for `darwin`/`linux` × `amd64`/`arm64` with a `checksums.txt`; swap `linux_amd64` for your platform. The snippet resolves the [latest release](https://github.com/oullin/fmtkit/releases/latest) rather than naming a version, so it does not go stale. For CI, pin `tag` to a known release instead. +Archives are published for `darwin`/`linux` × `amd64`/`arm64` with a `checksums.txt`; swap `linux_amd64` for your platform. The snippet resolves the [latest release](https://github.com/oullin/fmtkit/releases/latest) rather than naming a version, so it does not go stale. But **for CI, pin `tag` to a known release** so a new upstream version can't change your build. -**With Go** (good for local hacking and contributors): +### Go install (Go-only CLI) ```bash go install go.ollin.sh/fmtkit/driver/cmd/fmtkit-go@latest fmtkit-go check . -fmtkit-go format . ``` -If `fmtkit-go` is not on your `PATH` after `go install`, add the Go bin directory: `export PATH="$(go env GOPATH)/bin:$PATH"`. - -For CI, download the release binary for the runner's platform and pin the tag — it needs no daemon, no image pull, and no Node.js. +This gives you `fmtkit-go`, the Go formatter alone; no TS/Vue support. Good for Go-only projects and for contributors. -## Usage +If it isn't on your `PATH` afterward: `export PATH="$(go env GOPATH)/bin:$PATH"`. -The distributed `fmtkit` binary runs the whole pipeline (TS/Vue lint, TS/Vue formatting, Go formatting) with `format` / `format-all`, and narrows it with step flags: +## Quickstart ```bash -fmtkit format . # every step, over the working tree's changes -fmtkit format --ts . # TS/Vue lint + formatting only -fmtkit format --go . # Go formatting only -fmtkit format-all --quiet +fmtkit format . # everything you changed, both languages +fmtkit format --go . # Go only +fmtkit format --ts . # TS/Vue only +fmtkit format-all # the entire repository +fmtkit check . # report Go violations, write nothing +fmtkit lint . # report TS/Vue lint violations, write nothing ``` -`format` applies oxlint's safe fixes (`oxlint --fix`) first, then the formatting -passes normalize whatever oxlint rewrote. Standalone `fmtkit lint` only reports -violations; it never edits your files. +In CI, use `format-all` (or `check`) — see [`format` vs `format-all`](#format-vs-format-all) for why the scope matters. + +## What it does to your code + +### Go + +Given this: + +```go +func run(items []string) error { + total := 0 + type result struct{ n int } + for _, it := range items { + total += len(it) + } + if total == 0 { + return fmt.Errorf("empty") + } + r := result{n: total} + return nil +} +``` + +`fmtkit format` produces: + +```go +func run(items []string) error { + total := 0 + + type result struct{ n int } + + for _, it := range items { + total += len(it) + } + + if total == 0 { + return fmt.Errorf("empty") + } + + r := result{n: total} + + return nil +} +``` + +The spacing rule in summary: + +- Blank lines **before and after control flow** — `if`, `for`, `range`, `switch`, `select`, `defer`, `return`, `break`, `continue`, `goto`, `fallthrough`. +- Separates standalone `var` declarations from surrounding statements when they aren't already grouped. +- Blank lines around standalone stdlib `sort.*` / `slices.Sort*` and `rand.*` calls, and after `t.Helper()`. +- Separates `type` declarations from their neighbors, and **hoists top-level `type` definitions** to the top of the file, after imports. +- Blank line after anonymous-function assignments, and between top-level `routes.Add` / `routes.Group` calls. + +Full catalogue with before/after for every variant: [docs/spacing.md](docs/spacing.md). + +### TypeScript / Vue + +The TS lane runs `oxlint --fix` for safe lint fixes, then `oxfmt`, then these structural passes: + +| Pass | What it does | +| ------------------------ | ------------------------------------------------------------------------------ | +| `BlankLinePass` | The statement-spacing rules, mirroring the Go side. | +| `ClassReorderPass` | Reorders class members into a stable shape (properties, constructor, methods). | +| `DeclarationReorderPass` | Reorders declarations, only where provably side-effect safe. | +| `FluentChainPass` | Splits fluent call chains so each link starts on its own line. | +| `ExpandedCallPass` | Expands structurally complex call arguments into stable multiline layouts. | +| `BodyWrapPass` | Braces unbraced statement bodies. | + +### What is never touched + +When given directories, the engine walks recursively and always skips: + +| Skipped | Reason | +| ----------------------------------- | ------------------------------------ | +| Hidden directories | Convention, not source code. | +| `.git/`, `vendor/` | Repository and dependency metadata. | +| `*.gen.go` | Generated code by convention. | +| Files starting `// Code generated` | Go's standard generated-file marker. | +| `.gitignore`d paths | Not yours to format. | +| `exclude` / `not_path` / `not_name` | Your own exclusions (see below). | + +## Commands + +### `fmtkit` (the full binary) + +| Command | What it does | +| ------------------------------------------- | ---------------------------------------------------- | +| `format [--ts] [--go] [--quiet] [paths...]` | Format files changed vs `HEAD`, plus untracked ones. | +| `format-all [--ts] [--go] [--quiet]` | Format every non-ignored file in the repo. | +| `ts [paths...]` | TS/Vue formatting only. | +| `lint [paths...]` | Report TS/Vue lint violations. Never writes. | +| `check [args...]` | Run the Go formatter in check mode. | +| `go ` | The Go formatter CLI. | +| `version`, `help` | The usual. | + +No language flag means all lanes, TS before Go. + +`format` applies oxlint's safe fixes first, then the formatting passes normalize whatever oxlint rewrote. Standalone `lint` only reports; it never edits your files. + +### `format` vs `format-all` -**`format` covers what you changed; `format-all` covers everything.** `format` -covers the files that diverge from HEAD — modified (staged or not) and -untracked — so an everyday format stays proportional to your diff rather than -the repo. -`format-all` covers every non-ignored file, and is what a CI gate wants: a -changed-file scope would pass vacuously on a fresh checkout, where nothing is -modified. Both skip anything `.gitignore`d, and both need a git working tree. +**`format` covers what you changed; `format-all` covers everything.** -This applies to every step. The TS/Vue steps collect through git directly; the -Go formatter keeps its own walk (so `config.yml`'s `exclude` / `not_path` / -`not_name` and generated-file detection always apply) and `format` then narrows -that to what git reports as changed. `go vet` is unscoped either way — it -analyses whole packages, not files. +`format` covers files that diverge from `HEAD`, modified (staged or not) and untracked, so an everyday format stays proportional to your diff rather than your repo. -`ts`, `lint`, `go `, `check`, `version`, and `help` are also available; `fmtkit help` lists them. +`format-all` covers every non-ignored file, and **is what a CI gate wants**: a changed-file scope would pass vacuously on a fresh checkout, where nothing is modified. -The `fmtkit-go` CLI (the Go-only formatter published via `go install`) accepts: +Both skip `.gitignore`d files, and both need a git working tree. -| Command | What it does | -| ------------------- | ----------------------------------- | -| `check [paths...]` | Reports violations without writing. | -| `format [paths...]` | Rewrites files in place. | +This applies to every step, with two wrinkles: the Go formatter keeps its own walk (so `config.yml`'s `exclude` / `not_path` / `not_name` and generated-file detection always apply) and `format` then narrows that to what git reports as changed; and `go vet` is unscoped either way, because it analyses whole packages, not files. -Both default to `.` when no paths are given. Both run `go vet ./...` automatically when the working directory is inside a Go module or workspace. +### `fmtkit-go` (the Go-only CLI) + +| Command | What it does | +| --------------------------------------------- | -------------------------------------------------------------------- | +| `check [paths...]` | Reports violations without writing. | +| `format [paths...]` | Rewrites files in place. | +| `sources [--include-declarations] [paths...]` | Prints the collected file list, NUL-separated. Plumbing for scripts. | + +Both `check` and `format` default to `.`, and both run `go vet ./...` automatically when the working directory is inside a Go module or workspace. | Flag | Default | Description | | ---------- | ------- | ------------------------------------------------------------------------ | @@ -97,19 +211,18 @@ Both default to `.` when no paths are given. Both run `go vet ./...` automatical | `--format` | `text` | Output mode: `text`, `json`, or `agent`. | | `--jobs` | `0` | Max files in parallel; `0` uses `runtime.NumCPU()`. Reads `FMTKIT_JOBS`. | -A handful of common invocations: - ```bash fmtkit-go check . fmtkit-go format ./core ./demo/api fmtkit-go check --format json . -fmtkit-go check --format agent . fmtkit-go check ./packages/go/formatter/rules/spacing/spacing.go ``` ## Configuration -`fmtkit` looks for `config.yml` in the working directory; if none is found, the defaults below apply. Point at a specific file with `--config`. +### Go (`config.yml`) + +`fmtkit` looks for `config.yml` in the working directory; without one, the defaults below apply. Point at a specific file with `--config`. ```yaml rules: @@ -148,88 +261,83 @@ concurrency: 0 | `not_name` | list | empty | Globs matched against file names. | | `concurrency` | int | `0` | Max files in parallel (`0` = `NumCPU`). | -### TS/Vue formatting (`.oxfmtrc.json`) - -The TS/Vue layer runs [`oxfmt`](https://www.npmjs.com/package/oxfmt) over your sources, then applies project-specific syntax passes for blank lines and fluent builder chains. The binary ships a bundled `.oxfmtrc.json` (tabs, single quotes, trailing commas, 200-column width) that is applied by default, so you get the same style out of the box without any setup. +### TS/Vue (`.oxfmtrc.json`) -The config is resolved by precedence, first match wins: +The binary ships a bundled `.oxfmtrc.json` (tabs, single quotes, trailing commas, 200-column width) applied by default, so you get a consistent style with zero setup. Resolution is by precedence, first match wins: -1. `FMTKIT_OXFMTRC` — an explicit path, matching the other `FMTKIT_*` knobs. -2. A project-local `.oxfmtrc.*` (`.json`, `.jsonc`, `.ts`, `.js`, …) in the directory being formatted: the bundled default is skipped and oxfmt uses yours. -3. A config derived from your Prettier setup: if the directory has a Prettier config (`.prettierrc*`, `prettier.config.*`, or a `"prettier"` key in `package.json`) but no oxfmt config, fmtkit translates it via `oxfmt --migrate=prettier` so a Prettier-configured project formats consistently with no extra setup. The translation is cached by the Prettier config's content hash, so it runs once and re-runs only when that config changes. If a config cannot be translated (a JS config importing project-local modules, say), fmtkit warns on stderr and falls back to the bundled default rather than failing the run. -4. The bundled default. +1. **`FMTKIT_OXFMTRC`** — an explicit path. +2. **A project-local `.oxfmtrc.*`** (`.json`, `.jsonc`, `.ts`, `.js`, …) in the directory being formatted. The bundled default is skipped and oxfmt uses yours. +3. **Your Prettier config.** If the directory has a Prettier config (`.prettierrc*`, `prettier.config.*`, or a `"prettier"` key in `package.json`) but no oxfmt config, fmtkit translates it via `oxfmt --migrate=prettier`, so a Prettier-configured project formats consistently with no extra setup. The translation is cached by the Prettier config's content hash, so it runs once and re-runs only when that config changes. If a config can't be translated (a JS config importing project-local modules, say), fmtkit warns on stderr and falls back to the bundled default rather than failing the run. +4. **The bundled default.** -To opt out of the Prettier-derived step, drop in your own `.oxfmtrc.*`, which takes precedence over it. +To opt out of the Prettier-derived step, drop in your own `.oxfmtrc.*` — it takes precedence. ### Ignoring files (`.prettierignore`) -`oxfmt` already honors `.prettierignore` (and `.gitignore`) in its own step. fmtkit extends that to the rest of the TS/Vue pipeline — the blank-line and fluent-chain passes and `oxlint --fix` — by filtering `.prettierignore`d paths out of the file set it collects, so an ignored file is left untouched by every lane. The matcher follows gitignore syntax (comments, negation, leading-`/` anchoring, trailing-`/` directories, and the `*`, `?`, `[…]`, and `**` wildcards). The Go formatter is unaffected: `.prettierignore` governs only the TS/Vue/HTML/Markdown lanes. +`oxfmt` already honors `.prettierignore` and `.gitignore` in its own step. fmtkit extends that to the rest of the TS/Vue pipeline — the structural passes and `oxlint --fix` — by filtering ignored paths out of the file set it collects, so an ignored file is untouched by every lane. -## What it formats - -The built-in spacing rule, in summary: - -- Inserts blank lines before and after control flow (`if`, `for`, `switch`, `select`, `defer`, `return`, `break`, `continue`, `goto`, `fallthrough`). -- Separates standalone `var` declarations from surrounding statements when they are not already grouped. -- Adds blank lines around standalone stdlib `sort.*` / `slices.Sort*` and `rand.*` calls, and after `t.Helper()`. -- Separates `type` declarations from neighbours and hoists all `type` definitions to the top of the file, after imports. -- Adds a blank line after anonymous-function assignments and between top-level `routes.Add` / `routes.Group` calls. - -Full catalogue with before/after examples: [docs/spacing.md](docs/spacing.md). - -When given directories, the engine walks recursively for `.go` files and always skips: - -| Skipped | Reason | -| ----------------------------------- | ------------------------------------ | -| Hidden directories | Convention, not source code. | -| `.git/`, `vendor/` | Repository and dependency metadata. | -| `*.gen.go` | Generated code by convention. | -| Files starting `// Code generated` | Go's standard generated-file marker. | -| `exclude` / `not_path` / `not_name` | User-defined exclusions. | +The matcher follows gitignore syntax: comments, negation, leading-`/` anchoring, trailing-`/` directories, and the `*`, `?`, `[…]`, and `**` wildcards. The Go formatter is unaffected — `.prettierignore` governs only the TS/Vue/HTML/Markdown lanes. ## Output formats -**Text** — for local runs: +**`text`** — for humans: ```text +Formatter + Checked 1 file(s). main.go - [spacing] line 5: missing blank line before if statement + [spacing] line 7: missing blank line before type definition + [spacing] line 11: missing blank line before if statement ✓ would apply spacing - Result: fail. 1 changed, 1 violation(s), 0 error(s). + Result: fail. 1 changed, 2 violation(s), 0 error(s). + +Vet + + Result: ok. 0 error(s). ``` -**JSON** — for scripts and automation: +**`json`** — for scripts. Emitted as a single line; shown here expanded: ```json { "result": "fail", - "files": 1, - "changed": 1, - "results": [ - { - "file": "main.go", - "applied": ["spacing"], - "violations": [{ "rule": "spacing", "line": 5, "message": "missing blank line before if statement" }], - "changed": true - } - ] + "formatter": { + "result": "fail", + "files": 1, + "changed": 1, + "results": [ + { + "file": "main.go", + "applied": ["spacing"], + "violations": [{ "rule": "spacing", "line": 7, "message": "missing blank line before type definition" }], + "changed": true + } + ] + }, + "vet": { "status": "skipped" } } ``` -**Agent** — compact JSON for CI and AI tools: +**`agent`** — indented JSON, grouped for CI and AI tools: ```json { "result": "fail", - "summary": { "files": 1, "changed": 1, "violations": 1 }, - "changed": [{ "file": "main.go", "steps": ["spacing"] }], - "violations": [{ "file": "main.go", "rule": "spacing", "line": 5, "message": "missing blank line before if statement" }] + "formatter": { + "result": "fail", + "summary": { "files": 1, "changed": 1, "violations": 1 }, + "changed": [{ "file": "main.go", "steps": ["spacing"] }], + "violations": [{ "file": "main.go", "rule": "spacing", "line": 7, "message": "missing blank line before type definition" }] + }, + "vet": { "status": "skipped" } } ``` +The `json` and `agent` shapes are a public contract, pinned by golden tests. + ## Exit codes | Command | Code | Meaning | @@ -239,9 +347,11 @@ When given directories, the engine walks recursively for `.go` files and always | `format` | `0` | Formatting applied successfully. | | `format` | `1` | An error occurred during formatting. | +Note that `format` exits `0` when it _fixes_ violations — it only fails on a genuine error. Use `check` for gates. + ## Development -You will need Go 1.26.4+, Vite+, and [Bun](https://bun.com) (used to compile the TS sidecar the binary embeds). Vite+ manages the project Node.js runtime and pnpm version declared by the workspace. +You'll need Go 1.26.5+, [Bun](https://bun.com) (to compile the TS sidecar), and Vite+ (which manages the Node.js runtime and pnpm version the workspace declares). ```bash curl -fsSL https://vite.plus -o install-vp.sh @@ -249,54 +359,87 @@ sh install-vp.sh vp install ``` -Use Vite+ tasks for day-to-day development: +Day-to-day tasks: ```bash vp run build # build the local fmtkit-go binary into storage/bin -vp run check # run package checks across the workspace -vp run test # run all package tests +vp run check # package checks across the workspace +vp run test # all package tests vp run test-race # tests with the race detector (forces CGO_ENABLED=1) vp run test:binary # build the self-contained binary and smoke test it -vp run vet # run go vet across the Go module packages -vp run format -- . # format this repo with fmtkit's own binary +vp run vet # go vet across the Go module packages vp run install-cli # install fmtkit-go from the local source tree -vp run release # build cross-platform binaries into storage/dist +vp run release # cross-platform binaries into storage/dist ``` -### Formatting fmtkit with fmtkit +### fmtkit formats itself -fmtkit formats itself with the binary it ships, so the development loop and the -release exercise the same Go orchestrator and the same Bun-compiled TS sidecar. -The root `Makefile` is the shortest way in: +fmtkit formats its own source with the binary it ships, so the development loop and the release exercise the same Go orchestrator and the same Bun-compiled sidecar. The `Makefile` is the shortest way in: ```bash make format # format the repo (ARGS defaults to ".") make format ARGS=--ts # only the TS/Vue half make format-all # the whole repository make check # Go formatter in check mode +make version # the version the working tree builds as ``` -The first run stages the host TS toolchain assets into -`packages/go/driver/internal/embedded/bin/_/` (this needs Bun and takes a -few seconds); later runs reuse them and re-stage only when the support scripts, -the tool pins, or the `.oxfmtrc.json` / `.oxlintrc.json` configs change. The -inner loop is then a plain incremental `go build`. +The first run stages the host TS toolchain into `packages/go/driver/internal/typescript/embedded/bin/_/` (needs Bun, takes a few seconds). Later runs reuse it and re-stage only when the support scripts, the tool pins, or the `.oxfmtrc.json` / `.oxlintrc.json` configs change. The inner loop is then a plain incremental `go build`. -That loop points `FMTKIT_SUPPORT_DIR` at the staged assets rather than embedding -them, which keeps it fast. The embedded-asset path a release actually uses is -covered by `vp run test:binary`. +That loop points `FMTKIT_SUPPORT_DIR` at the staged assets rather than embedding them, which keeps it fast. The embedded-asset path a release actually uses is covered by `vp run test:binary`. -Package layout: +## How the code is organized -```text -packages/go/ The Go module (go.ollin.sh/fmtkit) -packages/go/driver/ Stand-alone Go CLI, config loading, report rendering -packages/go/vet/ Vet planning and automatic go vet execution -packages/go/formatter/ Formatter planning, engine, rules, and formatters -packages/go/infra/ Go-toolchain task runner -packages/ts/sidecar/ Oxc-based formatting for supported non-Go file types -packages/ts/infra/ Staging for the bun-compiled TS toolchain assets -infra/ Repo-wide tasks, shared shell lib, release scripts -``` +fmtkit is one binary with two halves: + +- A **Go driver** (`packages/go`) that owns the CLI, finds files, formats Go, runs `go vet`, renders reports, and orchestrates the run. +- A **TypeScript sidecar** (`packages/ts/sidecar`), compiled with Bun and embedded in the binary, that formats TS/Vue and the embedded blocks in Markdown/HTML. + +The driver runs the sidecar as a child process. Everything crossing that boundary. The executable name, modes, flags, env vars, and the summary lines the driver reads back is defined once per side (`driver/internal/typescript/proto` in Go, the `cli/` DTOs in TS) and pinned by tests. **Change one side, and you change the other in the same PR.** + +### Go side (`packages/go`, module `go.ollin.sh/fmtkit`) + +The importable library: + +| Package | What it does | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `formatter` | Public entry points: `Check`, `Format`, `CheckFiles`, `FormatFiles`. | +| `formatter/engine` | Runs the formatters over files concurrently and builds the `Report`. | +| `formatter/config` | Single source of truth for formatter settings and defaults. | +| `formatter/rules/spacing` | The spacing rule. Parses each file once, then three types do the work: blank-line insertion, type reordering, embed-directive repair. | +| `vet` | Wraps `go vet` behind an injectable toolchain so tests can fake it. | +| `driver/config` | CLI config. Embeds the formatter config and adds the vet toggle; the `config.yml` schema is a public contract. | +| `driver/report` | Typed output modes and the renderer; the JSON/agent shapes are a public contract. | + +The CLI internals (`driver/internal/...`), one job each: `command` holds the dispatch table both binaries share; `app` wires things together, registering language lanes with `toolchain` — the registry that turns `--ts`/`--go` into an ordered set of lanes; `pipeline` runs generic steps whose summaries come from typed results (nothing scrapes rendered text); `console` owns terminal colors and printing; `gitfiles` owns git-backed file selection. + +Each language then owns its behavior in its own package. `golang` is the Go check/format use case (returning a typed `Outcome`) plus its format step. `typescript` builds the TS/Vue lint and format steps and splits its machinery across subpackages — `typescript/runtime` extracts and spawns the sidecar, `typescript/proto` is the frozen wire protocol, `typescript/filetypes` and `typescript/prettierignore` each own one kind of file selection composed by `typescript/sourcefiles`, and `typescript/embedded` holds the `go:embed` assets (its `bin/` folder is where staging writes — **do not move it**). + +### TS side (`packages/ts/sidecar/src`) + +| Directory | What it does | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `kernel/` | `Result` helpers, error types, the concurrency pool. | +| `syntax/` | Parsing and editing: `SourceDocument` (an immutable file value), `SourceParser` (the Zod boundary), `AstReader`, `EditApplier`. | +| `hosts/` | Pulls TS out of `.vue`/`.md`/`.html` files and puts it back. | +| `passes/` | One class per formatting rule. Every pass implements the same small interface: `computeEdits(document)` returns edits. Policy classes hold the layout knowledge. | +| `pipeline/` | Runs passes in order. `PipelineFactory` is the only place a pass sequence is defined; loops and fixed points are declared there, not hidden inside passes. | +| `io/` | File and process access behind ports, with Node adapters. | +| `cli/` | The commands, the DTOs that parse argv, and `CompositionRoot` — the one place everything gets constructed. Entry files are just `main()` shims. | + +**Adding a TS pass:** write a class implementing `FormattingPass`, register it in `PipelineFactory`. Nothing else changes. +**Adding a Go rule:** implement the `Rule` interface (`Name()`, `Apply()`) and register it before the engine is built. + +### Ground rules + +- **Logic lives on types.** Go logic belongs to structs with methods; free functions are for small stateless helpers only. TS code lives in classes with real instances and constructor-injected dependencies — the only exceptions are `main()` entry shims, the `Result` helpers, value types with factory statics (the Zod DTOs, `SourceDocument.of`), and error classes. +- **Parse, don't validate.** Outside data enters through a Zod-backed DTO exactly once. No `typeof` checks in TS source. +- **The wire is frozen.** The Go↔TS protocol values never change casually; golden tests on both sides fail loudly if they drift. +- **The repo formats itself.** `make format-all` must leave the tree unchanged. Write class members in the formatter's order (properties, constructor, methods) or the self-check will reorder them for you. +- **Golden are never regenerated to make a change pass.** Pipeline transcripts, report renders, CLI usage/exit codes, and the spacing corpus are pinned byte-for-byte; if a golden fails, the code is wrong. + +The Go pipeline runs `source → spacing rule → gofmt → goimports`, skipping any stage disabled in config. + +## License -The pipeline runs `source → spacing rule → gofmt → goimports`, skipping any stage disabled in config. New rules can be added by implementing the `Rule` interface (`Name()`, `Apply()`) and registering them with the rule set before the engine is constructed. +[MIT](LICENSE) diff --git a/package.json b/package.json index 1cea779..879c97c 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,13 @@ "name": "workspaces", "private": true, "scripts": { - "build": "./infra/task.sh build", + "build": "./scripts/task.sh build", "lint": "vp run --filter sidecar --fail-if-no-match lint:check", - "test": "vp run --filter formatter --filter vet --filter driver --filter sidecar --filter ts-infra --fail-if-no-match test", - "typecheck": "vp run --filter sidecar --filter ts-infra --fail-if-no-match typecheck" + "test": "vp run --filter formatter --filter vet --filter driver --filter sidecar --filter ts-toolchain --fail-if-no-match test", + "typecheck": "vp run --filter sidecar --filter ts-toolchain --fail-if-no-match typecheck" }, "devDependencies": { - "vite-plus": "0.2.4" + "vite-plus": "0.2.6" }, - "packageManager": "pnpm@10.33.0" + "packageManager": "pnpm@11.17.0" } diff --git a/packages/go/driver/cmd/fmtkit-go/main.go b/packages/go/driver/cmd/fmtkit-go/main.go index c3e094d..5429c6f 100644 --- a/packages/go/driver/cmd/fmtkit-go/main.go +++ b/packages/go/driver/cmd/fmtkit-go/main.go @@ -1,14 +1,15 @@ +// Command fmtkit-go is the standalone Go formatter CLI. Its command surface +// lives in internal/app (app.GoCLI); this entrypoint only carries the version +// stamped in by -X main.version and the signal handling. package main import ( "context" - "fmt" - "io" "os" "os/signal" "syscall" - "go.ollin.sh/fmtkit/driver/internal/cli" + "go.ollin.sh/fmtkit/driver/internal/app" ) var version = "dev" @@ -18,49 +19,10 @@ func main() { // os.Exit skips deferred calls, so release the signal handler explicitly // before exiting with the captured code. - code := run(ctx, os.Args[1:], os.Stdout, os.Stderr) + code := app. + GoCLI(version, os.Stdout, os.Stderr). + Dispatch(ctx, os.Args[1:]) stop() os.Exit(code) } - -func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { - if len(args) == 0 { - printUsage(stderr) - - return 1 - } - - switch args[0] { - case "check": - return cli. - NewRunner(stdout, stderr). - Run(ctx, cli.CheckMode, args[1:]) - case "format": - return cli. - NewRunner(stdout, stderr). - Run(ctx, cli.FormatMode, args[1:]) - case "sources": - return cli.RunSources(ctx, args[1:], stdout, stderr) - case "version", "--version", "-version": - _, _ = fmt.Fprintf(stdout, "fmtkit %s\n", version) - - return 0 - case "help", "--help", "-h": - printUsage(stderr) - - return 0 - default: - _, _ = fmt.Fprintf(stderr, "unknown subcommand - {%q}\n\n", args[0]) - - printUsage(stderr) - - return 1 - } -} - -func printUsage(w io.Writer) { - _, _ = fmt.Fprintf(w, "fmtkit check [paths...]\n\n") - _, _ = fmt.Fprintf(w, "fmtkit format [paths...]\n\n") - _, _ = fmt.Fprintf(w, "fmtkit sources [--include-declarations] [paths...]\n\n") -} diff --git a/packages/go/driver/cmd/fmtkit-go/main_test.go b/packages/go/driver/cmd/fmtkit-go/main_test.go index fedb898..e70c000 100644 --- a/packages/go/driver/cmd/fmtkit-go/main_test.go +++ b/packages/go/driver/cmd/fmtkit-go/main_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "go.ollin.sh/fmtkit/driver/internal/app" "go.ollin.sh/fmtkit/driver/testutil" ) @@ -189,6 +190,53 @@ func TestRunSourcesEmitsNullDelimitedTypeScriptFiles(t *testing.T) { } } +// TestDispatchGoldens pins the fmtkit-go binary's usage text, version string, +// stdout/stderr routing, and exit codes byte for byte. Note the deliberate +// differences from the umbrella (internal/app): unknown subcommands and missing +// args exit 1 here (not 2), and the usage prefixes read "fmtkit check ..." (not +// "fmtkit go check ..."). Pin the current bytes; do not reconcile the two. +func TestDispatchGoldens(t *testing.T) { + usage, err := os.ReadFile(filepath.Join("testdata", "usage.txt")) + + if err != nil { + t.Fatalf("read usage golden: %v", err) + } + + cases := []struct { + name string + args []string + wantExit int + wantStdout string + wantStderr string + }{ + {"no args", nil, 1, "", string(usage)}, + {"unknown", []string{"bogus"}, 1, "", "unknown subcommand - {\"bogus\"}\n\n" + string(usage)}, + {"help", []string{"help"}, 0, "", string(usage)}, + {"help long flag", []string{"--help"}, 0, "", string(usage)}, + {"help short flag", []string{"-h"}, 0, "", string(usage)}, + {"version", []string{"version"}, 0, "fmtkit dev\n", ""}, + {"version long flag", []string{"--version"}, 0, "fmtkit dev\n", ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + exitCode, stdout, stderr := runCLI(t, t.TempDir(), tc.args...) + + if exitCode != tc.wantExit { + t.Fatalf("exit = %d, want %d", exitCode, tc.wantExit) + } + + if stdout != tc.wantStdout { + t.Fatalf("stdout mismatch\n--- got ---\n%q\n--- want ---\n%q", stdout, tc.wantStdout) + } + + if stderr != tc.wantStderr { + t.Fatalf("stderr mismatch\n--- got ---\n%q\n--- want ---\n%q", stderr, tc.wantStderr) + } + }) + } +} + func TestPrintUsage(t *testing.T) { tests := []struct { name string @@ -443,7 +491,7 @@ func runCLI(t *testing.T, workdir string, args ...string) (int, string, string) var stdout strings.Builder var stderr strings.Builder - exitCode := run(context.Background(), args, &stdout, &stderr) + exitCode := app.GoCLI("dev", &stdout, &stderr).Dispatch(context.Background(), args) return exitCode, stdout.String(), stderr.String() } diff --git a/packages/go/driver/cmd/fmtkit-go/testdata/usage.txt b/packages/go/driver/cmd/fmtkit-go/testdata/usage.txt new file mode 100644 index 0000000..c326063 --- /dev/null +++ b/packages/go/driver/cmd/fmtkit-go/testdata/usage.txt @@ -0,0 +1,6 @@ +fmtkit check [paths...] + +fmtkit format [paths...] + +fmtkit sources [--include-declarations] [paths...] + diff --git a/packages/go/driver/cmd/fmtkit-sources/main.go b/packages/go/driver/cmd/fmtkit-sources/main.go deleted file mode 100644 index b6ff852..0000000 --- a/packages/go/driver/cmd/fmtkit-sources/main.go +++ /dev/null @@ -1,21 +0,0 @@ -package main - -import ( - "context" - "os" - "os/signal" - "syscall" - - "go.ollin.sh/fmtkit/driver/internal/sourcefiles" -) - -func main() { - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - - // os.Exit skips deferred calls, so release the signal handler explicitly - // before exiting with the captured code. - code := sourcefiles.Run(ctx, os.Args[1:], os.Stdout, os.Stderr) - - stop() - os.Exit(code) -} diff --git a/packages/go/driver/cmd/fmtkit/main.go b/packages/go/driver/cmd/fmtkit/main.go index 43750e7..afd624a 100644 --- a/packages/go/driver/cmd/fmtkit/main.go +++ b/packages/go/driver/cmd/fmtkit/main.go @@ -20,8 +20,8 @@ func main() { // os.Exit skips deferred calls, so release the signal handler explicitly // before exiting with the captured code. code := app. - New(version, os.Stdout, os.Stderr). - Run(ctx, os.Args[1:]) + Umbrella(version, os.Stdout, os.Stderr). + Dispatch(ctx, os.Args[1:]) stop() os.Exit(code) diff --git a/packages/go/driver/config/config.go b/packages/go/driver/config/config.go index 9b9438d..c52e385 100644 --- a/packages/go/driver/config/config.go +++ b/packages/go/driver/config/config.go @@ -10,69 +10,43 @@ type Toggle struct { Enabled bool `mapstructure:"enabled"` } -// Rules configures rule toggles exposed by the CLI. -type Rules struct { - Spacing Toggle `mapstructure:"spacing"` -} - -// Formatters configures formatter passes exposed by the CLI. -type Formatters struct { - Gofmt bool `mapstructure:"gofmt"` - Goimports bool `mapstructure:"goimports"` -} - -// Config controls CLI formatting and vet behavior. +// Config controls CLI formatting and vet behavior. It embeds the formatter +// config as the single source of truth for formatting options and adds the vet +// toggle the CLI owns. The squash tag flattens the embedded formatter keys to +// the top level so the on-disk schema stays a flat set of keys. type Config struct { - Rules Rules `mapstructure:"rules"` - Vet Toggle `mapstructure:"vet"` - Formatters Formatters `mapstructure:"formatters"` - Exclude []string `mapstructure:"exclude"` - NotPath []string `mapstructure:"not_path"` - NotName []string `mapstructure:"not_name"` - // Concurrency caps the number of files processed in parallel. - // Zero means use runtime.NumCPU(). - Concurrency int `mapstructure:"concurrency"` + formatterconfig.Config `mapstructure:",squash"` + + Vet Toggle `mapstructure:"vet"` } -// Default returns the default CLI configuration. +// Default returns the default CLI configuration: the formatter defaults with +// vet enabled. func Default() Config { return Config{ - Rules: Rules{ - Spacing: Toggle{Enabled: true}, - }, - Vet: Toggle{Enabled: true}, - Formatters: Formatters{ - Gofmt: true, - Goimports: true, - }, - Exclude: []string{ - ".git", - "node_modules", - "vendor", - }, - NotPath: []string{}, - NotName: []string{}, + Config: formatterconfig.Default(), + Vet: Toggle{Enabled: true}, } } -// FormatterConfig projects CLI config into the public formatter config type. -func (c Config) FormatterConfig() formatterconfig.Config { - return formatterconfig.Config{ - Rules: formatterconfig.Rules{ - Spacing: formatterconfig.RuleToggle{Enabled: c.Rules.Spacing.Enabled}, - }, - Formatters: formatterconfig.Formatters{ - Gofmt: c.Formatters.Gofmt, - Goimports: c.Formatters.Goimports, - }, - Exclude: c.Exclude, - NotPath: c.NotPath, - NotName: c.NotName, - Concurrency: c.Concurrency, - } +// Formatter returns the embedded formatter configuration. +func (c Config) Formatter() formatterconfig.Config { + return c.Config } // VetConfig projects CLI config into the public vet config type. func (c Config) VetConfig() vet.Config { return vet.Config{Enabled: c.Vet.Enabled} } + +// WithJobs applies a --jobs override to the formatter concurrency. A jobs value +// of -1 means "unset" and returns the config unchanged; any other value pins +// Concurrency (0 selects runtime.NumCPU()), matching the CLI's jobs-override +// semantics. +func (c Config) WithJobs(jobs int) Config { + if jobs != -1 { + c.Concurrency = jobs + } + + return c +} diff --git a/packages/go/driver/config/config_test.go b/packages/go/driver/config/config_test.go new file mode 100644 index 0000000..2905eec --- /dev/null +++ b/packages/go/driver/config/config_test.go @@ -0,0 +1,56 @@ +package config + +import ( + "reflect" + "testing" + + formatterconfig "go.ollin.sh/fmtkit/formatter/config" +) + +func TestDefaultComposesFormatterDefaults(t *testing.T) { + cfg := Default() + + if !reflect.DeepEqual(cfg.Formatter(), formatterconfig.Default()) { + t.Fatalf("Formatter() should equal formatter defaults\n got: %#v\nwant: %#v", cfg.Formatter(), formatterconfig.Default()) + } + + if !cfg.Vet.Enabled { + t.Fatal("expected vet enabled by default") + } +} + +func TestVetConfigProjectsToggle(t *testing.T) { + if got := Default().VetConfig().Enabled; !got { + t.Fatal("expected default vet config enabled") + } + + disabled := Default() + disabled.Vet.Enabled = false + + if got := disabled.VetConfig().Enabled; got { + t.Fatal("expected disabled vet config") + } +} + +func TestWithJobs(t *testing.T) { + tests := []struct { + name string + jobs int + want int + }{ + {name: "unset leaves concurrency", jobs: -1, want: 7}, + {name: "zero pins numcpu sentinel", jobs: 0, want: 0}, + {name: "positive pins worker count", jobs: 4, want: 4}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := Default() + cfg.Concurrency = 7 + + if got := cfg.WithJobs(tt.jobs).Concurrency; got != tt.want { + t.Fatalf("WithJobs(%d).Concurrency = %d, want %d", tt.jobs, got, tt.want) + } + }) + } +} diff --git a/packages/go/driver/config/load.go b/packages/go/driver/config/load.go index cdd1cdc..3ce931d 100644 --- a/packages/go/driver/config/load.go +++ b/packages/go/driver/config/load.go @@ -14,16 +14,12 @@ const DefaultFileName = "config.yml" // Load reads CLI configuration from disk or returns defaults when none exists. func Load(cwd, explicitPath string) (Config, error) { + // cfg starts fully populated with Default(); viper unmarshals the file onto + // it, and mapstructure leaves keys absent from the file untouched, so + // defaults survive without restating them as viper SetDefault calls. cfg := Default() v := viper.New() - v.SetDefault("rules.spacing.enabled", cfg.Rules.Spacing.Enabled) - v.SetDefault("vet.enabled", cfg.Vet.Enabled) - v.SetDefault("formatters.gofmt", cfg.Formatters.Gofmt) - v.SetDefault("formatters.goimports", cfg.Formatters.Goimports) - v.SetDefault("exclude", cfg.Exclude) - v.SetDefault("not_path", cfg.NotPath) - v.SetDefault("not_name", cfg.NotName) if explicitPath != "" { v.SetConfigFile(explicitPath) @@ -32,9 +28,9 @@ func Load(cwd, explicitPath string) (Config, error) { } if err := v.ReadInConfig(); err != nil { - var notFound viper.ConfigFileNotFoundError + _, notFound := errors.AsType[viper.ConfigFileNotFoundError](err) - if explicitPath == "" && (errors.As(err, ¬Found) || os.IsNotExist(err)) { + if explicitPath == "" && (notFound || os.IsNotExist(err)) { return cfg, nil } diff --git a/packages/go/driver/config/load_test.go b/packages/go/driver/config/load_test.go index 0e7a0b0..8966588 100644 --- a/packages/go/driver/config/load_test.go +++ b/packages/go/driver/config/load_test.go @@ -3,9 +3,132 @@ package config import ( "os" "path/filepath" + "reflect" "testing" ) +func writeConfig(t *testing.T, content string) string { + t.Helper() + + dir := t.TempDir() + + if err := os.WriteFile(filepath.Join(dir, DefaultFileName), []byte(content), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + return dir +} + +// TestLoadFullConfigRoundTrips pins that every top-level key in the config +// schema decodes onto its field. It guards the schema contract the loader must +// keep byte-compatible. +func TestLoadFullConfigRoundTrips(t *testing.T) { + dir := writeConfig(t, "rules:\n spacing:\n enabled: false\nvet:\n enabled: false\nformatters:\n gofmt: false\n goimports: false\nexclude:\n - build\nnot_path:\n - generated\nnot_name:\n - '*.pb.go'\nconcurrency: 4\n") + + cfg, err := Load(dir, "") + + if err != nil { + t.Fatalf("load config: %v", err) + } + + if cfg.Rules.Spacing.Enabled { + t.Fatalf("expected spacing disabled") + } + + if cfg.Vet.Enabled { + t.Fatalf("expected vet disabled") + } + + if cfg.Formatters.Gofmt || cfg.Formatters.Goimports { + t.Fatalf("expected formatters disabled: %#v", cfg.Formatters) + } + + if !reflect.DeepEqual(cfg.Exclude, []string{"build"}) { + t.Fatalf("unexpected exclude: %#v", cfg.Exclude) + } + + if !reflect.DeepEqual(cfg.NotPath, []string{"generated"}) { + t.Fatalf("unexpected not_path: %#v", cfg.NotPath) + } + + if !reflect.DeepEqual(cfg.NotName, []string{"*.pb.go"}) { + t.Fatalf("unexpected not_name: %#v", cfg.NotName) + } + + if cfg.Concurrency != 4 { + t.Fatalf("unexpected concurrency: %d", cfg.Concurrency) + } +} + +// TestLoadPartialKeepsDefaults pins that keys absent from the file retain their +// Default() values while the present key wins. This is the mapstructure +// leave-absent-keys-untouched behavior the loader relies on. +func TestLoadPartialKeepsDefaults(t *testing.T) { + dir := writeConfig(t, "rules:\n spacing:\n enabled: false\n") + + cfg, err := Load(dir, "") + + if err != nil { + t.Fatalf("load config: %v", err) + } + + if cfg.Rules.Spacing.Enabled { + t.Fatalf("expected spacing disabled from file") + } + + if !cfg.Vet.Enabled { + t.Fatalf("expected vet to keep default enabled") + } + + if !cfg.Formatters.Gofmt || !cfg.Formatters.Goimports { + t.Fatalf("expected formatters to keep defaults: %#v", cfg.Formatters) + } + + if !reflect.DeepEqual(cfg.Exclude, []string{".git", "node_modules", "vendor"}) { + t.Fatalf("expected exclude to keep default: %#v", cfg.Exclude) + } +} + +// TestLoadExplicitEmptyExcludeOverridesDefault pins that an explicit empty list +// clears the default exclude list rather than being treated as absent. +func TestLoadExplicitEmptyExcludeOverridesDefault(t *testing.T) { + dir := writeConfig(t, "exclude: []\n") + + cfg, err := Load(dir, "") + + if err != nil { + t.Fatalf("load config: %v", err) + } + + if len(cfg.Exclude) != 0 { + t.Fatalf("expected explicit empty exclude to override default, got %#v", cfg.Exclude) + } +} + +// TestLoadEmptyFileYieldsDefaults pins that a present-but-empty config file +// leaves every field at its Default() value. +func TestLoadEmptyFileYieldsDefaults(t *testing.T) { + dir := writeConfig(t, "") + + cfg, err := Load(dir, "") + + if err != nil { + t.Fatalf("load config: %v", err) + } + + if !reflect.DeepEqual(cfg, Default()) { + t.Fatalf("expected defaults for empty file\n got: %#v\nwant: %#v", cfg, Default()) + } +} + +// TestLoadExplicitMissingPathErrors pins that a missing explicit config path is +// an error, unlike a missing default-location file which falls back to defaults. +func TestLoadExplicitMissingPathErrors(t *testing.T) { + if _, err := Load(t.TempDir(), filepath.Join(t.TempDir(), "missing.yml")); err == nil { + t.Fatal("expected error for missing explicit config path") + } +} + func TestLoadDefaultsWhenConfigDoesNotExist(t *testing.T) { dir := t.TempDir() diff --git a/packages/go/driver/internal/app/app.go b/packages/go/driver/internal/app/app.go index b1743b5..0d8593d 100644 --- a/packages/go/driver/internal/app/app.go +++ b/packages/go/driver/internal/app/app.go @@ -5,68 +5,168 @@ import ( "fmt" "io" - "go.ollin.sh/fmtkit/driver/internal/cli" + "go.ollin.sh/fmtkit/driver/internal/command" + "go.ollin.sh/fmtkit/driver/internal/golang" + "go.ollin.sh/fmtkit/driver/internal/toolchain" + "go.ollin.sh/fmtkit/driver/internal/typescript" + "go.ollin.sh/fmtkit/driver/internal/typescript/sourcefiles" + report "go.ollin.sh/fmtkit/driver/report" ) -// App is the fmtkit command surface. The version is injected by the binary so -// release builds keep stamping it through -X main.version. -type App struct { +// deps carries what every command handler needs: the version stamped by the +// binary and the output streams. It is a pointer so the usage printer can be +// wired in after the Set is built. +type deps struct { version string stdout io.Writer stderr io.Writer + + // toolchains are the language lanes the format pipeline runs, in execution + // order (ts before go). The --ts/--go flags select among them. + toolchains toolchain.Registry + + // usage prints the enclosing Set's usage text; wired after the Set exists so + // the flag-parsing handlers can reprint it on a bad argument. + usage func(io.Writer) } -func New(version string, stdout, stderr io.Writer) App { - return App{ - version: version, - stdout: stdout, - stderr: stderr, +// umbrellaHeader is the top line of the umbrella usage; the per-command lines +// follow from each Command's Usage. +const umbrellaHeader = "usage: fmtkit [args...]\n" + +// Umbrella builds the fmtkit command surface: the pipeline commands plus the +// embedded Go formatter CLI reached through `fmtkit go`. +func Umbrella(version string, stdout, stderr io.Writer) command.Set { + // Register the language lanes explicitly, in execution order: TS (lint then + // format) runs before Go, matching the pipeline the driver has always run. + d := &deps{ + version: version, + stdout: stdout, + stderr: stderr, + toolchains: toolchain.NewRegistry(typescript.New(), golang.New()), } + + // The Go CLI reached through `fmtkit go` prints "fmtkit go ..." usage and + // adopts the umbrella's exit code for bad subcommands. + goSet := d.goCommandSet("fmtkit go", 2) + + set := command.Set{ + Name: "fmtkit", + Header: umbrellaHeader, + ErrExit: 2, + Stderr: stderr, + Commands: []command.Command{ + { + Name: "format", + Usage: " format [--ts] [--go] [--quiet] [paths...] format changed files (vs HEAD) and untracked ones\n", + Run: d.runFormat, + }, + { + Name: "format-all", + Usage: " format-all [--ts] [--go] [--quiet] format every file, against .\n --ts only TS/Vue lint + formatting; --go only Go formatting; default: all\n", + Run: d.runFormatAll, + }, + { + Name: "go", + Usage: " go run the Go formatter CLI\n", + Run: goSet.Dispatch, + }, + { + Name: "ts", + Usage: " ts [paths...] run TS/Vue formatting support and oxfmt\n", + Run: d.runTS, + }, + { + Name: "lint", + Usage: " lint [paths...] lint TS/Vue files with oxlint\n", + Run: d.runLint, + }, + { + Name: "check", + Usage: " check [args...] run the Go formatter in check mode\n", + Run: d.runCheck, + }, + { + Name: "version", + Aliases: []string{"--version", "-version"}, + Usage: " version print the fmtkit version\n", + Run: d.runVersion, + }, + }, + } + + d.usage = set.PrintUsage + + return set } -// Run dispatches a subcommand to its handler; each mode lives in its own file. -func (a App) Run(ctx context.Context, args []string) int { - if len(args) == 0 { - printUsage(a.stderr) +// GoCLI builds the standalone fmtkit-go command surface: check, format, +// sources, version, and help, exiting 1 on a bad subcommand. +func GoCLI(version string, stdout, stderr io.Writer) command.Set { + d := &deps{version: version, stdout: stdout, stderr: stderr} + + set := d.goCommandSet("fmtkit", 1) - return 2 + d.usage = set.PrintUsage + + return set +} + +// goCommandSet builds the Go formatter command group. name is the usage prefix +// ("fmtkit" standalone, "fmtkit go" under the umbrella) and errExit is the +// exit code for an empty or unknown subcommand. +func (d *deps) goCommandSet(name string, errExit int) command.Set { + usage := func(sub string) string { + return name + " " + sub + "\n\n" } - mode := args[0] - rest := args[1:] - - switch mode { - case "format": - return a.runFormat(ctx, rest) - case "format-all": - return a.runFormatAll(ctx, rest) - case "ts": - return a.runTS(ctx, rest) - case "lint": - return a.runLint(ctx, rest) - case "go": - return a.runGo(ctx, rest) - case "check": - return cli. - NewRunner(a.stdout, a.stderr). - Run(ctx, cli.CheckMode, rest) - case "version", "--version", "-version": - return a.printVersion() - case "help", "--help", "-h": - printUsage(a.stderr) - - return 0 - default: - _, _ = fmt.Fprintf(a.stderr, "unknown subcommand - {%q}\n\n", mode) - - printUsage(a.stderr) - - return 2 + return command.Set{ + Name: name, + ErrExit: errExit, + Stderr: d.stderr, + Commands: []command.Command{ + { + Name: "check", + Usage: usage("check [paths...]"), + Run: d.runCheck, + }, + { + Name: "format", + Usage: usage("format [paths...]"), + Run: d.runGoFormat, + }, + { + Name: "sources", + Usage: usage("sources [--include-declarations] [paths...]"), + Run: func(ctx context.Context, args []string) int { + return sourcefiles.Run(ctx, args, d.stdout, d.stderr) + }, + }, + { + Name: "version", + Aliases: []string{"--version", "-version"}, + Run: d.runVersion, + }, + }, } } -func (a App) printVersion() int { - _, _ = fmt.Fprintf(a.stdout, "fmtkit %s\n", a.version) +// goRunner is the unscoped Go formatter runner shared by `check` and the +// standalone `format`. +func (d *deps) goRunner() golang.Runner { + return golang.Runner{Stdout: d.stdout, Stderr: d.stderr} +} + +func (d *deps) runCheck(ctx context.Context, args []string) int { + return d.goRunner().Run(ctx, report.ModeCheck, args) +} + +func (d *deps) runGoFormat(ctx context.Context, args []string) int { + return d.goRunner().Run(ctx, report.ModeFormat, args) +} + +func (d *deps) runVersion(_ context.Context, _ []string) int { + _, _ = fmt.Fprintf(d.stdout, "fmtkit %s\n", d.version) return 0 } diff --git a/packages/go/driver/internal/app/app_test.go b/packages/go/driver/internal/app/app_test.go index bb56ab2..b200cb2 100644 --- a/packages/go/driver/internal/app/app_test.go +++ b/packages/go/driver/internal/app/app_test.go @@ -42,7 +42,7 @@ func runCLI(t *testing.T, workdir string, args ...string) (int, string, string) var stderr strings.Builder // "dev" mirrors the unstamped binary: no embedded TS assets. - exitCode := New("dev", &stdout, &stderr).Run(context.Background(), args) + exitCode := Umbrella("dev", &stdout, &stderr).Dispatch(context.Background(), args) return exitCode, stdout.String(), stderr.String() } @@ -103,6 +103,53 @@ func run() { return dir } +// TestDispatchGoldens pins the umbrella binary's usage text, version string, +// stdout/stderr routing, and exit codes byte for byte. The fmtkit-go binary +// deliberately differs (unknown -> exit 1, a different usage prefix); its +// counterpart lives in cmd/fmtkit-go/main_test.go. Keep both in sync only if the +// behavior is intentionally unified. +func TestDispatchGoldens(t *testing.T) { + usage, err := os.ReadFile(filepath.Join("testdata", "usage.txt")) + + if err != nil { + t.Fatalf("read usage golden: %v", err) + } + + cases := []struct { + name string + args []string + wantExit int + wantStdout string + wantStderr string + }{ + {"no args", nil, 2, "", string(usage)}, + {"unknown", []string{"bogus"}, 2, "", "unknown subcommand - {\"bogus\"}\n\n" + string(usage)}, + {"help", []string{"help"}, 0, "", string(usage)}, + {"help long flag", []string{"--help"}, 0, "", string(usage)}, + {"help short flag", []string{"-h"}, 0, "", string(usage)}, + {"version", []string{"version"}, 0, "fmtkit dev\n", ""}, + {"version long flag", []string{"--version"}, 0, "fmtkit dev\n", ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + exitCode, stdout, stderr := runCLI(t, t.TempDir(), tc.args...) + + if exitCode != tc.wantExit { + t.Fatalf("exit = %d, want %d", exitCode, tc.wantExit) + } + + if stdout != tc.wantStdout { + t.Fatalf("stdout mismatch\n--- got ---\n%q\n--- want ---\n%q", stdout, tc.wantStdout) + } + + if stderr != tc.wantStderr { + t.Fatalf("stderr mismatch\n--- got ---\n%q\n--- want ---\n%q", stderr, tc.wantStderr) + } + }) + } +} + func TestRunWithoutArgsPrintsUsage(t *testing.T) { exitCode, _, stderr := runCLI(t, t.TempDir()) diff --git a/packages/go/driver/internal/app/doc.go b/packages/go/driver/internal/app/doc.go index b84e779..b486365 100644 --- a/packages/go/driver/internal/app/doc.go +++ b/packages/go/driver/internal/app/doc.go @@ -1,5 +1,4 @@ // Package app implements the fmtkit command surface: the pipeline -// orchestration that infra/bin/fmtkit provides in the container images, fused -// with the Go formatter CLI and the embedded TS toolchain (see -// internal/tsruntime). +// orchestration fused with the Go formatter CLI and the embedded TS toolchain +// (see internal/typescript). package app diff --git a/packages/go/driver/internal/app/exit.go b/packages/go/driver/internal/app/exit.go index d8964ae..85050e2 100644 --- a/packages/go/driver/internal/app/exit.go +++ b/packages/go/driver/internal/app/exit.go @@ -8,18 +8,16 @@ import ( // reportError maps a tool failure onto an exit code, propagating the child's // own code when it already reported the problem itself. -func (a App) reportError(err error) int { +func (d *deps) reportError(err error) int { if err == nil { return 0 } - var exit *exec.ExitError - - if errors.As(err, &exit) { + if exit, ok := errors.AsType[*exec.ExitError](err); ok { return exit.ExitCode() } - _, _ = fmt.Fprintf(a.stderr, "fmtkit: %v\n", err) + _, _ = fmt.Fprintf(d.stderr, "fmtkit: %v\n", err) return 1 } diff --git a/packages/go/driver/internal/app/format.go b/packages/go/driver/internal/app/format.go index 72c03a4..18c883a 100644 --- a/packages/go/driver/internal/app/format.go +++ b/packages/go/driver/internal/app/format.go @@ -3,81 +3,84 @@ package app import ( "context" "fmt" - "io" + "strings" - "go.ollin.sh/fmtkit/driver/internal/cli" - "go.ollin.sh/fmtkit/driver/internal/orchestrator" - "go.ollin.sh/fmtkit/driver/internal/sourcefiles" - "go.ollin.sh/fmtkit/driver/internal/tsruntime" + "go.ollin.sh/fmtkit/driver/internal/console" + "go.ollin.sh/fmtkit/driver/internal/gitfiles" + "go.ollin.sh/fmtkit/driver/internal/pipeline" + "go.ollin.sh/fmtkit/driver/internal/toolchain" ) // runFormat formats what diverges from HEAD — modified files, staged or not, // plus untracked ones — so an everyday format stays proportional to the diff. // Use format-all to cover every file. -func (a App) runFormat(ctx context.Context, args []string) int { +func (d *deps) runFormat(ctx context.Context, args []string) int { opts, paths, err := parseFormatArgs(args) if err != nil { - _, _ = fmt.Fprintf(a.stderr, "%v\n\n", err) + _, _ = fmt.Fprintf(d.stderr, "%v\n\n", err) - printUsage(a.stderr) + d.usage(d.stderr) return 2 } - return a.runPipeline(ctx, paths, opts, sourcefiles.SelectionChanged) + return d.runPipeline(ctx, paths, opts, gitfiles.SelectionChanged) } // runFormatAll covers every non-ignored file rather than just the working // tree's changes, pinned to the current directory, so it takes flags but // rejects paths. -func (a App) runFormatAll(ctx context.Context, args []string) int { +func (d *deps) runFormatAll(ctx context.Context, args []string) int { opts, extra, err := parseFormatArgs(args) if err != nil || len(extra) != 0 { if err != nil { - _, _ = fmt.Fprintf(a.stderr, "%v\n\n", err) + _, _ = fmt.Fprintf(d.stderr, "%v\n\n", err) } - printUsage(a.stderr) + d.usage(d.stderr) return 2 } - return a.runPipeline(ctx, []string{"."}, opts, sourcefiles.SelectionAll) + return d.runPipeline(ctx, []string{"."}, opts, gitfiles.SelectionAll) } -func (a App) runPipeline(ctx context.Context, paths []string, opts formatOptions, selection sourcefiles.Selection) int { - pipeline := orchestrator.Pipeline{ - Tools: orchestrator.Tools{ - TS: func(ctx context.Context, scopes []string, output io.Writer) error { - support, err := tsruntime.Resolve(a.version) - - if err != nil { - return err - } - - return support.RunPipeline(ctx, tsruntime.RunOptions{Scopes: scopes, Selection: selection, Stdout: output, Stderr: output}) - }, - Lint: func(ctx context.Context, scopes []string, output io.Writer) error { - support, err := tsruntime.Resolve(a.version) - - if err != nil { - return err - } - - return support.RunLint(ctx, tsruntime.RunOptions{Scopes: scopes, Selection: selection, Fix: true, Stdout: output, Stderr: output}) - }, - Go: func(ctx context.Context, args []string, output io.Writer) int { - return cli. - NewScopedRunner(output, output, selection). - Run(ctx, cli.FormatMode, args[1:]) - }, - }, - Steps: opts.steps, - Quiet: opts.quiet, - Stderr: a.stderr, +// runPipeline frames the format run (target header, completion footer) around +// the typed steps the selected lanes contribute, handing them to the generic +// pipeline. Color is resolved once here, at the composition root. +func (d *deps) runPipeline(ctx context.Context, paths []string, opts formatOptions, selection gitfiles.Selection) int { + if len(paths) == 0 { + paths = []string{"."} } - return pipeline.RunFormat(ctx, paths) + printer := console.NewPrinter(d.stderr, console.DetectColor(d.stderr)) + + printer.Section("Formatting target(s)") + printer.Detail("paths", strings.Join(paths, " ")) + + req := toolchain.Request{Version: d.version, Paths: paths, Selection: selection} + + var steps []pipeline.Step + + for _, chain := range d.toolchains.Select(opts.toolchains...) { + steps = append(steps, chain.Steps(req)...) + } + + pipe := pipeline.Pipeline{ + Steps: steps, + Quiet: opts.quiet, + Printer: printer, + Stderr: d.stderr, + } + + if code := pipe.Run(ctx); code != 0 { + return code + } + + printer.Section("Formatting complete") + printer.SuccessDetail("status", "done") + + return 0 } diff --git a/packages/go/driver/internal/app/golang.go b/packages/go/driver/internal/app/golang.go deleted file mode 100644 index dbe7d3e..0000000 --- a/packages/go/driver/internal/app/golang.go +++ /dev/null @@ -1,43 +0,0 @@ -package app - -import ( - "context" - "fmt" - - "go.ollin.sh/fmtkit/driver/internal/cli" -) - -// runGo mirrors the fmtkit-go command surface so `fmtkit go ...` behaves like -// the container's Go formatter CLI. -func (a App) runGo(ctx context.Context, args []string) int { - if len(args) == 0 { - printGoUsage(a.stderr) - - return 2 - } - - switch args[0] { - case "check": - return cli. - NewRunner(a.stdout, a.stderr). - Run(ctx, cli.CheckMode, args[1:]) - case "format": - return cli. - NewRunner(a.stdout, a.stderr). - Run(ctx, cli.FormatMode, args[1:]) - case "sources": - return cli.RunSources(ctx, args[1:], a.stdout, a.stderr) - case "version", "--version", "-version": - return a.printVersion() - case "help", "--help", "-h": - printGoUsage(a.stderr) - - return 0 - default: - _, _ = fmt.Fprintf(a.stderr, "unknown subcommand - {%q}\n\n", args[0]) - - printGoUsage(a.stderr) - - return 2 - } -} diff --git a/packages/go/driver/internal/app/options.go b/packages/go/driver/internal/app/options.go index 75d548e..e255736 100644 --- a/packages/go/driver/internal/app/options.go +++ b/packages/go/driver/internal/app/options.go @@ -3,17 +3,17 @@ package app import ( "fmt" "strings" - - "go.ollin.sh/fmtkit/driver/internal/orchestrator" ) type formatOptions struct { - steps orchestrator.Steps - quiet bool + // toolchains names the lanes to run, as the registry selects them. Empty + // means every lane (the no-flag default); --ts and --go narrow it. + toolchains []string + quiet bool } // parseFormatArgs splits the format/format-all flags from the paths. With no -// step flags the whole pipeline runs; --ts and --go narrow it. +// lane flags every lane runs; --ts and --go narrow it. func parseFormatArgs(args []string) (formatOptions, []string, error) { var opts formatOptions @@ -22,9 +22,9 @@ func parseFormatArgs(args []string) (formatOptions, []string, error) { for _, arg := range args { switch arg { case "--ts": - opts.steps.TS = true + opts.toolchains = append(opts.toolchains, "ts") case "--go": - opts.steps.Go = true + opts.toolchains = append(opts.toolchains, "go") case "--quiet", "-q": opts.quiet = true default: diff --git a/packages/go/driver/internal/app/testdata/usage.txt b/packages/go/driver/internal/app/testdata/usage.txt new file mode 100644 index 0000000..7ddb13d --- /dev/null +++ b/packages/go/driver/internal/app/testdata/usage.txt @@ -0,0 +1,9 @@ +usage: fmtkit [args...] + format [--ts] [--go] [--quiet] [paths...] format changed files (vs HEAD) and untracked ones + format-all [--ts] [--go] [--quiet] format every file, against . + --ts only TS/Vue lint + formatting; --go only Go formatting; default: all + go run the Go formatter CLI + ts [paths...] run TS/Vue formatting support and oxfmt + lint [paths...] lint TS/Vue files with oxlint + check [args...] run the Go formatter in check mode + version print the fmtkit version diff --git a/packages/go/driver/internal/app/ts.go b/packages/go/driver/internal/app/ts.go index 2c25359..520496f 100644 --- a/packages/go/driver/internal/app/ts.go +++ b/packages/go/driver/internal/app/ts.go @@ -3,25 +3,25 @@ package app import ( "context" - "go.ollin.sh/fmtkit/driver/internal/tsruntime" + "go.ollin.sh/fmtkit/driver/internal/typescript/runtime" ) -func (a App) runTS(ctx context.Context, paths []string) int { - support, err := tsruntime.Resolve(a.version) +func (d *deps) runTS(ctx context.Context, paths []string) int { + assets, err := runtime.Resolve(d.version) if err != nil { - return a.reportError(err) + return d.reportError(err) } - return a.reportError(support.RunPipeline(ctx, tsruntime.RunOptions{Scopes: paths, Stdout: a.stdout, Stderr: a.stderr})) + return d.reportError(runtime.NewInvoker(assets).RunPipeline(ctx, runtime.Request{Scopes: paths, Stdout: d.stdout, Stderr: d.stderr})) } -func (a App) runLint(ctx context.Context, paths []string) int { - support, err := tsruntime.Resolve(a.version) +func (d *deps) runLint(ctx context.Context, paths []string) int { + assets, err := runtime.Resolve(d.version) if err != nil { - return a.reportError(err) + return d.reportError(err) } - return a.reportError(support.RunLint(ctx, tsruntime.RunOptions{Scopes: paths, Stdout: a.stdout, Stderr: a.stderr})) + return d.reportError(runtime.NewInvoker(assets).RunLint(ctx, runtime.Request{Scopes: paths, Stdout: d.stdout, Stderr: d.stderr})) } diff --git a/packages/go/driver/internal/app/usage.go b/packages/go/driver/internal/app/usage.go deleted file mode 100644 index 64e125a..0000000 --- a/packages/go/driver/internal/app/usage.go +++ /dev/null @@ -1,24 +0,0 @@ -package app - -import ( - "fmt" - "io" -) - -func printUsage(w io.Writer) { - _, _ = fmt.Fprintf(w, "usage: fmtkit [args...]\n") - _, _ = fmt.Fprintf(w, " format [--ts] [--go] [--quiet] [paths...] format changed files (vs HEAD) and untracked ones\n") - _, _ = fmt.Fprintf(w, " format-all [--ts] [--go] [--quiet] format every file, against .\n") - _, _ = fmt.Fprintf(w, " --ts only TS/Vue lint + formatting; --go only Go formatting; default: all\n") - _, _ = fmt.Fprintf(w, " go run the Go formatter CLI\n") - _, _ = fmt.Fprintf(w, " ts [paths...] run TS/Vue formatting support and oxfmt\n") - _, _ = fmt.Fprintf(w, " lint [paths...] lint TS/Vue files with oxlint\n") - _, _ = fmt.Fprintf(w, " check [args...] run the Go formatter in check mode\n") - _, _ = fmt.Fprintf(w, " version print the fmtkit version\n") -} - -func printGoUsage(w io.Writer) { - _, _ = fmt.Fprintf(w, "fmtkit go check [paths...]\n\n") - _, _ = fmt.Fprintf(w, "fmtkit go format [paths...]\n\n") - _, _ = fmt.Fprintf(w, "fmtkit go sources [--include-declarations] [paths...]\n\n") -} diff --git a/packages/go/driver/internal/cli/mode.go b/packages/go/driver/internal/cli/mode.go deleted file mode 100644 index 03999b9..0000000 --- a/packages/go/driver/internal/cli/mode.go +++ /dev/null @@ -1,12 +0,0 @@ -package cli - -type Mode string - -const ( - CheckMode Mode = "check" - FormatMode Mode = "format" -) - -func (m Mode) String() string { - return string(m) -} diff --git a/packages/go/driver/internal/cli/mode_test.go b/packages/go/driver/internal/cli/mode_test.go deleted file mode 100644 index 52ce60c..0000000 --- a/packages/go/driver/internal/cli/mode_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package cli - -import "testing" - -func TestModeString(t *testing.T) { - if got := CheckMode.String(); got != "check" { - t.Fatalf("CheckMode.String() = %q", got) - } - - if got := FormatMode.String(); got != "format" { - t.Fatalf("FormatMode.String() = %q", got) - } -} diff --git a/packages/go/driver/internal/cli/options.go b/packages/go/driver/internal/cli/options.go deleted file mode 100644 index b703525..0000000 --- a/packages/go/driver/internal/cli/options.go +++ /dev/null @@ -1,12 +0,0 @@ -package cli - -type options struct { - mode Mode - configPath string - reportRoot string - outputFormat string - positional []string - // jobs overrides config.Concurrency when not -1. -1 means "unset" - // (no override); 0 means "use NumCPU"; positive values pin the worker count. - jobs int -} diff --git a/packages/go/driver/internal/cli/parser.go b/packages/go/driver/internal/cli/parser.go deleted file mode 100644 index 80aab8e..0000000 --- a/packages/go/driver/internal/cli/parser.go +++ /dev/null @@ -1,66 +0,0 @@ -package cli - -import ( - "flag" - "io" - "os" - "strconv" - "strings" -) - -type parser struct { - stderr io.Writer -} - -func newParser(stderr io.Writer) parser { - return parser{stderr: stderr} -} - -func (p parser) Parse(mode Mode, args []string) (options, error) { - fs := flag.NewFlagSet(mode.String(), flag.ContinueOnError) - fs.SetOutput(p.stderr) - - configPath := fs.String("config", "", "Path to fmtkit YAML config") - reportRoot := fs.String("cwd", "", "Path used for config discovery and report-relative file paths") - outputFormat := fs.String("format", "text", "Output format: text, json, agent") - jobs := fs.Int("jobs", envJobs(), "Max files processed in parallel (0 = NumCPU; also reads FMTKIT_JOBS)") - - if err := fs.Parse(args); err != nil { - return options{}, err - } - - return options{ - mode: mode, - configPath: *configPath, - reportRoot: *reportRoot, - outputFormat: *outputFormat, - positional: fs.Args(), - jobs: *jobs, - }, nil -} - -// envJobs reads FMTKIT_JOBS as the default for the --jobs flag. -// Returns -1 when the env var is unset so the runner can distinguish -// "unset" from an explicit 0 (which means "use NumCPU"). -// Invalid values fall back to -1 as well. -func envJobs() int { - val, ok := os.LookupEnv("FMTKIT_JOBS") - - if !ok { - return -1 - } - - raw := strings.TrimSpace(val) - - if raw == "" { - return -1 - } - - n, err := strconv.Atoi(raw) - - if err != nil || n < 0 { - return -1 - } - - return n -} diff --git a/packages/go/driver/internal/cli/runner.go b/packages/go/driver/internal/cli/runner.go deleted file mode 100644 index 32ee850..0000000 --- a/packages/go/driver/internal/cli/runner.go +++ /dev/null @@ -1,204 +0,0 @@ -package cli - -import ( - "context" - "fmt" - "io" - "os" - "strings" - - driverconfig "go.ollin.sh/fmtkit/driver/config" - "go.ollin.sh/fmtkit/driver/internal/sourcefiles" - driverreport "go.ollin.sh/fmtkit/driver/report" - "go.ollin.sh/fmtkit/formatter" - formatterconfig "go.ollin.sh/fmtkit/formatter/config" - formatterengine "go.ollin.sh/fmtkit/formatter/engine" - "go.ollin.sh/fmtkit/vet" -) - -type Runner struct { - stdout io.Writer - stderr io.Writer - parser parser - - // selection is how much of the working tree the formatter covers. The zero - // value covers everything, which is what `fmtkit go` and `fmtkit check` want. - selection sourcefiles.Selection -} - -func NewRunner(stdout, stderr io.Writer) Runner { - return Runner{ - stdout: stdout, - stderr: stderr, - parser: newParser(stderr), - } -} - -// NewScopedRunner returns a Runner whose formatter covers only the part of the -// working tree that selection names. -func NewScopedRunner(stdout, stderr io.Writer, selection sourcefiles.Selection) Runner { - runner := NewRunner(stdout, stderr) - runner.selection = selection - - return runner -} - -func (r Runner) Run(ctx context.Context, mode Mode, args []string) int { - opts, err := r.parser.Parse(mode, args) - - if err != nil { - return 1 - } - - workRoot, err := os.Getwd() - - if err != nil { - r.writeError("resolve cwd: %v\n", err) - - return 1 - } - - reportRoot := workRoot - - if strings.TrimSpace(opts.reportRoot) != "" { - reportRoot = opts.reportRoot - } - - cfg, err := driverconfig.Load(reportRoot, opts.configPath) - - if err != nil { - r.writeError("%v\n", err) - - return 1 - } - - runPaths := opts.positional - - formatterCfg := cfg.FormatterConfig() - - if opts.jobs != -1 { - formatterCfg.Concurrency = opts.jobs - } - - formatterReport, err := r.runFormatter(ctx, mode, runPaths, formatterCfg) - - if err != nil { - r.writeError("%v\n", err) - - return 1 - } - - result := driverreport.Combined{ - Formatter: formatterReport, - Vet: vet.Run(ctx, workRoot, cfg.VetConfig()), - } - - if err := driverreport.Render(r.stdout, opts.outputFormat, reportRoot, mode.String(), result); err != nil { - r.writeError("render report: %v\n", err) - - return 1 - } - - return exitCode(mode, result) -} - -func (r Runner) runFormatter(ctx context.Context, mode Mode, paths []string, cfg formatterconfig.Config) (formatterengine.Report, error) { - if r.selection == sourcefiles.SelectionChanged { - files, err := changedGoFiles(ctx, paths, cfg) - - if err != nil { - return formatterengine.Report{}, err - } - - switch mode { - case CheckMode: - return formatter.CheckFiles(ctx, files, cfg) - case FormatMode: - return formatter.FormatFiles(ctx, files, cfg) - default: - return formatterengine.Report{}, fmt.Errorf("unsupported mode %q", mode) - } - } - - switch mode { - case CheckMode: - return formatter.Check(ctx, paths, cfg) - case FormatMode: - return formatter.Format(ctx, paths, cfg) - default: - return formatterengine.Report{}, fmt.Errorf("unsupported mode %q", mode) - } -} - -// changedGoFiles narrows the files the formatter owns down to the ones the -// working tree has touched. -// -// It intersects rather than asking git for `*.go` directly: the engine's walk is -// what applies cfg's exclusions (vendor, not_path/not_name, generated files), -// and git knows nothing about those. Taking the engine's list and keeping only -// what git reports as changed preserves both. Outside a git work tree there is -// no such thing as "changed", so the error surfaces rather than silently -// formatting everything. -func changedGoFiles(ctx context.Context, paths []string, cfg formatterconfig.Config) ([]string, error) { - owned, err := formatterengine.CollectGoFiles(paths, cfg) - - if err != nil { - return nil, err - } - - if len(owned) == 0 { - return nil, nil - } - - cwd, err := os.Getwd() - - if err != nil { - return nil, fmt.Errorf("resolve cwd: %w", err) - } - - touched, err := sourcefiles.ChangedPaths(ctx, cwd, paths) - - if err != nil { - return nil, err - } - - changed := make(map[string]struct{}, len(touched)) - - for _, path := range touched { - changed[path] = struct{}{} - } - - files := make([]string, 0, len(owned)) - - for _, path := range owned { - if _, ok := changed[path]; ok { - files = append(files, path) - } - } - - return files, nil -} - -func exitCode(mode Mode, result driverreport.Combined) int { - if result.Vet.ErrorCount() > 0 { - return 1 - } - - if mode == CheckMode { - if result.Formatter.Result == formatterengine.ResultPass { - return 0 - } - - return 1 - } - - if result.Formatter.ErrorCount() > 0 { - return 1 - } - - return 0 -} - -func (r Runner) writeError(format string, args ...any) { - _, _ = fmt.Fprintf(r.stderr, format, args...) -} diff --git a/packages/go/driver/internal/cli/sources.go b/packages/go/driver/internal/cli/sources.go deleted file mode 100644 index 7f7817c..0000000 --- a/packages/go/driver/internal/cli/sources.go +++ /dev/null @@ -1,12 +0,0 @@ -package cli - -import ( - "context" - "io" - - "go.ollin.sh/fmtkit/driver/internal/sourcefiles" -) - -func RunSources(ctx context.Context, args []string, stdout, stderr io.Writer) int { - return sourcefiles.Run(ctx, args, stdout, stderr) -} diff --git a/packages/go/driver/internal/command/command.go b/packages/go/driver/internal/command/command.go new file mode 100644 index 0000000..d9b0236 --- /dev/null +++ b/packages/go/driver/internal/command/command.go @@ -0,0 +1,94 @@ +// Package command is the CLI dispatch table shared by both fmtkit binaries. +// A Set is a named group of Commands with its own usage text and error exit +// code, so the two binaries' deliberate divergences (the umbrella exits 2 on a +// bad subcommand and prefixes its Go usage with "fmtkit go", the standalone +// fmtkit-go exits 1 and prefixes with "fmtkit") live in Set fields rather than +// in branching code. +package command + +import ( + "context" + "fmt" + "io" + "slices" +) + +// Command is one dispatchable subcommand. Name is how it is invoked; Aliases +// are equivalent spellings (e.g. --version). Usage is this command's line(s) in +// the parent Set's usage text. Run receives the arguments after the command +// name and returns the process exit code. +type Command struct { + Name string + Aliases []string + Usage string + Run func(ctx context.Context, args []string) int +} + +// Set is a named group of Commands. Header prefixes the usage text; ErrExit is +// the exit code for an empty or unknown subcommand; Stderr is where usage and +// errors are written. +type Set struct { + Name string + Header string + Commands []Command + ErrExit int + Stderr io.Writer +} + +func (c Command) matches(name string) bool { + if name == c.Name { + return true + } + + return slices.Contains(c.Aliases, name) +} + +// Dispatch routes args to a command. An empty argument list or an unknown +// subcommand prints the usage and returns ErrExit; help (help/--help/-h) prints +// the usage and returns 0; otherwise the matching command runs. +func (s Set) Dispatch(ctx context.Context, args []string) int { + if len(args) == 0 { + s.PrintUsage(s.Stderr) + + return s.ErrExit + } + + name := args[0] + rest := args[1:] + + if isHelp(name) { + s.PrintUsage(s.Stderr) + + return 0 + } + + for _, command := range s.Commands { + if command.matches(name) { + return command.Run(ctx, rest) + } + } + + _, _ = fmt.Fprintf(s.Stderr, "unknown subcommand - {%q}\n\n", name) + + s.PrintUsage(s.Stderr) + + return s.ErrExit +} + +// PrintUsage writes the Set's Header followed by each command's Usage line. +func (s Set) PrintUsage(w io.Writer) { + _, _ = io.WriteString(w, s.Header) + + for _, command := range s.Commands { + _, _ = io.WriteString(w, command.Usage) + } +} + +func isHelp(name string) bool { + switch name { + case "help", "--help", "-h": + return true + default: + return false + } +} diff --git a/packages/go/driver/internal/command/command_test.go b/packages/go/driver/internal/command/command_test.go new file mode 100644 index 0000000..19fc1a6 --- /dev/null +++ b/packages/go/driver/internal/command/command_test.go @@ -0,0 +1,152 @@ +package command + +import ( + "bytes" + "context" + "testing" +) + +// fixtureSet builds a Set with a run-recording command, one aliased command, +// and the given error exit code. +func fixtureSet(errExit int, stderr *bytes.Buffer, ran *string) Set { + record := func(name string) func(context.Context, []string) int { + return func(_ context.Context, args []string) int { + *ran = name + + return len(args) + } + } + + return Set{ + Name: "tool", + Header: "usage: tool \n", + ErrExit: errExit, + Stderr: stderr, + Commands: []Command{ + {Name: "do", Usage: " do do the thing\n", Run: record("do")}, + { + Name: "ping", + Aliases: []string{"--ping", "-p"}, + Usage: " ping ping the thing\n", + Run: record("ping"), + }, + {Name: "version", Aliases: []string{"--version"}, Run: record("version")}, + }, + } +} + +func TestDispatchRoutesToCommand(t *testing.T) { + var stderr bytes.Buffer + + var ran string + + set := fixtureSet(2, &stderr, &ran) + + if code := set.Dispatch(context.Background(), []string{"do", "a", "b"}); code != 2 { + t.Fatalf("Run should receive 2 args, got exit %d", code) + } + + if ran != "do" { + t.Fatalf("expected do to run, got %q", ran) + } + + if stderr.Len() != 0 { + t.Fatalf("unexpected stderr: %q", stderr.String()) + } +} + +func TestDispatchMatchesAliases(t *testing.T) { + for _, alias := range []string{"ping", "--ping", "-p"} { + var stderr bytes.Buffer + + var ran string + + set := fixtureSet(1, &stderr, &ran) + + if code := set.Dispatch(context.Background(), []string{alias}); code != 0 { + t.Fatalf("alias %q: unexpected exit %d", alias, code) + } + + if ran != "ping" { + t.Fatalf("alias %q did not route to ping, ran %q", alias, ran) + } + } +} + +func TestDispatchEmptyPrintsUsageAndErrExit(t *testing.T) { + var stderr bytes.Buffer + + var ran string + + set := fixtureSet(2, &stderr, &ran) + + if code := set.Dispatch(context.Background(), nil); code != 2 { + t.Fatalf("empty args exit = %d, want ErrExit 2", code) + } + + want := "usage: tool \n do do the thing\n ping ping the thing\n" + + if stderr.String() != want { + t.Fatalf("usage mismatch\n got: %q\nwant: %q", stderr.String(), want) + } +} + +func TestDispatchUnknownPrintsErrorThenUsage(t *testing.T) { + var stderr bytes.Buffer + + var ran string + + set := fixtureSet(1, &stderr, &ran) + + if code := set.Dispatch(context.Background(), []string{"bogus"}); code != 1 { + t.Fatalf("unknown exit = %d, want ErrExit 1", code) + } + + want := "unknown subcommand - {\"bogus\"}\n\nusage: tool \n do do the thing\n ping ping the thing\n" + + if stderr.String() != want { + t.Fatalf("unknown output mismatch\n got: %q\nwant: %q", stderr.String(), want) + } + + if ran != "" { + t.Fatalf("no command should have run, ran %q", ran) + } +} + +func TestDispatchHelpPrintsUsageAndExitsZero(t *testing.T) { + for _, arg := range []string{"help", "--help", "-h"} { + var stderr bytes.Buffer + + var ran string + + set := fixtureSet(2, &stderr, &ran) + + if code := set.Dispatch(context.Background(), []string{arg}); code != 0 { + t.Fatalf("help arg %q: exit = %d, want 0", arg, code) + } + + if ran != "" { + t.Fatalf("help must not run a command, ran %q", ran) + } + + if stderr.Len() == 0 { + t.Fatalf("help arg %q printed no usage", arg) + } + } +} + +func TestPrintUsageComposesHeaderAndCommands(t *testing.T) { + var out bytes.Buffer + + var ran string + + set := fixtureSet(2, &bytes.Buffer{}, &ran) + + set.PrintUsage(&out) + + want := "usage: tool \n do do the thing\n ping ping the thing\n" + + if out.String() != want { + t.Fatalf("PrintUsage mismatch\n got: %q\nwant: %q", out.String(), want) + } +} diff --git a/packages/go/driver/internal/console/printer.go b/packages/go/driver/internal/console/printer.go new file mode 100644 index 0000000..117da12 --- /dev/null +++ b/packages/go/driver/internal/console/printer.go @@ -0,0 +1,144 @@ +// Package console renders the pipeline's sectioned, ANSI-colored progress +// output: section headers, aligned detail lines, failure banners, and the +// indented live stream of a child tool's output. Color detection is resolved +// once by the caller (see DetectColor) and handed to NewPrinter, so the printer +// itself never reads the environment. +package console + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/mattn/go-isatty" +) + +// ColorMode is whether a Printer emits ANSI escape sequences. +type ColorMode int + +// Printer renders progress output to a writer. The palette fields are empty +// strings when color is off, so the same format strings render plain text. +type Printer struct { + w io.Writer + + bold string + dim string + cyan string + green string + red string + reset string +} + +type indentWriter struct { + printer *Printer + partial strings.Builder +} + +const ( + // ColorAuto defers the decision to DetectColor. NewPrinter treats it as + // no-color, so callers resolve it through DetectColor before constructing a + // Printer rather than passing it through. + ColorAuto ColorMode = iota + + // ColorAlways forces ANSI color on. + ColorAlways + + // ColorNever forces ANSI color off. + ColorNever +) + +// DetectColor resolves whether color should be used when writing to w. It +// honors FORCE_COLOR (always on) and NO_COLOR (always off) before falling back +// to whether w is a terminal. This is the single place the environment is read; +// callers resolve it once and pass the result to NewPrinter. +func DetectColor(w io.Writer) ColorMode { + if os.Getenv("FORCE_COLOR") != "" { + return ColorAlways + } + + if os.Getenv("NO_COLOR") != "" { + return ColorNever + } + + if file, ok := w.(*os.File); ok && isatty.IsTerminal(file.Fd()) { + return ColorAlways + } + + return ColorNever +} + +// NewPrinter builds a Printer writing to w. ANSI color is enabled only for +// ColorAlways; ColorAuto and ColorNever both render plain text, so callers pass +// the resolved result of DetectColor. +func NewPrinter(w io.Writer, mode ColorMode) *Printer { + p := &Printer{w: w} + + if mode == ColorAlways { + p.bold = "\033[1m" + p.dim = "\033[2m" + p.cyan = "\033[36m" + p.green = "\033[32m" + p.red = "\033[31m" + p.reset = "\033[0m" + } + + return p +} + +// Section prints a bold, cyan-arrowed section header preceded by a blank line. +func (p *Printer) Section(msg string) { + _, _ = fmt.Fprintf(p.w, "\n%s==>%s %s%s%s\n", p.cyan, p.reset, p.bold, msg, p.reset) +} + +// Detail prints an aligned label/value line under the current section. +func (p *Printer) Detail(label, value string) { + _, _ = fmt.Fprintf(p.w, " %s%-12s%s %s\n", p.dim, label, p.reset, value) +} + +// SuccessDetail prints an aligned label/value line in green. +func (p *Printer) SuccessDetail(label, value string) { + _, _ = fmt.Fprintf(p.w, " %s%-12s%s %s%s%s\n", p.green, label, p.reset, p.green, value, p.reset) +} + +// Failure prints a red, banged failure banner preceded by a blank line. +func (p *Printer) Failure(msg string) { + _, _ = fmt.Fprintf(p.w, "\n%s!!%s %s%s%s\n", p.red, p.reset, p.bold, msg, p.reset) +} + +// Stream returns a writer that renders a child tool's output live, dimmed and +// indented under the current section. Callers must Close it to flush a trailing +// partial line. +func (p *Printer) Stream() io.WriteCloser { + return &indentWriter{printer: p} +} + +func (w *indentWriter) Write(p []byte) (int, error) { + for _, b := range p { + if b != '\n' { + w.partial.WriteByte(b) + + continue + } + + w.flushLine() + } + + return len(p), nil +} + +func (w *indentWriter) Close() error { + if w.partial.Len() > 0 { + w.flushLine() + } + + return nil +} + +func (w *indentWriter) flushLine() { + p := w.printer + + _, _ = fmt.Fprintf(p.w, " %s%s%s\n", p.dim, w.partial.String(), p.reset) + + w.partial.Reset() +} diff --git a/packages/go/driver/internal/console/printer_test.go b/packages/go/driver/internal/console/printer_test.go new file mode 100644 index 0000000..2182c1f --- /dev/null +++ b/packages/go/driver/internal/console/printer_test.go @@ -0,0 +1,103 @@ +package console + +import ( + "strings" + "testing" +) + +func TestDetectColorHonorsForceColor(t *testing.T) { + t.Setenv("NO_COLOR", "1") + t.Setenv("FORCE_COLOR", "1") + + if got := DetectColor(&strings.Builder{}); got != ColorAlways { + t.Fatalf("DetectColor with FORCE_COLOR = %v, want ColorAlways", got) + } +} + +func TestDetectColorHonorsNoColor(t *testing.T) { + t.Setenv("FORCE_COLOR", "") + t.Setenv("NO_COLOR", "1") + + if got := DetectColor(&strings.Builder{}); got != ColorNever { + t.Fatalf("DetectColor with NO_COLOR = %v, want ColorNever", got) + } +} + +func TestDetectColorNonTerminalIsNever(t *testing.T) { + t.Setenv("FORCE_COLOR", "") + t.Setenv("NO_COLOR", "") + + // A strings.Builder is not an *os.File, so it is never a terminal. + if got := DetectColor(&strings.Builder{}); got != ColorNever { + t.Fatalf("DetectColor for non-tty = %v, want ColorNever", got) + } +} + +func TestPrinterPlainRendering(t *testing.T) { + var buf strings.Builder + + p := NewPrinter(&buf, ColorNever) + + p.Section("Running Go formatting") + p.Detail("fmtkit", "Formatted 2 file(s).") + p.SuccessDetail("status", "done") + p.Failure("Running Go formatting failed") + + want := "\n==> Running Go formatting\n" + + " fmtkit Formatted 2 file(s).\n" + + " status done\n" + + "\n!! Running Go formatting failed\n" + + if buf.String() != want { + t.Fatalf("plain rendering mismatch\n--- got ---\n%q\n--- want ---\n%q", buf.String(), want) + } +} + +func TestPrinterColorRendering(t *testing.T) { + var buf strings.Builder + + p := NewPrinter(&buf, ColorAlways) + + p.Section("Formatting complete") + + got := buf.String() + + for _, want := range []string{"\033[36m", "\033[1m", "\033[0m", "Formatting complete"} { + if !strings.Contains(got, want) { + t.Fatalf("color section missing %q:\n%q", want, got) + } + } +} + +func TestPrinterColorAutoRendersPlain(t *testing.T) { + var buf strings.Builder + + NewPrinter(&buf, ColorAuto).Detail("label", "value") + + if strings.Contains(buf.String(), "\033[") { + t.Fatalf("ColorAuto emitted ANSI escapes: %q", buf.String()) + } +} + +func TestStreamIndentsAndFlushesPartialLine(t *testing.T) { + var buf strings.Builder + + p := NewPrinter(&buf, ColorNever) + + stream := p.Stream() + + _, _ = stream.Write([]byte("first line\nsecond ")) + _, _ = stream.Write([]byte("half\ntrailing")) + + if err := stream.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + want := " first line\n" + + " second half\n" + + " trailing\n" + + if buf.String() != want { + t.Fatalf("stream mismatch\n--- got ---\n%q\n--- want ---\n%q", buf.String(), want) + } +} diff --git a/packages/go/driver/internal/gitfiles/gitfiles.go b/packages/go/driver/internal/gitfiles/gitfiles.go new file mode 100644 index 0000000..dfd1cc8 --- /dev/null +++ b/packages/go/driver/internal/gitfiles/gitfiles.go @@ -0,0 +1,248 @@ +// Package gitfiles discovers the files a working tree covers by driving git. +// It knows how to map a Selection onto the git invocations that list it, parse +// their NUL-separated output, and resolve those paths against a tree root. It +// carries no opinion about which files are worth formatting — that taxonomy +// lives in filetypes — nor about Prettier's ignore list. +package gitfiles + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" +) + +// Selection is how much of the working tree a collection covers. +type Selection int + +// Tree is a working-tree root that git commands run against. +type Tree struct { + Dir string +} + +const ( + // SelectionAll covers every non-ignored file: tracked plus untracked. + // This is what `format-all` runs against. + SelectionAll Selection = iota + + // SelectionChanged covers only what has actually diverged from HEAD: + // modified-but-tracked (staged or not) plus untracked. This is what `format` + // runs against, so an everyday format stays proportional to the diff rather + // than the repo. + SelectionChanged +) + +// gitCommands returns the git invocations whose combined output lists the +// files s covers under scope. Every command prints NUL-separated paths +// relative to the directory git runs in. +func (s Selection) gitCommands(scope string) [][]string { + if s == SelectionChanged { + return [][]string{ + // Untracked files, plus tracked ones whose working-tree copy differs + // from the index. + {"ls-files", "--others", "--modified", "--exclude-standard", "-z", "--", scope}, + + // Staged changes are invisible to ls-files' worktree-vs-index view — + // a pre-commit hook would otherwise see nothing to format — so they + // come from an index-vs-HEAD diff. --relative keeps paths cwd-relative + // like ls-files; --diff-filter=d drops staged deletions, which leave + // no file to format. + {"diff", "--cached", "--name-only", "--relative", "--diff-filter=d", "-z", "--", scope}, + } + } + + return [][]string{{"ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", scope}} +} + +// NewTree returns a Tree rooted at dir. An empty dir resolves to the current +// working directory, matching the behaviour of the callers that previously +// defaulted the root themselves. +func NewTree(dir string) (Tree, error) { + if strings.TrimSpace(dir) == "" { + cwd, err := os.Getwd() + + if err != nil { + return Tree{}, err + } + + dir = cwd + } + + return Tree{Dir: dir}, nil +} + +// Files lists the paths git reports for sel under scope, a single path git is +// passed as its pathspec. Entries come back exactly as git prints them — +// NUL-separated, relative to the tree — with no filtering or ordering applied. +func (t Tree) Files(ctx context.Context, scope string, sel Selection) ([]string, error) { + entries := []string{} + + for _, args := range sel.gitCommands(scope) { + found, err := t.runGit(ctx, args) + + if err != nil { + return nil, err + } + + entries = append(entries, found...) + } + + return entries, nil +} + +// ChangedPaths lists every file that diverges from HEAD — modified, staged, or +// added — under the given scopes, whatever its extension, as absolute cleaned +// paths, deduplicated and sorted. Callers do their own filtering — the Go +// formatter, for one, has its own notion of which files it owns. +// +// ChangedPaths deliberately skips .prettierignore filtering: it feeds the Go +// formatter, whose file set has nothing to do with Prettier's JS/TS ignore +// list. +func (t Tree) ChangedPaths(ctx context.Context, scopes []string) ([]string, error) { + return t.collect(ctx, scopes, SelectionChanged) +} + +// IntersectChanged narrows owned — the file list an engine reports it owns — +// down to the ones the working tree has touched under scopes. +// +// It intersects rather than asking git for the owned extensions directly: the +// engine's walk is what applies its own exclusions (vendor, not_path/not_name, +// generated files), and git knows nothing about those. Taking the engine's list +// and keeping only what git reports as changed preserves both. Outside a git +// work tree there is no such thing as "changed", so the error surfaces rather +// than silently formatting everything. +func (t Tree) IntersectChanged(ctx context.Context, scopes, owned []string) ([]string, error) { + touched, err := t.ChangedPaths(ctx, scopes) + + if err != nil { + return nil, err + } + + changed := make(map[string]struct{}, len(touched)) + + for _, path := range touched { + changed[path] = struct{}{} + } + + files := make([]string, 0, len(owned)) + + for _, path := range owned { + if _, ok := changed[path]; ok { + files = append(files, path) + } + } + + return files, nil +} + +// Walk lists the files sel covers under each scope and returns them as absolute +// cleaned paths, deduplicated and sorted. +// +// keep, when non-nil, drops entries it rejects before they are collected. +// Scopes that do not exist are returned in missing rather than failing the +// walk, so a caller can report them or ignore them; any other stat failure does +// fail the walk, with the scopes found missing so far still returned. +func (t Tree) Walk(ctx context.Context, scopes []string, sel Selection, keep func(string) bool) ([]string, []string, error) { + if len(scopes) == 0 { + scopes = []string{"."} + } + + files := []string{} + missing := []string{} + seen := map[string]struct{}{} + + for _, scope := range scopes { + absolute := scope + + if !filepath.IsAbs(absolute) { + absolute = filepath.Join(t.Dir, scope) + } + + if _, err := os.Stat(absolute); err != nil { + if os.IsNotExist(err) { + missing = append(missing, absolute) + + continue + } + + return nil, missing, err + } + + entries, err := t.Files(ctx, absolute, sel) + + if err != nil { + return nil, missing, err + } + + for _, entry := range entries { + if keep != nil && !keep(entry) { + continue + } + + path := entry + + if !filepath.IsAbs(path) { + path = filepath.Join(t.Dir, path) + } + + path = filepath.Clean(path) + + if _, ok := seen[path]; ok { + continue + } + + files = append(files, path) + seen[path] = struct{}{} + } + } + + slices.Sort(files) + + return files, missing, nil +} + +// collect walks scopes for the files sel covers, skipping missing scopes +// silently. +func (t Tree) collect(ctx context.Context, scopes []string, sel Selection) ([]string, error) { + files, _, err := t.Walk(ctx, scopes, sel, nil) + + return files, err +} + +func (t Tree) runGit(ctx context.Context, args []string) ([]string, error) { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = t.Dir + + var stderr bytes.Buffer + + cmd.Stderr = &stderr + + out, err := cmd.Output() + + if err != nil { + reason := strings.TrimSpace(stderr.String()) + + if reason == "" { + return nil, fmt.Errorf("git %s failed: %w", args[0], err) + } + + return nil, fmt.Errorf("git %s failed: %s: %w", args[0], reason, err) + } + + parts := bytes.Split(out, []byte{0}) + entries := make([]string, 0, len(parts)) + + for _, part := range parts { + if len(part) == 0 { + continue + } + + entries = append(entries, string(part)) + } + + return entries, nil +} diff --git a/packages/go/driver/internal/gitfiles/gitfiles_test.go b/packages/go/driver/internal/gitfiles/gitfiles_test.go new file mode 100644 index 0000000..add4459 --- /dev/null +++ b/packages/go/driver/internal/gitfiles/gitfiles_test.go @@ -0,0 +1,221 @@ +package gitfiles + +import ( + "context" + "os" + "path/filepath" + "reflect" + "testing" + + "go.ollin.sh/fmtkit/driver/testutil" +) + +func TestNewTreeDefaultsToWorkingDirectory(t *testing.T) { + tree, err := NewTree("") + + if err != nil { + t.Fatalf("new tree: %v", err) + } + + cwd, err := os.Getwd() + + if err != nil { + t.Fatalf("getwd: %v", err) + } + + if tree.Dir != cwd { + t.Fatalf("empty dir must resolve to cwd\nwant: %q\n got: %q", cwd, tree.Dir) + } +} + +func TestNewTreeKeepsGivenDirectory(t *testing.T) { + dir := t.TempDir() + + tree, err := NewTree(dir) + + if err != nil { + t.Fatalf("new tree: %v", err) + } + + if tree.Dir != dir { + t.Fatalf("dir mismatch\nwant: %q\n got: %q", dir, tree.Dir) + } +} + +func TestFilesListsTrackedAndUntracked(t *testing.T) { + dir := testutil.InitRepo(t) + testutil.WriteFile(t, filepath.Join(dir, "tracked.ts"), "const value = 1;\n") + testutil.GitAdd(t, dir, "tracked.ts") + testutil.WriteFile(t, filepath.Join(dir, "untracked.ts"), "const other = 2;\n") + + tree, err := NewTree(dir) + + if err != nil { + t.Fatalf("new tree: %v", err) + } + + entries, err := tree.Files(context.Background(), dir, SelectionAll) + + if err != nil { + t.Fatalf("files: %v", err) + } + + // git prints paths relative to the tree; order is git's own, so compare sets. + got := map[string]struct{}{} + + for _, entry := range entries { + got[entry] = struct{}{} + } + + want := map[string]struct{}{"tracked.ts": {}, "untracked.ts": {}} + + if !reflect.DeepEqual(got, want) { + t.Fatalf("entries mismatch\nwant: %#v\n got: %#v", want, got) + } +} + +func TestFilesSurfacesGitErrorsOutsideARepo(t *testing.T) { + dir := t.TempDir() + + tree, err := NewTree(dir) + + if err != nil { + t.Fatalf("new tree: %v", err) + } + + if _, err := tree.Files(context.Background(), dir, SelectionAll); err == nil { + t.Fatal("expected an error running git outside a work tree") + } +} + +func TestChangedPathsCoversOnlyTheWorkingTreesChanges(t *testing.T) { + dir := testutil.InitRepo(t) + testutil.WriteFile(t, filepath.Join(dir, "untouched.ts"), "const untouched = 1;\n") + testutil.WriteFile(t, filepath.Join(dir, "modified.ts"), "const modified = 1;\n") + testutil.GitAdd(t, dir, "untouched.ts", "modified.ts") + testutil.GitCommit(t, dir) + + testutil.WriteFile(t, filepath.Join(dir, "modified.ts"), "const modified = 2;\n") + testutil.WriteFile(t, filepath.Join(dir, "added.ts"), "const added = 3;\n") + + tree, err := NewTree(dir) + + if err != nil { + t.Fatalf("new tree: %v", err) + } + + files, err := tree.ChangedPaths(context.Background(), nil) + + if err != nil { + t.Fatalf("changed paths: %v", err) + } + + want := []string{ + filepath.Join(dir, "added.ts"), + filepath.Join(dir, "modified.ts"), + } + + if !reflect.DeepEqual(files, want) { + t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, files) + } +} + +func TestChangedPathsIgnoresPrettierIgnore(t *testing.T) { + dir := testutil.InitRepo(t) + testutil.WriteFile(t, filepath.Join(dir, ".prettierignore"), "main.go\n") + testutil.WriteFile(t, filepath.Join(dir, "main.go"), "package main\n") + testutil.GitAdd(t, dir, ".") + + tree, err := NewTree(dir) + + if err != nil { + t.Fatalf("new tree: %v", err) + } + + files, err := tree.ChangedPaths(context.Background(), nil) + + if err != nil { + t.Fatalf("changed paths: %v", err) + } + + // The Go lane must still see main.go even though .prettierignore lists it: + // gitfiles never consults .prettierignore. + want := []string{ + filepath.Join(dir, ".prettierignore"), + filepath.Join(dir, "main.go"), + } + + if !reflect.DeepEqual(files, want) { + t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, files) + } +} + +func TestChangedPathsSkipsMissingScopes(t *testing.T) { + dir := testutil.InitRepo(t) + testutil.WriteFile(t, filepath.Join(dir, "src", "app.ts"), "const value = 1;\n") + testutil.GitAdd(t, dir, ".") + + tree, err := NewTree(dir) + + if err != nil { + t.Fatalf("new tree: %v", err) + } + + files, err := tree.ChangedPaths(context.Background(), []string{"src", "missing"}) + + if err != nil { + t.Fatalf("changed paths: %v", err) + } + + want := []string{filepath.Join(dir, "src", "app.ts")} + + if !reflect.DeepEqual(files, want) { + t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, files) + } +} + +func TestIntersectChangedKeepsOnlyOwnedAndChanged(t *testing.T) { + dir := testutil.InitRepo(t) + testutil.WriteFile(t, filepath.Join(dir, "changed.go"), "package main\n") + testutil.WriteFile(t, filepath.Join(dir, "untracked.go"), "package main\n") + testutil.GitAdd(t, dir, ".") + + tree, err := NewTree(dir) + + if err != nil { + t.Fatalf("new tree: %v", err) + } + + owned := []string{ + filepath.Join(dir, "changed.go"), + // Owned by the engine but not reported as changed by git. + filepath.Join(dir, "vendored.go"), + } + + files, err := tree.IntersectChanged(context.Background(), nil, owned) + + if err != nil { + t.Fatalf("intersect changed: %v", err) + } + + // Order follows owned, and only the git-changed intersection survives. + want := []string{filepath.Join(dir, "changed.go")} + + if !reflect.DeepEqual(files, want) { + t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, files) + } +} + +func TestIntersectChangedSurfacesGitErrors(t *testing.T) { + dir := t.TempDir() + + tree, err := NewTree(dir) + + if err != nil { + t.Fatalf("new tree: %v", err) + } + + if _, err := tree.IntersectChanged(context.Background(), nil, []string{filepath.Join(dir, "a.go")}); err == nil { + t.Fatal("expected an error running git outside a work tree") + } +} diff --git a/packages/go/driver/internal/golang/execute.go b/packages/go/driver/internal/golang/execute.go new file mode 100644 index 0000000..da16ceb --- /dev/null +++ b/packages/go/driver/internal/golang/execute.go @@ -0,0 +1,118 @@ +package golang + +import ( + "context" + "fmt" + "os" + + driverconfig "go.ollin.sh/fmtkit/driver/config" + "go.ollin.sh/fmtkit/driver/internal/gitfiles" + report "go.ollin.sh/fmtkit/driver/report" + "go.ollin.sh/fmtkit/formatter" + formatterconfig "go.ollin.sh/fmtkit/formatter/config" + formatterengine "go.ollin.sh/fmtkit/formatter/engine" + "go.ollin.sh/fmtkit/vet" +) + +// Request is one Go check/format run: the mode, the paths to cover, the loaded +// config, the working-tree root git and vet run against, and how much of that +// tree the run scopes to. +type Request struct { + Mode report.Mode + Paths []string + Config driverconfig.Config + Root string + Scope gitfiles.Selection +} + +// Outcome is the combined formatter and vet report produced for a mode, ready +// to render or to reduce to an exit code. +type Outcome struct { + Combined report.Combined + Mode report.Mode +} + +// ExitCode reduces the outcome to a process exit code under its mode's policy. +func (o Outcome) ExitCode() int { + return o.Combined.ExitCode(o.Mode) +} + +// Execute runs the Go formatter (scoped as the request asks) and go vet, and +// returns the combined outcome. It is the reusable core the standalone runner +// and the umbrella pipeline both drive. +func Execute(ctx context.Context, req Request) (Outcome, error) { + formatterReport, err := runFormatter(ctx, req, req.Config.Formatter()) + + if err != nil { + return Outcome{}, err + } + + combined := report.Combined{ + Formatter: formatterReport, + Vet: vet.Run(ctx, req.Root, req.Config.VetConfig()), + } + + return Outcome{Combined: combined, Mode: req.Mode}, nil +} + +func runFormatter(ctx context.Context, req Request, cfg formatterconfig.Config) (formatterengine.Report, error) { + if req.Scope == gitfiles.SelectionChanged { + files, err := changedGoFiles(ctx, req.Root, req.Paths, cfg) + + if err != nil { + return formatterengine.Report{}, err + } + + switch req.Mode { + case report.ModeCheck: + return formatter.CheckFiles(ctx, files, cfg) + case report.ModeFormat: + return formatter.FormatFiles(ctx, files, cfg) + default: + return formatterengine.Report{}, fmt.Errorf("unsupported mode %q", req.Mode) + } + } + + switch req.Mode { + case report.ModeCheck: + return formatter.Check(ctx, req.Paths, cfg) + case report.ModeFormat: + return formatter.Format(ctx, req.Paths, cfg) + default: + return formatterengine.Report{}, fmt.Errorf("unsupported mode %q", req.Mode) + } +} + +// changedGoFiles narrows the files the formatter owns down to the ones the +// working tree has touched. The engine reports what it owns; gitfiles keeps only +// the subset git reports as changed (see Tree.IntersectChanged for why this is +// an intersection rather than a direct `git ls-files *.go`). +func changedGoFiles(ctx context.Context, root string, paths []string, cfg formatterconfig.Config) ([]string, error) { + owned, err := formatterengine.CollectGoFiles(paths, cfg) + + if err != nil { + return nil, err + } + + if len(owned) == 0 { + return nil, nil + } + + if root == "" { + cwd, err := os.Getwd() + + if err != nil { + return nil, fmt.Errorf("resolve cwd: %w", err) + } + + root = cwd + } + + tree, err := gitfiles.NewTree(root) + + if err != nil { + return nil, err + } + + return tree.IntersectChanged(ctx, paths, owned) +} diff --git a/packages/go/driver/internal/golang/execute_test.go b/packages/go/driver/internal/golang/execute_test.go new file mode 100644 index 0000000..8c0c9ac --- /dev/null +++ b/packages/go/driver/internal/golang/execute_test.go @@ -0,0 +1,106 @@ +package golang + +import ( + "context" + "os" + "path/filepath" + "testing" + + driverconfig "go.ollin.sh/fmtkit/driver/config" + "go.ollin.sh/fmtkit/driver/internal/gitfiles" + report "go.ollin.sh/fmtkit/driver/report" + formatterengine "go.ollin.sh/fmtkit/formatter/engine" +) + +func TestExecuteCheckReportsViolationWithoutRewriting(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "sample.go") + + if err := os.WriteFile(file, []byte(spacingViolationSource), 0o644); err != nil { + t.Fatalf("write sample: %v", err) + } + + outcome, err := Execute(context.Background(), Request{ + Mode: report.ModeCheck, + Paths: []string{file}, + Config: driverconfig.Default(), + Root: dir, + }) + + if err != nil { + t.Fatalf("execute: %v", err) + } + + if outcome.Mode != report.ModeCheck { + t.Fatalf("outcome mode = %q", outcome.Mode) + } + + if outcome.Combined.Formatter.Result == formatterengine.ResultPass { + t.Fatalf("expected a non-pass result for the violation") + } + + if outcome.ExitCode() != 1 { + t.Fatalf("check exit = %d, want 1", outcome.ExitCode()) + } + + if got, _ := os.ReadFile(file); string(got) != spacingViolationSource { + t.Fatal("check mode must not rewrite the file") + } +} + +func TestExecuteFormatRewritesFile(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "sample.go") + + if err := os.WriteFile(file, []byte(spacingViolationSource), 0o644); err != nil { + t.Fatalf("write sample: %v", err) + } + + outcome, err := Execute(context.Background(), Request{ + Mode: report.ModeFormat, + Paths: []string{file}, + Config: driverconfig.Default(), + Root: dir, + }) + + if err != nil { + t.Fatalf("execute: %v", err) + } + + if outcome.ExitCode() != 0 { + t.Fatalf("format exit = %d, want 0", outcome.ExitCode()) + } + + got, err := os.ReadFile(file) + + if err != nil { + t.Fatalf("read sample: %v", err) + } + + if string(got) == spacingViolationSource { + t.Fatal("format mode should rewrite the file") + } +} + +func TestExecuteChangedScopeOutsideGitTreeErrors(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "sample.go") + + if err := os.WriteFile(file, []byte(cleanSource), 0o644); err != nil { + t.Fatalf("write sample: %v", err) + } + + // A changed scope needs a git tree; a bare temp dir has none, so Execute must + // surface the error rather than silently formatting everything. + _, err := Execute(context.Background(), Request{ + Mode: report.ModeFormat, + Paths: []string{file}, + Config: driverconfig.Default(), + Root: dir, + Scope: gitfiles.SelectionChanged, + }) + + if err == nil { + t.Fatal("expected an error scoping to changes outside a git tree") + } +} diff --git a/packages/go/driver/internal/golang/parser.go b/packages/go/driver/internal/golang/parser.go new file mode 100644 index 0000000..bd789f0 --- /dev/null +++ b/packages/go/driver/internal/golang/parser.go @@ -0,0 +1,87 @@ +package golang + +import ( + "flag" + "fmt" + "io" + "os" + "strconv" + "strings" + + report "go.ollin.sh/fmtkit/driver/report" +) + +// Invocation is the parsed form of a `check`/`format` command line: the flags +// (--config --cwd --format --jobs, plus FMTKIT_JOBS) resolved to typed values +// and the positional paths. +type Invocation struct { + Mode report.Mode + ConfigPath string + ReportRoot string + Output report.Format + Paths []string + + // Jobs overrides config.Concurrency when not -1. -1 means "unset" (no + // override); 0 means "use NumCPU"; positive values pin the worker count. + Jobs int +} + +// ParseInvocation parses the shared check/format flag set for mode. Flag errors +// (already reported to stderr by the flag package) and an unknown --format +// value both surface as an error so the caller can exit non-zero. +func ParseInvocation(mode report.Mode, args []string, stderr io.Writer) (Invocation, error) { + fs := flag.NewFlagSet(string(mode), flag.ContinueOnError) + fs.SetOutput(stderr) + + configPath := fs.String("config", "", "Path to fmtkit YAML config") + reportRoot := fs.String("cwd", "", "Path used for config discovery and report-relative file paths") + outputFormat := fs.String("format", "text", "Output format: text, json, agent") + jobs := fs.Int("jobs", envJobs(), "Max files processed in parallel (0 = NumCPU; also reads FMTKIT_JOBS)") + + if err := fs.Parse(args); err != nil { + return Invocation{}, err + } + + format, err := report.ParseFormat(*outputFormat) + + if err != nil { + _, _ = fmt.Fprintf(stderr, "%v\n", err) + + return Invocation{}, err + } + + return Invocation{ + Mode: mode, + ConfigPath: *configPath, + ReportRoot: *reportRoot, + Output: format, + Paths: fs.Args(), + Jobs: *jobs, + }, nil +} + +// envJobs reads FMTKIT_JOBS as the default for the --jobs flag. +// Returns -1 when the env var is unset so the runner can distinguish +// "unset" from an explicit 0 (which means "use NumCPU"). +// Invalid values fall back to -1 as well. +func envJobs() int { + val, ok := os.LookupEnv("FMTKIT_JOBS") + + if !ok { + return -1 + } + + raw := strings.TrimSpace(val) + + if raw == "" { + return -1 + } + + n, err := strconv.Atoi(raw) + + if err != nil || n < 0 { + return -1 + } + + return n +} diff --git a/packages/go/driver/internal/cli/parser_test.go b/packages/go/driver/internal/golang/parser_test.go similarity index 54% rename from packages/go/driver/internal/cli/parser_test.go rename to packages/go/driver/internal/golang/parser_test.go index 1af3eea..3d0463e 100644 --- a/packages/go/driver/internal/cli/parser_test.go +++ b/packages/go/driver/internal/golang/parser_test.go @@ -1,33 +1,35 @@ -package cli +package golang import ( "io" "os" "reflect" "testing" + + report "go.ollin.sh/fmtkit/driver/report" ) -func TestParseDefaults(t *testing.T) { +func TestParseInvocationDefaults(t *testing.T) { unsetJobsEnv(t) - opts, err := newParser(io.Discard).Parse(CheckMode, nil) + inv, err := ParseInvocation(report.ModeCheck, nil, io.Discard) if err != nil { t.Fatalf("parse: %v", err) } - want := options{ - mode: CheckMode, - outputFormat: "text", - jobs: -1, + want := Invocation{ + Mode: report.ModeCheck, + Output: report.FormatText, + Jobs: -1, } - if !reflect.DeepEqual(opts, want) { - t.Fatalf("unexpected defaults: %#v", opts) + if !reflect.DeepEqual(inv, want) { + t.Fatalf("unexpected defaults: %#v", inv) } } -func TestParseAllFlags(t *testing.T) { +func TestParseInvocationAllFlags(t *testing.T) { unsetJobsEnv(t) args := []string{ @@ -38,38 +40,44 @@ func TestParseAllFlags(t *testing.T) { "main.go", "pkg", } - opts, err := newParser(io.Discard).Parse(FormatMode, args) + inv, err := ParseInvocation(report.ModeFormat, args, io.Discard) if err != nil { t.Fatalf("parse: %v", err) } - want := options{ - mode: FormatMode, - configPath: "custom.yml", - reportRoot: "/repo", - outputFormat: "json", - positional: []string{"main.go", "pkg"}, - jobs: 4, + want := Invocation{ + Mode: report.ModeFormat, + ConfigPath: "custom.yml", + ReportRoot: "/repo", + Output: report.FormatJSON, + Paths: []string{"main.go", "pkg"}, + Jobs: 4, } - if !reflect.DeepEqual(opts, want) { - t.Fatalf("unexpected options: %#v", opts) + if !reflect.DeepEqual(inv, want) { + t.Fatalf("unexpected invocation: %#v", inv) } } -func TestParseRejectsUnknownFlag(t *testing.T) { - if _, err := newParser(io.Discard).Parse(CheckMode, []string{"--bogus"}); err == nil { +func TestParseInvocationRejectsUnknownFlag(t *testing.T) { + if _, err := ParseInvocation(report.ModeCheck, []string{"--bogus"}, io.Discard); err == nil { t.Fatal("expected unknown flag error") } } -func TestParseRejectsNonNumericJobs(t *testing.T) { - if _, err := newParser(io.Discard).Parse(CheckMode, []string{"--jobs", "abc"}); err == nil { +func TestParseInvocationRejectsNonNumericJobs(t *testing.T) { + if _, err := ParseInvocation(report.ModeCheck, []string{"--jobs", "abc"}, io.Discard); err == nil { t.Fatal("expected invalid --jobs error") } } +func TestParseInvocationRejectsUnknownFormat(t *testing.T) { + if _, err := ParseInvocation(report.ModeCheck, []string{"--format", "yaml"}, io.Discard); err == nil { + t.Fatal("expected unsupported format error") + } +} + func TestEnvJobs(t *testing.T) { cases := []struct { name string diff --git a/packages/go/driver/internal/golang/runner.go b/packages/go/driver/internal/golang/runner.go new file mode 100644 index 0000000..3aee0c4 --- /dev/null +++ b/packages/go/driver/internal/golang/runner.go @@ -0,0 +1,96 @@ +package golang + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + driverconfig "go.ollin.sh/fmtkit/driver/config" + "go.ollin.sh/fmtkit/driver/internal/gitfiles" + report "go.ollin.sh/fmtkit/driver/report" +) + +// Runner is the thin orchestration around Execute: it parses the command line, +// loads config, runs the core, renders the report, and returns the exit code. +// +// Scope is how much of the working tree the formatter covers. The zero value +// (SelectionAll) covers everything, which is what `fmtkit go` and `fmtkit +// check` want; a scoped runner narrows to the working tree's changes. +type Runner struct { + Stdout io.Writer + Stderr io.Writer + Scope gitfiles.Selection +} + +// Run parses args for mode, executes the Go formatter and vet, renders the +// report, and returns the process exit code. +func (r Runner) Run(ctx context.Context, mode report.Mode, args []string) int { + _, code := r.RunReport(ctx, mode, args) + + return code +} + +// RunReport is Run that also returns the typed outcome so pipeline callers can +// derive their summary details from it rather than scraping the rendered text. +// On a setup failure it returns the zero Outcome and a non-zero code after +// reporting the problem to Stderr, so the outcome is only meaningful when the +// returned code is zero. +func (r Runner) RunReport(ctx context.Context, mode report.Mode, args []string) (Outcome, int) { + inv, err := ParseInvocation(mode, args, r.Stderr) + + if err != nil { + return Outcome{}, 1 + } + + workRoot, err := os.Getwd() + + if err != nil { + r.errf("resolve cwd: %v\n", err) + + return Outcome{}, 1 + } + + reportRoot := workRoot + + if strings.TrimSpace(inv.ReportRoot) != "" { + reportRoot = inv.ReportRoot + } + + cfg, err := driverconfig.Load(reportRoot, inv.ConfigPath) + + if err != nil { + r.errf("%v\n", err) + + return Outcome{}, 1 + } + + outcome, err := Execute(ctx, Request{ + Mode: mode, + Paths: inv.Paths, + Config: cfg.WithJobs(inv.Jobs), + Root: workRoot, + Scope: r.Scope, + }) + + if err != nil { + r.errf("%v\n", err) + + return Outcome{}, 1 + } + + renderer := report.Renderer{Root: reportRoot, Mode: mode} + + if err := renderer.Render(r.Stdout, inv.Output, outcome.Combined); err != nil { + r.errf("render report: %v\n", err) + + return Outcome{}, 1 + } + + return outcome, outcome.ExitCode() +} + +func (r Runner) errf(format string, args ...any) { + _, _ = fmt.Fprintf(r.Stderr, format, args...) +} diff --git a/packages/go/driver/internal/cli/runner_test.go b/packages/go/driver/internal/golang/runner_test.go similarity index 73% rename from packages/go/driver/internal/cli/runner_test.go rename to packages/go/driver/internal/golang/runner_test.go index b756481..dd9f82d 100644 --- a/packages/go/driver/internal/cli/runner_test.go +++ b/packages/go/driver/internal/golang/runner_test.go @@ -1,4 +1,4 @@ -package cli +package golang import ( "bytes" @@ -9,63 +9,10 @@ import ( "strings" "testing" - "go.ollin.sh/fmtkit/driver/internal/sourcefiles" - driverreport "go.ollin.sh/fmtkit/driver/report" - formatterengine "go.ollin.sh/fmtkit/formatter/engine" - "go.ollin.sh/fmtkit/vet" + "go.ollin.sh/fmtkit/driver/internal/gitfiles" + report "go.ollin.sh/fmtkit/driver/report" ) -func TestExitCode(t *testing.T) { - cases := []struct { - name string - mode Mode - result driverreport.Combined - want int - }{ - { - name: "vet errors fail either mode", - mode: FormatMode, - result: driverreport.Combined{Vet: vet.Report{Errors: []vet.ErrorResult{{Message: "boom"}}}}, - want: 1, - }, - { - name: "check passes on pass result", - mode: CheckMode, - result: driverreport.Combined{Formatter: formatterengine.Report{Result: "pass"}}, - want: 0, - }, - { - name: "check fails on non-pass result", - mode: CheckMode, - result: driverreport.Combined{Formatter: formatterengine.Report{Result: "fail"}}, - want: 1, - }, - { - name: "format fails on formatter errors", - mode: FormatMode, - result: driverreport.Combined{Formatter: formatterengine.Report{ - Result: "fail", - Errors: []formatterengine.ErrorResult{{Message: "walk failed"}}, - }}, - want: 1, - }, - { - name: "format succeeds after applying fixes", - mode: FormatMode, - result: driverreport.Combined{Formatter: formatterengine.Report{Result: "fixed"}}, - want: 0, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := exitCode(tc.mode, tc.result); got != tc.want { - t.Fatalf("exitCode(%s) = %d, want %d", tc.mode, got, tc.want) - } - }) - } -} - const cleanSource = `package sample func run() { @@ -85,7 +32,7 @@ func run() { // runInTempModulelessDir writes source to a Go file in a fresh temp dir, // chdirs there (so vet finds no module and skips), and runs the CLI. -func runInTempModulelessDir(t *testing.T, source string, mode Mode, extraArgs ...string) (code int, stdout, stderr string, file string) { +func runInTempModulelessDir(t *testing.T, source string, mode report.Mode, extraArgs ...string) (code int, stdout, stderr string, file string) { t.Helper() dir := t.TempDir() @@ -99,13 +46,13 @@ func runInTempModulelessDir(t *testing.T, source string, mode Mode, extraArgs .. var out, errOut bytes.Buffer - code = NewRunner(&out, &errOut).Run(context.Background(), mode, append(extraArgs, file)) + code = Runner{Stdout: &out, Stderr: &errOut}.Run(context.Background(), mode, append(extraArgs, file)) return code, out.String(), errOut.String(), file } func TestRunnerRunCleanFileJSON(t *testing.T) { - code, stdout, stderr, _ := runInTempModulelessDir(t, cleanSource, CheckMode, "--format", "json") + code, stdout, stderr, _ := runInTempModulelessDir(t, cleanSource, report.ModeCheck, "--format", "json") if code != 0 { t.Fatalf("exit = %d, stderr: %s", code, stderr) @@ -121,7 +68,7 @@ func TestRunnerRunCleanFileJSON(t *testing.T) { } func TestRunnerRunCheckModeReportsViolation(t *testing.T) { - code, stdout, _, file := runInTempModulelessDir(t, spacingViolationSource, CheckMode) + code, stdout, _, file := runInTempModulelessDir(t, spacingViolationSource, report.ModeCheck) if code != 1 { t.Fatalf("exit = %d, stdout: %s", code, stdout) @@ -139,7 +86,7 @@ func TestRunnerRunCheckModeReportsViolation(t *testing.T) { } func TestRunnerRunFormatModeRewritesFile(t *testing.T) { - code, stdout, stderr, file := runInTempModulelessDir(t, spacingViolationSource, FormatMode) + code, stdout, stderr, file := runInTempModulelessDir(t, spacingViolationSource, report.ModeFormat) if code != 0 { t.Fatalf("exit = %d, stdout: %s, stderr: %s", code, stdout, stderr) @@ -161,7 +108,7 @@ func TestRunnerRunFormatModeRewritesFile(t *testing.T) { } func TestRunnerRunRejectsUnsupportedFormat(t *testing.T) { - code, _, stderr, _ := runInTempModulelessDir(t, cleanSource, CheckMode, "--format", "yaml") + code, _, stderr, _ := runInTempModulelessDir(t, cleanSource, report.ModeCheck, "--format", "yaml") if code != 1 { t.Fatalf("exit = %d", code) @@ -179,11 +126,62 @@ func TestRunnerRunRejectsUnknownFlag(t *testing.T) { var out, errOut bytes.Buffer - if code := NewRunner(&out, &errOut).Run(context.Background(), CheckMode, []string{"--bogus"}); code != 1 { + if code := (Runner{Stdout: &out, Stderr: &errOut}).Run(context.Background(), report.ModeCheck, []string{"--bogus"}); code != 1 { t.Fatalf("exit = %d", code) } } +func TestRunnerReportsConfigLoadError(t *testing.T) { + dir := t.TempDir() + + if err := os.WriteFile(filepath.Join(dir, "sample.go"), []byte(cleanSource), 0o644); err != nil { + t.Fatalf("write sample: %v", err) + } + + t.Chdir(dir) + + var out, errOut bytes.Buffer + + // An explicit --config path that does not exist makes config.Load fail, so + // the runner reports it on stderr and exits 1 before running the formatter. + code := Runner{Stdout: &out, Stderr: &errOut}.Run(context.Background(), report.ModeCheck, + []string{"--config", filepath.Join(dir, "missing.yml"), "sample.go"}) + + if code != 1 { + t.Fatalf("exit = %d, want 1", code) + } + + if !strings.Contains(errOut.String(), "load config") { + t.Fatalf("expected a config-load error on stderr, got: %q", errOut.String()) + } +} + +func TestRunnerHonorsReportRootFlag(t *testing.T) { + work := t.TempDir() + reportRoot := t.TempDir() + + if err := os.WriteFile(filepath.Join(work, "sample.go"), []byte(cleanSource), 0o644); err != nil { + t.Fatalf("write sample: %v", err) + } + + t.Chdir(work) + + var out, errOut bytes.Buffer + + // --cwd points config discovery and report-relative paths at reportRoot while + // the process stays in work; a clean file still passes. + code := Runner{Stdout: &out, Stderr: &errOut}.Run(context.Background(), report.ModeCheck, + []string{"--cwd", reportRoot, "--format", "json", "sample.go"}) + + if code != 0 { + t.Fatalf("exit = %d, stderr: %s", code, errOut.String()) + } + + if !strings.Contains(out.String(), `"result":"pass"`) { + t.Fatalf("unexpected output: %s", out.String()) + } +} + // generatedViolationSource carries the same spacing violation as // spacingViolationSource, but is marked generated so the engine must never // rewrite it. @@ -281,7 +279,7 @@ func TestScopedRunnerFormatsOnlyTheWorkingTreesChanges(t *testing.T) { var out, errOut bytes.Buffer - code := NewScopedRunner(&out, &errOut, sourcefiles.SelectionChanged).Run(context.Background(), FormatMode, nil) + code := Runner{Stdout: &out, Stderr: &errOut, Scope: gitfiles.SelectionChanged}.Run(context.Background(), report.ModeFormat, nil) if code != 0 { t.Fatalf("exit code = %d, want 0\n%s\n%s", code, out.String(), errOut.String()) @@ -305,7 +303,7 @@ func TestUnscopedRunnerFormatsEveryOwnedFile(t *testing.T) { var out, errOut bytes.Buffer - code := NewRunner(&out, &errOut).Run(context.Background(), FormatMode, nil) + code := Runner{Stdout: &out, Stderr: &errOut}.Run(context.Background(), report.ModeFormat, nil) if code != 0 { t.Fatalf("exit code = %d, want 0\n%s\n%s", code, out.String(), errOut.String()) @@ -342,7 +340,7 @@ func TestScopedRunnerOnACleanTreeFormatsNothing(t *testing.T) { var out, errOut bytes.Buffer - code := NewScopedRunner(&out, &errOut, sourcefiles.SelectionChanged).Run(context.Background(), FormatMode, nil) + code := Runner{Stdout: &out, Stderr: &errOut, Scope: gitfiles.SelectionChanged}.Run(context.Background(), report.ModeFormat, nil) if code != 0 { t.Fatalf("exit code = %d, want 0\n%s\n%s", code, out.String(), errOut.String()) diff --git a/packages/go/driver/internal/golang/step.go b/packages/go/driver/internal/golang/step.go new file mode 100644 index 0000000..94fac2e --- /dev/null +++ b/packages/go/driver/internal/golang/step.go @@ -0,0 +1,113 @@ +package golang + +import ( + "context" + "fmt" + "io" + + "go.ollin.sh/fmtkit/driver/internal/gitfiles" + "go.ollin.sh/fmtkit/driver/internal/pipeline" + "go.ollin.sh/fmtkit/driver/internal/toolchain" + report "go.ollin.sh/fmtkit/driver/report" + formatterengine "go.ollin.sh/fmtkit/formatter/engine" +) + +// Toolchain is the Go lane: it formats Go files and runs go vet, contributing a +// single format step to the pipeline. +type Toolchain struct{} + +type formatStep struct { + paths []string + selection gitfiles.Selection +} + +// New builds the Go toolchain. +func New() Toolchain { return Toolchain{} } + +// Name is the lane's selector, matching the --go flag. +func (Toolchain) Name() string { return "go" } + +// Steps returns the Go lane's ordered steps: just the format step. +func (Toolchain) Steps(req toolchain.Request) []pipeline.Step { + return []pipeline.Step{FormatStep(req.Paths, req.Selection)} +} + +// FormatStep builds the pipeline step that formats Go files and runs go vet, +// deriving its details from the typed outcome rather than the rendered report +// text. +func FormatStep(paths []string, selection gitfiles.Selection) pipeline.Step { + return formatStep{paths: paths, selection: selection} +} + +func (s formatStep) Label() string { return "Running Go formatting" } + +func (s formatStep) Run(ctx context.Context, output io.Writer) pipeline.Result { + outcome, code := Runner{Stdout: output, Stderr: output, Scope: s.selection}. + RunReport(ctx, report.ModeFormat, s.paths) + + if code != 0 { + return pipeline.Result{ExitCode: code} + } + + return pipeline.Result{Details: formatDetails(outcome)} +} + +// formatDetails computes the Go step's detail lines from the typed outcome, +// reproducing the exact strings the text report renders (which the pipeline +// previously scraped back out of that rendered text). +func formatDetails(outcome Outcome) []pipeline.Detail { + fm := outcome.Combined.Formatter + vt := outcome.Combined.Vet + + var details []pipeline.Detail + + if summary := fileSummary(fm, outcome.Mode); summary != "" { + details = append(details, pipeline.Detail{Label: "fmtkit", Value: summary}) + } + + // The formatter renders a Result line unless it found no files and hit no + // errors; the vet Result line always renders. The "result" detail is the + // first Result line (the formatter's when present, else the vet's), matching + // the text report's top-to-bottom order. + formatterResult := "" + + if fm.Files != 0 || len(fm.Errors) != 0 { + formatterResult = fmt.Sprintf("%s. %d changed, %d violation(s), %d error(s).", fm.Result, fm.Changed, fm.ViolationCount(), fm.ErrorCount()) + } + + vetResult := fmt.Sprintf("%s. %d error(s).", report.VetStatus(vt), vt.ErrorCount()) + + resultLine := formatterResult + + if resultLine == "" { + resultLine = vetResult + } + + details = append(details, pipeline.Detail{Label: "result", Value: resultLine}) + + if summary := report.VetSummary(vt); summary != "" { + details = append(details, pipeline.Detail{Label: "vet", Value: summary}) + } + + if vetResult != resultLine { + details = append(details, pipeline.Detail{Label: "vet result", Value: vetResult}) + } + + return details +} + +// fileSummary is the formatter's file-count line: "No Go files found." when it +// owns none, otherwise the mode's verb and count. +func fileSummary(fm formatterengine.Report, mode report.Mode) string { + if fm.Files == 0 { + return "No Go files found." + } + + action := "Checked" + + if mode == report.ModeFormat { + action = "Formatted" + } + + return fmt.Sprintf("%s %d file(s).", action, fm.Files) +} diff --git a/packages/go/driver/internal/golang/step_test.go b/packages/go/driver/internal/golang/step_test.go new file mode 100644 index 0000000..b404714 --- /dev/null +++ b/packages/go/driver/internal/golang/step_test.go @@ -0,0 +1,146 @@ +package golang + +import ( + "fmt" + "testing" + + "go.ollin.sh/fmtkit/driver/internal/pipeline" + "go.ollin.sh/fmtkit/driver/internal/toolchain" + report "go.ollin.sh/fmtkit/driver/report" + formatterengine "go.ollin.sh/fmtkit/formatter/engine" + "go.ollin.sh/fmtkit/vet" +) + +func detailStrings(details []pipeline.Detail) []string { + out := make([]string, 0, len(details)) + + for _, d := range details { + out = append(out, d.Label+"|"+d.Value) + } + + return out +} + +func assertDetails(t *testing.T, got []pipeline.Detail, want ...string) { + t.Helper() + + if g := fmt.Sprint(detailStrings(got)); g != fmt.Sprint(want) { + t.Fatalf("details mismatch\n--- got ---\n%s\n--- want ---\n%s", g, fmt.Sprint(want)) + } +} + +// outcomeFor builds a Go outcome for the given mode, formatter report, and vet +// report. +func outcomeFor(mode report.Mode, fm formatterengine.Report, vt vet.Report) Outcome { + return Outcome{Mode: mode, Combined: report.Combined{Formatter: fm, Vet: vt}} +} + +func TestFormatDetailsPass(t *testing.T) { + outcome := outcomeFor( + report.ModeFormat, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 2}, + vet.Report{Root: "/work"}, + ) + + assertDetails(t, formatDetails(outcome), + "fmtkit|Formatted 2 file(s).", + "result|pass. 0 changed, 0 violation(s), 0 error(s).", + "vet|go vet ./... passed.", + "vet result|pass. 0 error(s).", + ) +} + +func TestFormatDetailsCheckModeVerb(t *testing.T) { + outcome := outcomeFor( + report.ModeCheck, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 3}, + vet.Report{Root: "/work"}, + ) + + assertDetails(t, formatDetails(outcome), + "fmtkit|Checked 3 file(s).", + "result|pass. 0 changed, 0 violation(s), 0 error(s).", + "vet|go vet ./... passed.", + "vet result|pass. 0 error(s).", + ) +} + +// TestFormatDetailsNoFiles reproduces the scraper's quirk: with no formatter +// Result line rendered, the "result" detail borrows the vet Result line and the +// separate "vet result" line is suppressed (they are identical). +func TestFormatDetailsNoFiles(t *testing.T) { + outcome := outcomeFor( + report.ModeFormat, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 0}, + vet.Report{Root: "/work"}, + ) + + assertDetails(t, formatDetails(outcome), + "fmtkit|No Go files found.", + "result|pass. 0 error(s).", + "vet|go vet ./... passed.", + ) +} + +func TestFormatDetailsVetSkippedNoModule(t *testing.T) { + outcome := outcomeFor( + report.ModeFormat, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 1}, + vet.Report{Root: ""}, + ) + + assertDetails(t, formatDetails(outcome), + "fmtkit|Formatted 1 file(s).", + "result|pass. 0 changed, 0 violation(s), 0 error(s).", + "vet|Skipped automatic go vet ./... because no Go module or workspace was detected.", + "vet result|skipped. 0 error(s).", + ) +} + +func TestFormatDetailsVetSkippedToolchain(t *testing.T) { + outcome := outcomeFor( + report.ModeFormat, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 1}, + vet.Report{Root: "/work", Skipped: true}, + ) + + assertDetails(t, formatDetails(outcome), + "fmtkit|Formatted 1 file(s).", + "result|pass. 0 changed, 0 violation(s), 0 error(s).", + "vet|Skipped automatic go vet ./... because the Go toolchain is not available.", + "vet result|skipped. 0 error(s).", + ) +} + +// TestFormatDetailsVetFailure: a vet failure renders per-error lines instead of +// a status summary, so there is no "vet" detail, but the differing vet Result +// line still appears. +func TestFormatDetailsVetFailure(t *testing.T) { + outcome := outcomeFor( + report.ModeFormat, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 2}, + vet.Report{Root: "/work", Errors: []vet.ErrorResult{{File: "a.go", Message: "boom"}}}, + ) + + assertDetails(t, formatDetails(outcome), + "fmtkit|Formatted 2 file(s).", + "result|pass. 0 changed, 0 violation(s), 0 error(s).", + "vet result|fail. 1 error(s).", + ) +} + +func TestSteps(t *testing.T) { + steps := New().Steps(toolchain.Request{Paths: []string{"."}}) + + if len(steps) != 1 { + t.Fatalf("Steps len = %d, want 1", len(steps)) + } + + if got := steps[0].Label(); got != "Running Go formatting" { + t.Fatalf("step label = %q, want %q", got, "Running Go formatting") + } + + if got := New().Name(); got != "go" { + t.Fatalf("Name = %q, want go", got) + } +} diff --git a/packages/go/driver/internal/orchestrator/logging.go b/packages/go/driver/internal/orchestrator/logging.go deleted file mode 100644 index c93f71c..0000000 --- a/packages/go/driver/internal/orchestrator/logging.go +++ /dev/null @@ -1,114 +0,0 @@ -// Package orchestrator drives the full fmtkit formatting pipeline (TS/Vue -// formatting, TS/Vue lint, Go formatting) with sectioned, colorized progress -// output: each step's tool output streams live, indented under its section -// header, and is followed by the condensed summary lines. -package orchestrator - -import ( - "fmt" - "io" - "os" - "strings" - - "github.com/mattn/go-isatty" -) - -type logger struct { - w io.Writer - quiet bool - - bold string - dim string - cyan string - green string - red string - reset string -} - -// stream returns a writer that renders tool output live, dimmed and indented -// under the current section. Callers must Close it to flush a trailing -// partial line. - -type indentWriter struct { - logger *logger - partial strings.Builder -} - -func newLogger(w io.Writer, quiet bool) *logger { - l := &logger{w: w, quiet: quiet} - - if colorEnabled(w) { - l.bold = "\033[1m" - l.dim = "\033[2m" - l.cyan = "\033[36m" - l.green = "\033[32m" - l.red = "\033[31m" - l.reset = "\033[0m" - } - - return l -} - -func colorEnabled(w io.Writer) bool { - if os.Getenv("FORCE_COLOR") != "" { - return true - } - - if os.Getenv("NO_COLOR") != "" { - return false - } - - file, ok := w.(*os.File) - - return ok && isatty.IsTerminal(file.Fd()) -} - -func (l *logger) section(msg string) { - _, _ = fmt.Fprintf(l.w, "\n%s==>%s %s%s%s\n", l.cyan, l.reset, l.bold, msg, l.reset) -} - -func (l *logger) detail(label, value string) { - _, _ = fmt.Fprintf(l.w, " %s%-12s%s %s\n", l.dim, label, l.reset, value) -} - -func (l *logger) successDetail(label, value string) { - _, _ = fmt.Fprintf(l.w, " %s%-12s%s %s%s%s\n", l.green, label, l.reset, l.green, value, l.reset) -} - -func (l *logger) failure(msg string) { - _, _ = fmt.Fprintf(l.w, "\n%s!!%s %s%s%s\n", l.red, l.reset, l.bold, msg, l.reset) -} - -func (l *logger) stream() io.WriteCloser { - return &indentWriter{logger: l} -} - -func (w *indentWriter) Write(p []byte) (int, error) { - for _, b := range p { - if b != '\n' { - w.partial.WriteByte(b) - - continue - } - - w.flushLine() - } - - return len(p), nil -} - -func (w *indentWriter) Close() error { - if w.partial.Len() > 0 { - w.flushLine() - } - - return nil -} - -func (w *indentWriter) flushLine() { - l := w.logger - - _, _ = fmt.Fprintf(l.w, " %s%s%s\n", l.dim, w.partial.String(), l.reset) - - w.partial.Reset() -} diff --git a/packages/go/driver/internal/orchestrator/pipeline.go b/packages/go/driver/internal/orchestrator/pipeline.go deleted file mode 100644 index 0aedfe3..0000000 --- a/packages/go/driver/internal/orchestrator/pipeline.go +++ /dev/null @@ -1,166 +0,0 @@ -package orchestrator - -import ( - "bytes" - "context" - "errors" - "io" - "os/exec" - "strings" -) - -// Tools carries the three pipeline steps. The TS steps return an error whose -// exec.ExitError code propagates; the Go step reports its exit code directly. -type Tools struct { - TS func(ctx context.Context, scopes []string, output io.Writer) error - Lint func(ctx context.Context, scopes []string, output io.Writer) error - Go func(ctx context.Context, args []string, output io.Writer) int -} - -// Steps selects which parts of the pipeline run; the zero value (no -// selection flags) runs everything. -type Steps struct { - TS bool - Go bool -} - -// Pipeline renders sectioned progress on Stderr while running the steps. -type Pipeline struct { - Tools Tools - Steps Steps - - // Quiet restores the entrypoint's summary-only output; tool logs then - // only appear when a step fails. - Quiet bool - - Stderr io.Writer -} - -func (s Steps) normalized() Steps { - if !s.TS && !s.Go { - return Steps{TS: true, Go: true} - } - - return s -} - -// RunFormat runs TS/Vue lint (applying oxlint's safe fixes), TS/Vue formatting, -// and Go formatting against the given paths. Lint runs first so the formatting -// passes normalize whatever oxlint rewrites. -func (p Pipeline) RunFormat(ctx context.Context, paths []string) int { - if len(paths) == 0 { - paths = []string{"."} - } - - log := newLogger(p.Stderr, p.Quiet) - - log.section("Formatting target(s)") - log.detail("paths", strings.Join(paths, " ")) - - selected := p.Steps.normalized() - - type step struct { - label string - summarize func(string, *logger) - run func(ctx context.Context, output io.Writer) int - } - - var steps []step - - if selected.TS { - steps = append(steps, - step{ - label: "Running TS/Vue lint", - summarize: summarizeTSLint, - run: func(ctx context.Context, output io.Writer) int { - return exitCode(p.Tools.Lint(ctx, paths, output), output) - }, - }, - step{ - label: "Running TS/Vue formatting", - summarize: summarizeTSFormat, - run: func(ctx context.Context, output io.Writer) int { - return exitCode(p.Tools.TS(ctx, paths, output), output) - }, - }, - ) - } - - if selected.Go { - steps = append(steps, step{ - label: "Running Go formatting", - summarize: summarizeGoFormat, - run: func(ctx context.Context, output io.Writer) int { - return p.Tools.Go(ctx, append([]string{"format"}, paths...), output) - }, - }) - } - - for _, step := range steps { - if code := p.runStep(ctx, log, step.label, step.summarize, step.run); code != 0 { - return code - } - } - - log.section("Formatting complete") - log.successDetail("status", "done") - - return 0 -} - -// runStep captures a step's combined output, streaming it live unless quiet, -// and prints either its summary details or (on failure) the captured log. -func (p Pipeline) runStep(ctx context.Context, log *logger, label string, summarize func(string, *logger), run func(context.Context, io.Writer) int) int { - log.section(label) - - var captured bytes.Buffer - - output := io.Writer(&captured) - - var live io.WriteCloser - - if !p.Quiet { - live = log.stream() - output = io.MultiWriter(&captured, live) - } - - code := run(ctx, output) - - if live != nil { - _ = live.Close() - } - - if code != 0 { - log.failure(label + " failed") - - if p.Quiet { - _, _ = io.Copy(p.Stderr, bytes.NewReader(captured.Bytes())) - } - - return code - } - - summarize(captured.String(), log) - - return 0 -} - -// exitCode maps a step error to its exit code. Failures that never produced -// tool output (a missing sidecar, an unreadable working tree) surface their -// message through the step's output writer so they are visible both live and -// in the failure dump. -func exitCode(err error, output io.Writer) int { - if err == nil { - return 0 - } - - var exit *exec.ExitError - - if errors.As(err, &exit) { - return exit.ExitCode() - } - - _, _ = io.WriteString(output, err.Error()+"\n") - - return 1 -} diff --git a/packages/go/driver/internal/orchestrator/pipeline_test.go b/packages/go/driver/internal/orchestrator/pipeline_test.go deleted file mode 100644 index dcc5ae1..0000000 --- a/packages/go/driver/internal/orchestrator/pipeline_test.go +++ /dev/null @@ -1,243 +0,0 @@ -package orchestrator - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "os" - "strings" - "testing" -) - -// TestMain pins a color-free environment: CI task runners export FORCE_COLOR, -// which would inject ANSI codes into the captured output these tests assert. - -// The stub outputs mirror infra/test-binary-smoke.sh so -// the Go orchestrator preserves the entrypoint's summary contract. - -type invocation struct { - tool string - args []string -} - -func TestMain(m *testing.M) { - _ = os.Unsetenv("FORCE_COLOR") - _ = os.Setenv("NO_COLOR", "1") - - os.Exit(m.Run()) -} - -const ( - stubTSOutput = "[blank-lines] processed 3 file(s) in /work, 0 changed\n" + - "Finished in 10ms on 3 files using 8 threads.\n" + - "[fluent-chains] processed 3 file(s) in /work, 1 changed\n" - - stubLintOutput = "Found 0 warnings and 0 errors.\n" - - stubGoOutput = "\nFormatter\n\n" + - " Formatted 2 file(s).\n\n" + - " Result: pass. 0 changed, 0 violation(s), 0 error(s).\n\n" + - "Vet\n\n" + - " go vet ./... passed.\n\n" + - " Result: pass. 0 error(s).\n" -) - -func stubTools(log *[]invocation, tsErr, lintErr error, goCode int) Tools { - return Tools{ - TS: func(_ context.Context, scopes []string, output io.Writer) error { - *log = append(*log, invocation{"ts", scopes}) - - _, _ = io.WriteString(output, stubTSOutput) - - return tsErr - }, - Lint: func(_ context.Context, scopes []string, output io.Writer) error { - *log = append(*log, invocation{"lint", scopes}) - - _, _ = io.WriteString(output, stubLintOutput) - - return lintErr - }, - Go: func(_ context.Context, args []string, output io.Writer) int { - *log = append(*log, invocation{"go", args}) - - _, _ = io.WriteString(output, stubGoOutput) - - return goCode - }, - } -} - -func TestRunFormatRunsStepsInOrder(t *testing.T) { - var log []invocation - - var stderr bytes.Buffer - - pipeline := Pipeline{ - Tools: stubTools(&log, nil, nil, 0), - Stderr: &stderr, - } - - if code := pipeline.RunFormat(context.Background(), []string{"."}); code != 0 { - t.Fatalf("RunFormat = %d, want 0\n%s", code, stderr.String()) - } - - want := []invocation{ - {"lint", []string{"."}}, - {"ts", []string{"."}}, - {"go", []string{"format", "."}}, - } - - if fmt.Sprint(log) != fmt.Sprint(want) { - t.Fatalf("invocations = %v, want %v", log, want) - } - - for _, needle := range []string{ - "==> Formatting target(s)", - "paths .", - "==> Running TS/Vue lint", - "oxlint Found 0 warnings and 0 errors.", - "==> Running TS/Vue formatting", - "blank-lines processed 3 file(s) in /work, 0 changed", - "oxfmt Finished in 10ms on 3 files using 8 threads.", - "fluent processed 3 file(s) in /work, 1 changed", - "==> Running Go formatting", - "fmtkit Formatted 2 file(s).", - "result pass. 0 changed, 0 violation(s), 0 error(s).", - "vet go vet ./... passed.", - "vet result pass. 0 error(s).", - "==> Formatting complete", - "status", - "done", - } { - if !strings.Contains(stderr.String(), needle) { - t.Fatalf("stderr missing %q:\n%s", needle, stderr.String()) - } - } -} - -func TestRunFormatStreamsToolOutputLive(t *testing.T) { - var log []invocation - - var stderr bytes.Buffer - - pipeline := Pipeline{ - Tools: stubTools(&log, nil, nil, 0), - Stderr: &stderr, - } - - if code := pipeline.RunFormat(context.Background(), nil); code != 0 { - t.Fatalf("RunFormat = %d, want 0", code) - } - - // The raw tool line appears indented (live stream) in addition to the - // condensed summary line. - if !strings.Contains(stderr.String(), " [blank-lines] processed 3 file(s) in /work, 0 changed") { - t.Fatalf("stderr missing live-streamed tool output:\n%s", stderr.String()) - } -} - -func TestRunFormatQuietHidesToolOutput(t *testing.T) { - var log []invocation - - var stderr bytes.Buffer - - pipeline := Pipeline{ - Tools: stubTools(&log, nil, nil, 0), - Quiet: true, - Stderr: &stderr, - } - - if code := pipeline.RunFormat(context.Background(), nil); code != 0 { - t.Fatalf("RunFormat = %d, want 0", code) - } - - if strings.Contains(stderr.String(), " [blank-lines]") { - t.Fatalf("quiet mode streamed tool output:\n%s", stderr.String()) - } - - if !strings.Contains(stderr.String(), "blank-lines processed 3 file(s) in /work, 0 changed") { - t.Fatalf("quiet mode lost summary:\n%s", stderr.String()) - } -} - -func TestRunFormatShortCircuitsOnTSFailure(t *testing.T) { - var log []invocation - - var stderr bytes.Buffer - - pipeline := Pipeline{ - Tools: stubTools(&log, errors.New("sidecar exploded"), nil, 0), - Stderr: &stderr, - } - - if code := pipeline.RunFormat(context.Background(), nil); code != 1 { - t.Fatalf("RunFormat = %d, want 1", code) - } - - if len(log) != 2 || log[0].tool != "lint" || log[1].tool != "ts" { - t.Fatalf("invocations = %v, want lint then ts (Go short-circuited)", log) - } - - if !strings.Contains(stderr.String(), "!! Running TS/Vue formatting failed") { - t.Fatalf("stderr missing failure banner:\n%s", stderr.String()) - } - - if !strings.Contains(stderr.String(), "sidecar exploded") { - t.Fatalf("stderr missing error message:\n%s", stderr.String()) - } -} - -func TestRunFormatQuietDumpsLogOnFailure(t *testing.T) { - var log []invocation - - var stderr bytes.Buffer - - pipeline := Pipeline{ - Tools: stubTools(&log, nil, nil, 3), - Quiet: true, - Stderr: &stderr, - } - - if code := pipeline.RunFormat(context.Background(), nil); code != 3 { - t.Fatalf("RunFormat = %d, want 3", code) - } - - if !strings.Contains(stderr.String(), "Formatted 2 file(s).") { - t.Fatalf("quiet failure did not dump captured log:\n%s", stderr.String()) - } -} - -func TestSummarizeTSLintFallbacks(t *testing.T) { - var out bytes.Buffer - - log := newLogger(&out, true) - - summarizeTSLint("[lint] no TS/Vue files to lint.\n", log) - - if !strings.Contains(out.String(), "oxlint no TS/Vue files to lint.") { - t.Fatalf("missing skip summary: %q", out.String()) - } - - out.Reset() - - summarizeTSLint("nothing interesting\n", log) - - if !strings.Contains(out.String(), "oxlint no issues found") { - t.Fatalf("missing fallback summary: %q", out.String()) - } -} - -func TestSummarizeTSFormatCountsMissing(t *testing.T) { - var out bytes.Buffer - - log := newLogger(&out, true) - - summarizeTSFormat("[sources] path not found, skipping: /work/a\n[sources] path not found, skipping: /work/b\n", log) - - if !strings.Contains(out.String(), "skipped 2 missing tracked file(s)") { - t.Fatalf("missing skipped summary: %q", out.String()) - } -} diff --git a/packages/go/driver/internal/orchestrator/summarize.go b/packages/go/driver/internal/orchestrator/summarize.go deleted file mode 100644 index 9bc9451..0000000 --- a/packages/go/driver/internal/orchestrator/summarize.go +++ /dev/null @@ -1,134 +0,0 @@ -package orchestrator - -import ( - "fmt" - "regexp" - "strings" -) - -// The summarizers distill a step's captured output into the aligned detail -// lines shown under its section header. - -var ( - lintResultPattern = regexp.MustCompile(`Found [0-9]+ warning|[0-9]+ error`) - goFileSummaryPattern = regexp.MustCompile(`^ (Formatted|Checked) [0-9]+ file\(s\)\.$|^ No Go files found\.$`) - goVetSummaryPattern = regexp.MustCompile(`^ go vet \./\.\.\. passed\.$|^ Skipped automatic go vet `) - sourcesMissingPrefix = "[sources] path not found, skipping:" - blankLinesPrefix = "[blank-lines] processed " - fluentChainsPrefix = "[fluent-chains] processed " - oxfmtFinishedPrefix = "Finished in " - validateSyntaxPrefix = "[validate-syntax] checked " - lintNothingToLintLine = "[lint] no TS/Vue files to lint." - goResultPrefix = " Result: " -) - -func lines(log string) []string { - return strings.Split(log, "\n") -} - -func lastWithPrefix(logLines []string, prefix string) string { - var match string - - for _, line := range logLines { - if strings.HasPrefix(line, prefix) { - match = line - } - } - - return match -} - -func summarizeTSFormat(log string, l *logger) { - logLines := lines(log) - missing := 0 - - for _, line := range logLines { - if strings.HasPrefix(line, sourcesMissingPrefix) { - missing++ - } - } - - if line := lastWithPrefix(logLines, blankLinesPrefix); line != "" { - l.detail("blank-lines", strings.TrimPrefix(line, "[blank-lines] ")) - } - - if missing > 0 { - l.detail("skipped", fmt.Sprintf("%d missing tracked file(s)", missing)) - } - - if line := lastWithPrefix(logLines, oxfmtFinishedPrefix); line != "" { - l.detail("oxfmt", line) - } - - if line := lastWithPrefix(logLines, fluentChainsPrefix); line != "" { - l.detail("fluent", strings.TrimPrefix(line, "[fluent-chains] ")) - } - - if line := lastWithPrefix(logLines, validateSyntaxPrefix); line != "" { - l.detail("validated", strings.TrimPrefix(line, "[validate-syntax] ")) - } -} - -func summarizeTSLint(log string, l *logger) { - logLines := lines(log) - - if lastWithPrefix(logLines, lintNothingToLintLine) != "" { - l.detail("oxlint", strings.TrimPrefix(lintNothingToLintLine, "[lint] ")) - - return - } - - var match string - - for _, line := range logLines { - if lintResultPattern.MatchString(line) { - match = line - } - } - - if match != "" { - l.detail("oxlint", match) - - return - } - - l.detail("oxlint", "no issues found") -} - -func summarizeGoFormat(log string, l *logger) { - var fileSummary, formatterResult, vetSummary, vetResult string - - for _, line := range lines(log) { - if fileSummary == "" && goFileSummaryPattern.MatchString(line) { - fileSummary = strings.TrimPrefix(line, " ") - } - - if vetSummary == "" && goVetSummaryPattern.MatchString(line) { - vetSummary = strings.TrimPrefix(line, " ") - } - - if strings.HasPrefix(line, goResultPrefix) { - if formatterResult == "" { - formatterResult = strings.TrimPrefix(line, goResultPrefix) - } - - vetResult = strings.TrimPrefix(line, goResultPrefix) - } - } - - if fileSummary != "" { - l.detail("fmtkit", fileSummary) - } - - if formatterResult != "" { - l.detail("result", formatterResult) - } - - if vetSummary != "" { - l.detail("vet", vetSummary) - } - - if vetResult != "" && vetResult != formatterResult { - l.detail("vet result", vetResult) - } -} diff --git a/packages/go/driver/internal/pipeline/pipeline.go b/packages/go/driver/internal/pipeline/pipeline.go new file mode 100644 index 0000000..cbf6b6b --- /dev/null +++ b/packages/go/driver/internal/pipeline/pipeline.go @@ -0,0 +1,103 @@ +// Package pipeline drives a sequence of typed pipeline steps, rendering +// sectioned, colorized progress: each step's tool output streams live, indented +// under its section header, followed by the condensed detail lines the step +// derives from its typed result. It owns only the section/tee/quiet-failure-dump +// mechanics; the concrete steps (and their detail computation) live with the +// composition root that builds them. +package pipeline + +import ( + "bytes" + "context" + "io" + + "go.ollin.sh/fmtkit/driver/internal/console" +) + +// Detail is one aligned label/value line shown under a step's section header. +type Detail struct { + Label string + Value string +} + +// Result is what a Step reports: the process exit code it wants (0 on success) +// and, on success, the detail lines to render under its section. +type Result struct { + ExitCode int + Details []Detail +} + +// Step is one unit of pipeline work. Label is the section header; Run writes the +// tool's live output to output (a tee of the live stream and, when a step needs +// it, its own capture) and returns the typed Result. +type Step interface { + Label() string + Run(ctx context.Context, output io.Writer) Result +} + +// Pipeline renders sectioned progress on Stderr while running the steps in +// order, short-circuiting on the first non-zero exit code. +type Pipeline struct { + Steps []Step + + // Quiet restores the summary-only output; a step's live tool log then only + // appears when it fails. + Quiet bool + + // Printer renders every section, detail, and failure banner. The caller + // constructs it once (resolving color at the boundary) and shares it. + Printer *console.Printer + + Stderr io.Writer +} + +// Run executes the steps in order, returning the first non-zero exit code or 0 +// when they all pass. +func (p Pipeline) Run(ctx context.Context) int { + for _, step := range p.Steps { + if code := p.runStep(ctx, step); code != 0 { + return code + } + } + + return 0 +} + +// runStep captures a step's combined output, streaming it live unless quiet, +// and prints either the step's detail lines or (on failure) the captured log. +func (p Pipeline) runStep(ctx context.Context, step Step) int { + p.Printer.Section(step.Label()) + + var captured bytes.Buffer + + output := io.Writer(&captured) + + var live io.WriteCloser + + if !p.Quiet { + live = p.Printer.Stream() + output = io.MultiWriter(&captured, live) + } + + result := step.Run(ctx, output) + + if live != nil { + _ = live.Close() + } + + if result.ExitCode != 0 { + p.Printer.Failure(step.Label() + " failed") + + if p.Quiet { + _, _ = io.Copy(p.Stderr, bytes.NewReader(captured.Bytes())) + } + + return result.ExitCode + } + + for _, detail := range result.Details { + p.Printer.Detail(detail.Label, detail.Value) + } + + return 0 +} diff --git a/packages/go/driver/internal/pipeline/pipeline_test.go b/packages/go/driver/internal/pipeline/pipeline_test.go new file mode 100644 index 0000000..036627f --- /dev/null +++ b/packages/go/driver/internal/pipeline/pipeline_test.go @@ -0,0 +1,300 @@ +package pipeline + +import ( + "bytes" + "context" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "go.ollin.sh/fmtkit/driver/internal/console" +) + +// fakeStep is a scripted Step: it streams a canned tool log to output, appends +// an optional trailing message (a non-exec error the real steps surface through +// output), and returns a fixed Result. It mirrors the tool stubs the earlier +// func-triple fakes used, now expressed against the Step interface. +type fakeStep struct { + label string + output string + trailing string + details []Detail + code int + + log *[]string +} + +var updateGolden = flag.Bool("update", false, "rewrite pipeline transcript golden files") + +func (s fakeStep) Label() string { return s.label } + +func (s fakeStep) Run(_ context.Context, output io.Writer) Result { + if s.log != nil { + *s.log = append(*s.log, s.label) + } + + _, _ = io.WriteString(output, s.output) + + if s.trailing != "" { + _, _ = io.WriteString(output, s.trailing) + } + + if s.code != 0 { + return Result{ExitCode: s.code} + } + + return Result{Details: s.details} +} + +const ( + stubTSOutput = "[blank-lines] processed 3 file(s) in /work, 0 changed\n" + + "Finished in 10ms on 3 files using 8 threads.\n" + + "[fluent-chains] processed 3 file(s) in /work, 1 changed\n" + + stubLintOutput = "Found 0 warnings and 0 errors.\n" + + stubGoOutput = "\nFormatter\n\n" + + " Formatted 2 file(s).\n\n" + + " Result: pass. 0 changed, 0 violation(s), 0 error(s).\n\n" + + "Vet\n\n" + + " go vet ./... passed.\n\n" + + " Result: pass. 0 error(s).\n" +) + +var ( + lintDetails = []Detail{{"oxlint", "Found 0 warnings and 0 errors."}} + + tsDetails = []Detail{ + {"blank-lines", "processed 3 file(s) in /work, 0 changed"}, + {"oxfmt", "Finished in 10ms on 3 files using 8 threads."}, + {"fluent", "processed 3 file(s) in /work, 1 changed"}, + } + + goDetails = []Detail{ + {"fmtkit", "Formatted 2 file(s)."}, + {"result", "pass. 0 changed, 0 violation(s), 0 error(s)."}, + {"vet", "go vet ./... passed."}, + {"vet result", "pass. 0 error(s)."}, + } +) + +// runFormat frames the three scripted steps exactly as the app composition root +// does (target header, completion footer), so the transcript the goldens pin is +// reproduced end to end without importing the app package. +func runFormat(t *testing.T, stderr io.Writer, quiet bool, steps []Step) int { + t.Helper() + + printer := console.NewPrinter(stderr, console.ColorNever) + + printer.Section("Formatting target(s)") + printer.Detail("paths", ".") + + code := Pipeline{Steps: steps, Quiet: quiet, Printer: printer, Stderr: stderr}.Run(context.Background()) + + if code == 0 { + printer.Section("Formatting complete") + printer.SuccessDetail("status", "done") + } + + return code +} + +// successSteps are the three passing steps in pipeline order (lint, TS, Go). +func successSteps(log *[]string) []Step { + return []Step{ + fakeStep{label: "Running TS/Vue lint", output: stubLintOutput, details: lintDetails, log: log}, + fakeStep{label: "Running TS/Vue formatting", output: stubTSOutput, details: tsDetails, log: log}, + fakeStep{label: "Running Go formatting", output: stubGoOutput, details: goDetails, log: log}, + } +} + +// TestRunFormatTranscriptGoldens pins the complete stderr transcript the +// pipeline renders, byte for byte, across the success and failure paths in both +// streaming and quiet modes. Color is forced off, so the golden files carry no +// ANSI escapes. These goldens characterize the rendering so refactors cannot +// silently change it; regenerate with +// `go test ./driver/internal/pipeline -run TestRunFormatTranscriptGoldens -update`. +func TestRunFormatTranscriptGoldens(t *testing.T) { + cases := []struct { + name string + quiet bool + steps []Step + golden string + }{ + {"success", false, successSteps(nil), "transcript_success.txt"}, + {"success_quiet", true, successSteps(nil), "transcript_success_quiet.txt"}, + { + "go_failure", false, + []Step{ + fakeStep{label: "Running TS/Vue lint", output: stubLintOutput, details: lintDetails}, + fakeStep{label: "Running TS/Vue formatting", output: stubTSOutput, details: tsDetails}, + fakeStep{label: "Running Go formatting", output: stubGoOutput, code: 3}, + }, + "transcript_go_failure.txt", + }, + { + "go_failure_quiet", true, + []Step{ + fakeStep{label: "Running TS/Vue lint", output: stubLintOutput, details: lintDetails}, + fakeStep{label: "Running TS/Vue formatting", output: stubTSOutput, details: tsDetails}, + fakeStep{label: "Running Go formatting", output: stubGoOutput, code: 3}, + }, + "transcript_go_failure_quiet.txt", + }, + { + "ts_failure", false, + []Step{ + fakeStep{label: "Running TS/Vue lint", output: stubLintOutput, details: lintDetails}, + fakeStep{label: "Running TS/Vue formatting", output: stubTSOutput, trailing: "sidecar exploded\n", code: 1}, + }, + "transcript_ts_failure.txt", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var stderr bytes.Buffer + + runFormat(t, &stderr, tc.quiet, tc.steps) + + path := filepath.Join("testdata", tc.golden) + + if *updateGolden { + if err := os.WriteFile(path, stderr.Bytes(), 0o644); err != nil { + t.Fatalf("update golden: %v", err) + } + + return + } + + want, err := os.ReadFile(path) + + if err != nil { + t.Fatalf("read golden: %v", err) + } + + if stderr.String() != string(want) { + t.Fatalf("transcript mismatch for %s\n--- got ---\n%s\n--- want ---\n%s", tc.golden, stderr.String(), want) + } + }) + } +} + +func TestRunRunsStepsInOrder(t *testing.T) { + var log []string + + var stderr bytes.Buffer + + if code := runFormat(t, &stderr, false, successSteps(&log)); code != 0 { + t.Fatalf("Run = %d, want 0\n%s", code, stderr.String()) + } + + want := []string{"Running TS/Vue lint", "Running TS/Vue formatting", "Running Go formatting"} + + if fmt.Sprint(log) != fmt.Sprint(want) { + t.Fatalf("step order = %v, want %v", log, want) + } + + for _, needle := range []string{ + "==> Formatting target(s)", + "paths .", + "==> Running TS/Vue lint", + "oxlint Found 0 warnings and 0 errors.", + "==> Running TS/Vue formatting", + "blank-lines processed 3 file(s) in /work, 0 changed", + "oxfmt Finished in 10ms on 3 files using 8 threads.", + "fluent processed 3 file(s) in /work, 1 changed", + "==> Running Go formatting", + "fmtkit Formatted 2 file(s).", + "result pass. 0 changed, 0 violation(s), 0 error(s).", + "vet go vet ./... passed.", + "vet result pass. 0 error(s).", + "==> Formatting complete", + "status", + "done", + } { + if !strings.Contains(stderr.String(), needle) { + t.Fatalf("stderr missing %q:\n%s", needle, stderr.String()) + } + } +} + +func TestRunStreamsToolOutputLive(t *testing.T) { + var stderr bytes.Buffer + + if code := runFormat(t, &stderr, false, successSteps(nil)); code != 0 { + t.Fatalf("Run = %d, want 0", code) + } + + // The raw tool line appears indented (live stream) in addition to the + // condensed detail line. + if !strings.Contains(stderr.String(), " [blank-lines] processed 3 file(s) in /work, 0 changed") { + t.Fatalf("stderr missing live-streamed tool output:\n%s", stderr.String()) + } +} + +func TestRunQuietHidesToolOutput(t *testing.T) { + var stderr bytes.Buffer + + if code := runFormat(t, &stderr, true, successSteps(nil)); code != 0 { + t.Fatalf("Run = %d, want 0", code) + } + + if strings.Contains(stderr.String(), " [blank-lines]") { + t.Fatalf("quiet mode streamed tool output:\n%s", stderr.String()) + } + + if !strings.Contains(stderr.String(), "blank-lines processed 3 file(s) in /work, 0 changed") { + t.Fatalf("quiet mode lost detail:\n%s", stderr.String()) + } +} + +func TestRunShortCircuitsOnFailure(t *testing.T) { + var log []string + + var stderr bytes.Buffer + + steps := []Step{ + fakeStep{label: "Running TS/Vue lint", output: stubLintOutput, details: lintDetails, log: &log}, + fakeStep{label: "Running TS/Vue formatting", output: stubTSOutput, trailing: "sidecar exploded\n", code: 1, log: &log}, + fakeStep{label: "Running Go formatting", output: stubGoOutput, details: goDetails, log: &log}, + } + + if code := runFormat(t, &stderr, false, steps); code != 1 { + t.Fatalf("Run = %d, want 1", code) + } + + want := []string{"Running TS/Vue lint", "Running TS/Vue formatting"} + + if fmt.Sprint(log) != fmt.Sprint(want) { + t.Fatalf("step order = %v, want %v (Go should have been skipped)", log, want) + } + + if !strings.Contains(stderr.String(), "!! Running TS/Vue formatting failed") { + t.Fatalf("stderr missing failure banner:\n%s", stderr.String()) + } + + if !strings.Contains(stderr.String(), "sidecar exploded") { + t.Fatalf("stderr missing error message:\n%s", stderr.String()) + } +} + +func TestRunQuietDumpsLogOnFailure(t *testing.T) { + var stderr bytes.Buffer + + steps := []Step{ + fakeStep{label: "Running Go formatting", output: stubGoOutput, code: 3}, + } + + if code := runFormat(t, &stderr, true, steps); code != 3 { + t.Fatalf("Run = %d, want 3", code) + } + + if !strings.Contains(stderr.String(), "Formatted 2 file(s).") { + t.Fatalf("quiet failure did not dump captured log:\n%s", stderr.String()) + } +} diff --git a/packages/go/driver/internal/pipeline/testdata/transcript_go_failure.txt b/packages/go/driver/internal/pipeline/testdata/transcript_go_failure.txt new file mode 100644 index 0000000..68af6fa --- /dev/null +++ b/packages/go/driver/internal/pipeline/testdata/transcript_go_failure.txt @@ -0,0 +1,31 @@ + +==> Formatting target(s) + paths . + +==> Running TS/Vue lint + Found 0 warnings and 0 errors. + oxlint Found 0 warnings and 0 errors. + +==> Running TS/Vue formatting + [blank-lines] processed 3 file(s) in /work, 0 changed + Finished in 10ms on 3 files using 8 threads. + [fluent-chains] processed 3 file(s) in /work, 1 changed + blank-lines processed 3 file(s) in /work, 0 changed + oxfmt Finished in 10ms on 3 files using 8 threads. + fluent processed 3 file(s) in /work, 1 changed + +==> Running Go formatting + + Formatter + + Formatted 2 file(s). + + Result: pass. 0 changed, 0 violation(s), 0 error(s). + + Vet + + go vet ./... passed. + + Result: pass. 0 error(s). + +!! Running Go formatting failed diff --git a/packages/go/driver/internal/pipeline/testdata/transcript_go_failure_quiet.txt b/packages/go/driver/internal/pipeline/testdata/transcript_go_failure_quiet.txt new file mode 100644 index 0000000..9c207c1 --- /dev/null +++ b/packages/go/driver/internal/pipeline/testdata/transcript_go_failure_quiet.txt @@ -0,0 +1,27 @@ + +==> Formatting target(s) + paths . + +==> Running TS/Vue lint + oxlint Found 0 warnings and 0 errors. + +==> Running TS/Vue formatting + blank-lines processed 3 file(s) in /work, 0 changed + oxfmt Finished in 10ms on 3 files using 8 threads. + fluent processed 3 file(s) in /work, 1 changed + +==> Running Go formatting + +!! Running Go formatting failed + +Formatter + + Formatted 2 file(s). + + Result: pass. 0 changed, 0 violation(s), 0 error(s). + +Vet + + go vet ./... passed. + + Result: pass. 0 error(s). diff --git a/packages/go/driver/internal/pipeline/testdata/transcript_success.txt b/packages/go/driver/internal/pipeline/testdata/transcript_success.txt new file mode 100644 index 0000000..9cdcd36 --- /dev/null +++ b/packages/go/driver/internal/pipeline/testdata/transcript_success.txt @@ -0,0 +1,36 @@ + +==> Formatting target(s) + paths . + +==> Running TS/Vue lint + Found 0 warnings and 0 errors. + oxlint Found 0 warnings and 0 errors. + +==> Running TS/Vue formatting + [blank-lines] processed 3 file(s) in /work, 0 changed + Finished in 10ms on 3 files using 8 threads. + [fluent-chains] processed 3 file(s) in /work, 1 changed + blank-lines processed 3 file(s) in /work, 0 changed + oxfmt Finished in 10ms on 3 files using 8 threads. + fluent processed 3 file(s) in /work, 1 changed + +==> Running Go formatting + + Formatter + + Formatted 2 file(s). + + Result: pass. 0 changed, 0 violation(s), 0 error(s). + + Vet + + go vet ./... passed. + + Result: pass. 0 error(s). + fmtkit Formatted 2 file(s). + result pass. 0 changed, 0 violation(s), 0 error(s). + vet go vet ./... passed. + vet result pass. 0 error(s). + +==> Formatting complete + status done diff --git a/packages/go/driver/internal/pipeline/testdata/transcript_success_quiet.txt b/packages/go/driver/internal/pipeline/testdata/transcript_success_quiet.txt new file mode 100644 index 0000000..c004512 --- /dev/null +++ b/packages/go/driver/internal/pipeline/testdata/transcript_success_quiet.txt @@ -0,0 +1,20 @@ + +==> Formatting target(s) + paths . + +==> Running TS/Vue lint + oxlint Found 0 warnings and 0 errors. + +==> Running TS/Vue formatting + blank-lines processed 3 file(s) in /work, 0 changed + oxfmt Finished in 10ms on 3 files using 8 threads. + fluent processed 3 file(s) in /work, 1 changed + +==> Running Go formatting + fmtkit Formatted 2 file(s). + result pass. 0 changed, 0 violation(s), 0 error(s). + vet go vet ./... passed. + vet result pass. 0 error(s). + +==> Formatting complete + status done diff --git a/packages/go/driver/internal/pipeline/testdata/transcript_ts_failure.txt b/packages/go/driver/internal/pipeline/testdata/transcript_ts_failure.txt new file mode 100644 index 0000000..c9c69af --- /dev/null +++ b/packages/go/driver/internal/pipeline/testdata/transcript_ts_failure.txt @@ -0,0 +1,15 @@ + +==> Formatting target(s) + paths . + +==> Running TS/Vue lint + Found 0 warnings and 0 errors. + oxlint Found 0 warnings and 0 errors. + +==> Running TS/Vue formatting + [blank-lines] processed 3 file(s) in /work, 0 changed + Finished in 10ms on 3 files using 8 threads. + [fluent-chains] processed 3 file(s) in /work, 1 changed + sidecar exploded + +!! Running TS/Vue formatting failed diff --git a/packages/go/driver/internal/sourcefiles/prettierignore_test.go b/packages/go/driver/internal/sourcefiles/prettierignore_test.go deleted file mode 100644 index d52a8b8..0000000 --- a/packages/go/driver/internal/sourcefiles/prettierignore_test.go +++ /dev/null @@ -1,153 +0,0 @@ -package sourcefiles - -import ( - "context" - "os" - "path/filepath" - "reflect" - "testing" -) - -func TestPrettierIgnoreMatches(t *testing.T) { - cases := []struct { - name string - ignore string - path string - excluded bool - }{ - {"blank and comment lines are inert", "\n# comment\n", "app.ts", false}, - {"plain name matches at root", "app.ts\n", "app.ts", true}, - {"plain name matches at any depth", "app.ts\n", "src/nested/app.ts", true}, - {"plain name does not match a different file", "app.ts\n", "app.tsx", false}, - {"leading slash anchors to root", "/app.ts\n", "app.ts", true}, - {"leading slash rejects nested", "/app.ts\n", "src/app.ts", false}, - {"trailing slash matches under a directory", "dist/\n", "dist/app.ts", true}, - {"trailing slash does not match a file of that name", "dist/\n", "dist", false}, - {"star stays within a segment", "*.ts\n", "src/app.ts", true}, - {"star does not cross a slash", "src/*.ts\n", "src/nested/app.ts", false}, - {"question matches one char", "app.?s\n", "app.ts", true}, - {"question needs exactly one char", "app.?s\n", "app.tss", false}, - {"character class matches a member", "app.[jt]s\n", "app.ts", true}, - {"character class rejects a non-member", "app.[jt]s\n", "app.xs", false}, - {"negated class rejects a member", "app.[!t]s\n", "app.ts", false}, - {"negated class matches a non-member", "app.[!t]s\n", "app.js", true}, - {"double star spans segments", "src/**/app.ts\n", "src/a/b/app.ts", true}, - {"double star spans zero segments", "src/**/app.ts\n", "src/app.ts", true}, - {"trailing double star matches everything below", "logs/**\n", "logs/a/b.txt", true}, - {"leading double star matches at any depth", "**/gen.ts\n", "a/b/gen.ts", true}, - {"excluding a directory excludes its contents", "build\n", "build/x/y.ts", true}, - {"a similarly named directory is untouched", "build\n", "prebuild/x.ts", false}, - {"negation re-includes a previously excluded file", "*.ts\n!keep.ts\n", "keep.ts", false}, - {"order matters: re-exclude after negation", "*.ts\n!keep.ts\nkeep.ts\n", "keep.ts", true}, - {"unclosed bracket is a literal", "a[b.ts\n", "a[b.ts", true}, - {"invalid character class is skipped without panicking", "[z-a]\napp.ts\n", "app.ts", true}, - {"invalid character class does not match its own line", "[z-a]\n", "z", false}, - {"crlf line trims the carriage return before spaces", "foo \r\n", "foo", true}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - ignore := compilePrettierIgnore([]byte(tc.ignore)) - - if got := ignore.ignores(tc.path); got != tc.excluded { - t.Fatalf("ignores(%q) = %v, want %v", tc.path, got, tc.excluded) - } - }) - } -} - -func TestLoadPrettierIgnoreAbsentFile(t *testing.T) { - ignore, err := loadPrettierIgnore(filepath.Join(t.TempDir(), ".prettierignore")) - - if err != nil { - t.Fatalf("loadPrettierIgnore: %v", err) - } - - if ignore != nil { - t.Fatalf("expected nil matcher for absent file, got %#v", ignore) - } -} - -func TestCollectSurfacesUnreadablePrettierIgnore(t *testing.T) { - dir := initRepo(t) - writeFile(t, filepath.Join(dir, "app.ts"), "const value = 1;\n") - gitAdd(t, dir, ".") - - // A directory named .prettierignore is not IsNotExist, so the read error must - // surface rather than being swallowed. - if err := os.Mkdir(filepath.Join(dir, ".prettierignore"), 0o755); err != nil { - t.Fatalf("mkdir .prettierignore: %v", err) - } - - if _, _, err := Collect(context.Background(), Options{Cwd: dir}); err == nil { - t.Fatal("expected an error from an unreadable .prettierignore") - } -} - -func TestCollectHonorsPrettierIgnore(t *testing.T) { - dir := initRepo(t) - writeFile(t, filepath.Join(dir, ".prettierignore"), "generated.ts\ndist/\n") - writeFile(t, filepath.Join(dir, "app.ts"), "const value = 1;\n") - writeFile(t, filepath.Join(dir, "generated.ts"), "const generated = 1;\n") - writeFile(t, filepath.Join(dir, "dist", "bundle.ts"), "const bundle = 1;\n") - gitAdd(t, dir, ".") - - files, warnings, err := Collect(context.Background(), Options{Cwd: dir}) - - if err != nil { - t.Fatalf("collect: %v", err) - } - - if len(warnings) != 0 { - t.Fatalf("unexpected warnings: %v", warnings) - } - - want := []string{filepath.Join(dir, "app.ts")} - - if !reflect.DeepEqual(files, want) { - t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, files) - } -} - -func TestCollectLintableHonorsPrettierIgnore(t *testing.T) { - dir := initRepo(t) - writeFile(t, filepath.Join(dir, ".prettierignore"), "vendor/\n") - writeFile(t, filepath.Join(dir, "app.ts"), "const value = 1;\n") - writeFile(t, filepath.Join(dir, "vendor", "lib.ts"), "const lib = 1;\n") - gitAdd(t, dir, ".") - - files, _, err := CollectLintable(context.Background(), Options{Cwd: dir}) - - if err != nil { - t.Fatalf("collect lintable: %v", err) - } - - want := []string{filepath.Join(dir, "app.ts")} - - if !reflect.DeepEqual(files, want) { - t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, files) - } -} - -func TestChangedPathsIgnoresPrettierIgnore(t *testing.T) { - dir := initRepo(t) - writeFile(t, filepath.Join(dir, ".prettierignore"), "main.go\n") - writeFile(t, filepath.Join(dir, "main.go"), "package main\n") - gitAdd(t, dir, ".") - - files, err := ChangedPaths(context.Background(), dir, nil) - - if err != nil { - t.Fatalf("changed paths: %v", err) - } - - // The Go lane must still see main.go even though .prettierignore lists it. - want := []string{ - filepath.Join(dir, ".prettierignore"), - filepath.Join(dir, "main.go"), - } - - if !reflect.DeepEqual(files, want) { - t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, files) - } -} diff --git a/packages/go/driver/internal/sourcefiles/sourcefiles.go b/packages/go/driver/internal/sourcefiles/sourcefiles.go deleted file mode 100644 index 912958b..0000000 --- a/packages/go/driver/internal/sourcefiles/sourcefiles.go +++ /dev/null @@ -1,287 +0,0 @@ -package sourcefiles - -import ( - "bytes" - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "slices" - "strings" -) - -// Selection is how much of the working tree a collection covers. -type Selection int - -type Options struct { - Cwd string - IncludeDeclarations bool - Scopes []string - - // Selection defaults to SelectionAll. - Selection Selection -} - -const ( - // SelectionAll covers every non-ignored file: tracked plus untracked. - // This is what `format-all` runs against. - SelectionAll Selection = iota - - // SelectionChanged covers only what has actually diverged from HEAD: - // modified-but-tracked (staged or not) plus untracked. This is what `format` - // runs against, so an everyday format stays proportional to the diff rather - // than the repo. - SelectionChanged -) - -// gitCommands returns the git invocations whose combined output lists the -// files s covers under scope. Every command prints NUL-separated paths -// relative to the directory git runs in. -func (s Selection) gitCommands(scope string) [][]string { - if s == SelectionChanged { - return [][]string{ - // Untracked files, plus tracked ones whose working-tree copy differs - // from the index. - {"ls-files", "--others", "--modified", "--exclude-standard", "-z", "--", scope}, - - // Staged changes are invisible to ls-files' worktree-vs-index view — - // a pre-commit hook would otherwise see nothing to format — so they - // come from an index-vs-HEAD diff. --relative keeps paths cwd-relative - // like ls-files; --diff-filter=d drops staged deletions, which leave - // no file to format. - {"diff", "--cached", "--name-only", "--relative", "--diff-filter=d", "-z", "--", scope}, - } - } - - return [][]string{{"ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", scope}} -} - -// Collect lists the files the formatter owns under the given scopes: the TS -// and Vue families plus the HTML and Markdown documents whose embedded scripts -// get formatted. -func Collect(ctx context.Context, opts Options) ([]string, []string, error) { - return collect(ctx, opts.Cwd, opts.Scopes, opts.Selection, true, func(path string) bool { - return isTargetFile(path, opts.IncludeDeclarations) - }) -} - -// CollectLintable lists only the files oxlint can lint under the given scopes: -// the TS and Vue families. It is a subset of Collect — HTML and Markdown are -// formattable but not lintable. -func CollectLintable(ctx context.Context, opts Options) ([]string, []string, error) { - return collect(ctx, opts.Cwd, opts.Scopes, opts.Selection, true, func(path string) bool { - return isLintableFile(path, opts.IncludeDeclarations) - }) -} - -// ChangedPaths lists every file that diverges from HEAD — modified, staged, or -// added — under the given scopes, whatever its extension. Callers do their own -// filtering — the Go formatter, for one, has its own notion of which files it -// owns. -// ChangedPaths deliberately skips .prettierignore filtering: it feeds the Go -// formatter, whose file set has nothing to do with Prettier's JS/TS ignore -// list. -func ChangedPaths(ctx context.Context, cwd string, scopes []string) ([]string, error) { - files, _, err := collect(ctx, cwd, scopes, SelectionChanged, false, func(string) bool { - return true - }) - - return files, err -} - -func collect(ctx context.Context, cwd string, scopes []string, selection Selection, honorPrettierIgnore bool, keep func(string) bool) ([]string, []string, error) { - if strings.TrimSpace(cwd) == "" { - var err error - - cwd, err = os.Getwd() - - if err != nil { - return nil, nil, err - } - } - - if len(scopes) == 0 { - scopes = []string{"."} - } - - files := []string{} - warnings := []string{} - seen := map[string]struct{}{} - - for _, scope := range scopes { - absolute := scope - - if !filepath.IsAbs(absolute) { - absolute = filepath.Join(cwd, scope) - } - - if _, err := os.Stat(absolute); err != nil { - if os.IsNotExist(err) { - warnings = append(warnings, fmt.Sprintf("path not found, skipping: %s", absolute)) - - continue - } - - return nil, warnings, err - } - - entries, err := gitFiles(ctx, cwd, absolute, selection) - - if err != nil { - return nil, warnings, err - } - - for _, entry := range entries { - if !keep(entry) { - continue - } - - path := entry - - if !filepath.IsAbs(path) { - path = filepath.Join(cwd, path) - } - - path = filepath.Clean(path) - - if _, ok := seen[path]; ok { - continue - } - - files = append(files, path) - seen[path] = struct{}{} - } - } - - if honorPrettierIgnore { - kept, err := filterPrettierIgnored(cwd, files) - - if err != nil { - return nil, warnings, err - } - - files = kept - } - - slices.Sort(files) - - return files, warnings, nil -} - -// filterPrettierIgnored drops any collected path the project's .prettierignore -// excludes. Paths outside cwd are kept untouched: .prettierignore is anchored -// to the directory that holds it and cannot speak to files above it. -func filterPrettierIgnored(cwd string, files []string) ([]string, error) { - ignore, err := loadPrettierIgnore(filepath.Join(cwd, ".prettierignore")) - - if err != nil { - return nil, err - } - - if ignore == nil { - return files, nil - } - - kept := make([]string, 0, len(files)) - - for _, path := range files { - rel, err := filepath.Rel(cwd, path) - - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - kept = append(kept, path) - - continue - } - - if ignore.ignores(filepath.ToSlash(rel)) { - continue - } - - kept = append(kept, path) - } - - return kept, nil -} - -func gitFiles(ctx context.Context, cwd, scope string, selection Selection) ([]string, error) { - entries := []string{} - - for _, args := range selection.gitCommands(scope) { - found, err := runGit(ctx, cwd, args) - - if err != nil { - return nil, err - } - - entries = append(entries, found...) - } - - return entries, nil -} - -func runGit(ctx context.Context, cwd string, args []string) ([]string, error) { - cmd := exec.CommandContext(ctx, "git", args...) - cmd.Dir = cwd - - var stderr bytes.Buffer - - cmd.Stderr = &stderr - - out, err := cmd.Output() - - if err != nil { - reason := strings.TrimSpace(stderr.String()) - - if reason == "" { - return nil, fmt.Errorf("git %s failed: %w", args[0], err) - } - - return nil, fmt.Errorf("git %s failed: %s: %w", args[0], reason, err) - } - - parts := bytes.Split(out, []byte{0}) - entries := make([]string, 0, len(parts)) - - for _, part := range parts { - if len(part) == 0 { - continue - } - - entries = append(entries, string(part)) - } - - return entries, nil -} - -// targetSuffixes are the extensions the sidecar knows how to format. The .ts -// entry also covers .d.ts; whether declarations are kept is decided separately. -var targetSuffixes = []string{".ts", ".vue", ".html", ".htm", ".md", ".markdown"} - -func isTargetFile(path string, includeDeclarations bool) bool { - matched := false - - for _, suffix := range targetSuffixes { - if strings.HasSuffix(path, suffix) { - matched = true - - break - } - } - - if !matched { - return false - } - - return includeDeclarations || !strings.HasSuffix(path, ".d.ts") -} - -// isLintableFile reports whether oxlint can lint path: the TS family (minus -// declarations unless includeDeclarations) and Vue, but not HTML or Markdown. -func isLintableFile(path string, includeDeclarations bool) bool { - if !strings.HasSuffix(path, ".ts") && !strings.HasSuffix(path, ".vue") { - return false - } - - return includeDeclarations || !strings.HasSuffix(path, ".d.ts") -} diff --git a/packages/go/driver/internal/sourcefiles/sourcefiles_test.go b/packages/go/driver/internal/sourcefiles/sourcefiles_test.go deleted file mode 100644 index 0832663..0000000 --- a/packages/go/driver/internal/sourcefiles/sourcefiles_test.go +++ /dev/null @@ -1,376 +0,0 @@ -package sourcefiles - -import ( - "context" - "os" - "os/exec" - "path/filepath" - "reflect" - "testing" -) - -func TestCollectFiltersSourceFiles(t *testing.T) { - dir := initRepo(t) - writeFile(t, filepath.Join(dir, "src", "app.ts"), "const value = 1;\n") - writeFile(t, filepath.Join(dir, "src", "component.vue"), "\n") - writeFile(t, filepath.Join(dir, "src", "types.d.ts"), "declare const value: string;\n") - writeFile(t, filepath.Join(dir, "src", "notes.md"), "# Notes\n") - writeFile(t, filepath.Join(dir, "src", "index.html"), "\n") - gitAdd(t, dir, ".") - - files, warnings, err := Collect(context.Background(), Options{Cwd: dir, Scopes: []string{"src"}}) - - if err != nil { - t.Fatalf("collect: %v", err) - } - - if len(warnings) != 0 { - t.Fatalf("unexpected warnings: %v", warnings) - } - - want := []string{ - filepath.Join(dir, "src", "app.ts"), - filepath.Join(dir, "src", "component.vue"), - filepath.Join(dir, "src", "index.html"), - filepath.Join(dir, "src", "notes.md"), - } - - if !reflect.DeepEqual(files, want) { - t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, files) - } -} - -func TestCollectCanIncludeDeclarationFiles(t *testing.T) { - dir := initRepo(t) - writeFile(t, filepath.Join(dir, "src", "app.ts"), "const value = 1;\n") - writeFile(t, filepath.Join(dir, "src", "types.d.ts"), "declare const value: string;\n") - gitAdd(t, dir, ".") - - files, warnings, err := Collect(context.Background(), Options{Cwd: dir, IncludeDeclarations: true, Scopes: []string{"src"}}) - - if err != nil { - t.Fatalf("collect: %v", err) - } - - if len(warnings) != 0 { - t.Fatalf("unexpected warnings: %v", warnings) - } - - want := []string{ - filepath.Join(dir, "src", "app.ts"), - filepath.Join(dir, "src", "types.d.ts"), - } - - if !reflect.DeepEqual(files, want) { - t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, files) - } -} - -func TestCollectLintableExcludesNonScriptDocuments(t *testing.T) { - dir := initRepo(t) - writeFile(t, filepath.Join(dir, "src", "app.ts"), "const value = 1;\n") - writeFile(t, filepath.Join(dir, "src", "component.vue"), "\n") - writeFile(t, filepath.Join(dir, "src", "types.d.ts"), "declare const value: string;\n") - writeFile(t, filepath.Join(dir, "src", "notes.md"), "# Notes\n") - writeFile(t, filepath.Join(dir, "src", "readme.markdown"), "# Readme\n") - writeFile(t, filepath.Join(dir, "src", "index.html"), "\n") - gitAdd(t, dir, ".") - - // Formatting owns the HTML and Markdown documents alongside the TS/Vue files. - formatFiles, _, err := Collect(context.Background(), Options{Cwd: dir, Scopes: []string{"src"}}) - - if err != nil { - t.Fatalf("collect: %v", err) - } - - wantFormat := []string{ - filepath.Join(dir, "src", "app.ts"), - filepath.Join(dir, "src", "component.vue"), - filepath.Join(dir, "src", "index.html"), - filepath.Join(dir, "src", "notes.md"), - filepath.Join(dir, "src", "readme.markdown"), - } - - if !reflect.DeepEqual(formatFiles, wantFormat) { - t.Fatalf("format files mismatch\nwant: %#v\n got: %#v", wantFormat, formatFiles) - } - - // Linting sees only the TS/Vue files: no HTML, no Markdown, and .d.ts stays - // out unless declarations are requested. - lintFiles, _, err := CollectLintable(context.Background(), Options{Cwd: dir, Scopes: []string{"src"}}) - - if err != nil { - t.Fatalf("collect lintable: %v", err) - } - - wantLint := []string{ - filepath.Join(dir, "src", "app.ts"), - filepath.Join(dir, "src", "component.vue"), - } - - if !reflect.DeepEqual(lintFiles, wantLint) { - t.Fatalf("lintable files mismatch\nwant: %#v\n got: %#v", wantLint, lintFiles) - } -} - -func TestCollectLintableCanIncludeDeclarationFiles(t *testing.T) { - dir := initRepo(t) - writeFile(t, filepath.Join(dir, "src", "app.ts"), "const value = 1;\n") - writeFile(t, filepath.Join(dir, "src", "types.d.ts"), "declare const value: string;\n") - writeFile(t, filepath.Join(dir, "src", "index.html"), "\n") - gitAdd(t, dir, ".") - - files, _, err := CollectLintable(context.Background(), Options{Cwd: dir, IncludeDeclarations: true, Scopes: []string{"src"}}) - - if err != nil { - t.Fatalf("collect lintable: %v", err) - } - - // Declarations come back when requested; HTML never does. - want := []string{ - filepath.Join(dir, "src", "app.ts"), - filepath.Join(dir, "src", "types.d.ts"), - } - - if !reflect.DeepEqual(files, want) { - t.Fatalf("lintable files mismatch\nwant: %#v\n got: %#v", want, files) - } -} - -func TestCollectIncludesUntrackedAndIgnoresIgnored(t *testing.T) { - dir := initRepo(t) - writeFile(t, filepath.Join(dir, ".gitignore"), "ignored.ts\n") - writeFile(t, filepath.Join(dir, "tracked.ts"), "const value = 1;\n") - gitAdd(t, dir, ".gitignore", "tracked.ts") - writeFile(t, filepath.Join(dir, "untracked.vue"), "\n") - writeFile(t, filepath.Join(dir, "ignored.ts"), "const ignored = true;\n") - - files, warnings, err := Collect(context.Background(), Options{Cwd: dir}) - - if err != nil { - t.Fatalf("collect: %v", err) - } - - if len(warnings) != 0 { - t.Fatalf("unexpected warnings: %v", warnings) - } - - want := []string{ - filepath.Join(dir, "tracked.ts"), - filepath.Join(dir, "untracked.vue"), - } - - if !reflect.DeepEqual(files, want) { - t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, files) - } -} - -func TestCollectScopesAndDeduplicatesFiles(t *testing.T) { - dir := initRepo(t) - writeFile(t, filepath.Join(dir, "src", "app.ts"), "const value = 1;\n") - writeFile(t, filepath.Join(dir, "other", "app.ts"), "const value = 2;\n") - gitAdd(t, dir, ".") - - files, warnings, err := Collect(context.Background(), Options{ - Cwd: dir, - Scopes: []string{"src", filepath.Join(dir, "src", "app.ts"), "missing"}, - }) - - if err != nil { - t.Fatalf("collect: %v", err) - } - - if len(warnings) != 1 { - t.Fatalf("expected one warning, got %v", warnings) - } - - want := []string{filepath.Join(dir, "src", "app.ts")} - - if !reflect.DeepEqual(files, want) { - t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, files) - } -} - -func initRepo(t *testing.T) string { - t.Helper() - - dir := t.TempDir() - run(t, dir, "git", "init", "-q") - run(t, dir, "git", "config", "user.email", "tests@example.com") - run(t, dir, "git", "config", "user.name", "Test Runner") - - return dir -} - -func gitAdd(t *testing.T, dir string, paths ...string) { - t.Helper() - - args := append([]string{"add"}, paths...) - run(t, dir, "git", args...) -} - -func writeFile(t *testing.T, path string, content string) { - t.Helper() - - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - t.Fatalf("write file: %v", err) - } -} - -func run(t *testing.T, dir string, name string, args ...string) { - t.Helper() - - cmd := exec.Command(name, args...) - cmd.Dir = dir - - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("%s %v: %v\n%s", name, args, err, out) - } -} - -func TestCollectChangedCoversOnlyTheWorkingTreesChanges(t *testing.T) { - dir := initRepo(t) - writeFile(t, filepath.Join(dir, ".gitignore"), "ignored.ts\n") - writeFile(t, filepath.Join(dir, "untouched.ts"), "const untouched = 1;\n") - writeFile(t, filepath.Join(dir, "modified.ts"), "const modified = 1;\n") - gitAdd(t, dir, ".gitignore", "untouched.ts", "modified.ts") - gitCommit(t, dir) - - // Only these three diverge from the commit: a tracked file edited in the - // working tree, a brand new file, and an ignored one that must stay out. - writeFile(t, filepath.Join(dir, "modified.ts"), "const modified = 2;\n") - writeFile(t, filepath.Join(dir, "untracked.vue"), "\n") - writeFile(t, filepath.Join(dir, "ignored.ts"), "const ignored = true;\n") - - files, warnings, err := Collect(context.Background(), Options{Cwd: dir, Selection: SelectionChanged}) - - if err != nil { - t.Fatalf("collect: %v", err) - } - - if len(warnings) != 0 { - t.Fatalf("unexpected warnings: %v", warnings) - } - - want := []string{ - filepath.Join(dir, "modified.ts"), - filepath.Join(dir, "untracked.vue"), - } - - if !reflect.DeepEqual(files, want) { - t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, files) - } -} - -func TestCollectChangedIncludesStagedFiles(t *testing.T) { - dir := initRepo(t) - writeFile(t, filepath.Join(dir, "staged.ts"), "const staged = 1;\n") - writeFile(t, filepath.Join(dir, "removed.ts"), "const removed = 1;\n") - writeFile(t, filepath.Join(dir, "untouched.ts"), "const untouched = 1;\n") - gitAdd(t, dir, "staged.ts", "removed.ts", "untouched.ts") - gitCommit(t, dir) - - // Fully staged: the working tree and index agree, but HEAD does not. This is - // the pre-commit-hook shape, where everything is added before the hook runs. - writeFile(t, filepath.Join(dir, "staged.ts"), "const staged = 2;\n") - gitAdd(t, dir, "staged.ts") - - // A staged deletion leaves no file to format and must stay out. - run(t, dir, "git", "rm", "-q", "removed.ts") - - files, warnings, err := Collect(context.Background(), Options{Cwd: dir, Selection: SelectionChanged}) - - if err != nil { - t.Fatalf("collect: %v", err) - } - - if len(warnings) != 0 { - t.Fatalf("unexpected warnings: %v", warnings) - } - - want := []string{filepath.Join(dir, "staged.ts")} - - if !reflect.DeepEqual(files, want) { - t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, files) - } -} - -func TestCollectChangedWorksBeforeTheFirstCommit(t *testing.T) { - dir := initRepo(t) - writeFile(t, filepath.Join(dir, "staged.ts"), "const staged = 1;\n") - gitAdd(t, dir, "staged.ts") - - files, warnings, err := Collect(context.Background(), Options{Cwd: dir, Selection: SelectionChanged}) - - if err != nil { - t.Fatalf("collect: %v", err) - } - - if len(warnings) != 0 { - t.Fatalf("unexpected warnings: %v", warnings) - } - - want := []string{filepath.Join(dir, "staged.ts")} - - if !reflect.DeepEqual(files, want) { - t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, files) - } -} - -func TestCollectAllCoversCommittedFilesThatChangedSelectionSkips(t *testing.T) { - dir := initRepo(t) - writeFile(t, filepath.Join(dir, "untouched.ts"), "const untouched = 1;\n") - gitAdd(t, dir, "untouched.ts") - gitCommit(t, dir) - - changed, _, err := Collect(context.Background(), Options{Cwd: dir, Selection: SelectionChanged}) - - if err != nil { - t.Fatalf("collect changed: %v", err) - } - - if len(changed) != 0 { - t.Fatalf("a clean working tree has no changes, got: %#v", changed) - } - - all, _, err := Collect(context.Background(), Options{Cwd: dir, Selection: SelectionAll}) - - if err != nil { - t.Fatalf("collect all: %v", err) - } - - want := []string{filepath.Join(dir, "untouched.ts")} - - if !reflect.DeepEqual(all, want) { - t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, all) - } -} - -func TestCollectDefaultsToAll(t *testing.T) { - dir := initRepo(t) - writeFile(t, filepath.Join(dir, "untouched.ts"), "const untouched = 1;\n") - gitAdd(t, dir, "untouched.ts") - gitCommit(t, dir) - - files, _, err := Collect(context.Background(), Options{Cwd: dir}) - - if err != nil { - t.Fatalf("collect: %v", err) - } - - want := []string{filepath.Join(dir, "untouched.ts")} - - if !reflect.DeepEqual(files, want) { - t.Fatalf("the zero Selection must cover everything\nwant: %#v\n got: %#v", want, files) - } -} - -func gitCommit(t *testing.T, dir string) { - t.Helper() - - run(t, dir, "git", "commit", "-q", "-m", "fixture") -} diff --git a/packages/go/driver/internal/toolchain/toolchain.go b/packages/go/driver/internal/toolchain/toolchain.go new file mode 100644 index 0000000..86e0b5b --- /dev/null +++ b/packages/go/driver/internal/toolchain/toolchain.go @@ -0,0 +1,71 @@ +// Package toolchain is the contract and registry that separate the format +// pipeline into per-language lanes. Each Toolchain contributes the ordered +// pipeline steps for one language (TS, Go); the Registry holds them in +// registration order, which is the order they run, and resolves the --ts/--go +// selection down to the lanes that should execute. +package toolchain + +import ( + "go.ollin.sh/fmtkit/driver/internal/gitfiles" + "go.ollin.sh/fmtkit/driver/internal/pipeline" +) + +// Request carries what a lane needs to build its steps: the binary version +// (the TS lane extracts a per-version toolchain cache from it), the target +// paths, and how much of the working tree the run scopes to. +type Request struct { + Version string + Paths []string + Selection gitfiles.Selection +} + +// A Toolchain contributes the pipeline steps for one language lane. Name is the +// lane's selector, matching the --ts/--go flags; Steps builds the ordered steps +// for a request (TS returns [lint, format]; Go returns [format]). +type Toolchain interface { + Name() string + Steps(req Request) []pipeline.Step +} + +// Registry holds the registered lanes in registration order, which is also +// their execution order. +type Registry struct { + chains []Toolchain +} + +// NewRegistry registers the given lanes in order. The composition root +// constructs and registers them explicitly; there is no init()-based +// self-registration, so registration order is whatever the caller passes. +func NewRegistry(chains ...Toolchain) Registry { + return Registry{chains: chains} +} + +// Select resolves a set of lane names to the lanes that should run. With no +// names it returns every registered lane (the no-flag "everything" default); +// otherwise it returns the registered lanes whose Name is among names. Either +// way the result preserves registration order, and names that match no +// registered lane are ignored. +func (r Registry) Select(names ...string) []Toolchain { + if len(names) == 0 { + out := make([]Toolchain, len(r.chains)) + copy(out, r.chains) + + return out + } + + want := make(map[string]struct{}, len(names)) + + for _, name := range names { + want[name] = struct{}{} + } + + var out []Toolchain + + for _, chain := range r.chains { + if _, ok := want[chain.Name()]; ok { + out = append(out, chain) + } + } + + return out +} diff --git a/packages/go/driver/internal/toolchain/toolchain_test.go b/packages/go/driver/internal/toolchain/toolchain_test.go new file mode 100644 index 0000000..df547eb --- /dev/null +++ b/packages/go/driver/internal/toolchain/toolchain_test.go @@ -0,0 +1,98 @@ +package toolchain + +import ( + "context" + "fmt" + "io" + "testing" + + "go.ollin.sh/fmtkit/driver/internal/pipeline" +) + +// fakeChain is a minimal Toolchain that records its name and a single labelled +// step, enough to assert the registry's selection and ordering. +type fakeChain struct { + name string +} + +// labelStep is a Step whose Label is its name, so a selection can be read back +// as a list of names. +type labelStep string + +func (c fakeChain) Name() string { return c.name } + +func (c fakeChain) Steps(Request) []pipeline.Step { + return []pipeline.Step{labelStep(c.name)} +} + +func (s labelStep) Label() string { return string(s) } + +func (s labelStep) Run(context.Context, io.Writer) pipeline.Result { return pipeline.Result{} } + +func names(chains []Toolchain) []string { + out := make([]string, 0, len(chains)) + + for _, chain := range chains { + out = append(out, chain.Name()) + } + + return out +} + +func TestSelectEmptyReturnsAllInOrder(t *testing.T) { + reg := NewRegistry(fakeChain{"ts"}, fakeChain{"go"}) + + if got := fmt.Sprint(names(reg.Select())); got != fmt.Sprint([]string{"ts", "go"}) { + t.Fatalf("Select() = %s, want [ts go]", got) + } +} + +func TestSelectByNamePreservesRegistrationOrder(t *testing.T) { + reg := NewRegistry(fakeChain{"ts"}, fakeChain{"go"}) + + // Ask in the opposite order; the registry still returns registration order. + if got := fmt.Sprint(names(reg.Select("go", "ts"))); got != fmt.Sprint([]string{"ts", "go"}) { + t.Fatalf("Select(go, ts) = %s, want [ts go]", got) + } +} + +func TestSelectSingleName(t *testing.T) { + reg := NewRegistry(fakeChain{"ts"}, fakeChain{"go"}) + + if got := fmt.Sprint(names(reg.Select("ts"))); got != fmt.Sprint([]string{"ts"}) { + t.Fatalf("Select(ts) = %s, want [ts]", got) + } + + if got := fmt.Sprint(names(reg.Select("go"))); got != fmt.Sprint([]string{"go"}) { + t.Fatalf("Select(go) = %s, want [go]", got) + } +} + +func TestSelectUnknownNamesAreIgnored(t *testing.T) { + reg := NewRegistry(fakeChain{"ts"}, fakeChain{"go"}) + + if got := names(reg.Select("rust")); len(got) != 0 { + t.Fatalf("Select(rust) = %v, want empty", got) + } + + // A known name mixed with an unknown one keeps only the known lane. + if got := fmt.Sprint(names(reg.Select("go", "rust"))); got != fmt.Sprint([]string{"go"}) { + t.Fatalf("Select(go, rust) = %s, want [go]", got) + } +} + +func TestSelectStepsComeFromChosenLanes(t *testing.T) { + reg := NewRegistry(fakeChain{"ts"}, fakeChain{"go"}) + + var labels []string + + for _, chain := range reg.Select() { + for _, step := range chain.Steps(Request{}) { + labels = append(labels, step.Label()) + } + } + + if got := fmt.Sprint(labels); got != fmt.Sprint([]string{"ts", "go"}) { + t.Fatalf("step labels = %s, want [ts go]", got) + } +} diff --git a/packages/go/driver/internal/tsruntime/run.go b/packages/go/driver/internal/tsruntime/run.go deleted file mode 100644 index 33d0340..0000000 --- a/packages/go/driver/internal/tsruntime/run.go +++ /dev/null @@ -1,255 +0,0 @@ -package tsruntime - -import ( - "context" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - - "go.ollin.sh/fmtkit/driver/internal/sourcefiles" -) - -// RunOptions describes one TS toolchain invocation. -type RunOptions struct { - // Scopes are the paths to process, defaulting to ".". - Scopes []string - - // Selection is how much of the working tree to cover within Scopes. It - // defaults to sourcefiles.SelectionAll. - Selection sourcefiles.Selection - - // Fix, when set, lets RunLint apply oxlint's safe fixes (--fix) rather - // than only reporting violations. - Fix bool - - Stdout io.Writer - Stderr io.Writer -} - -// overrides carries the environment overrides a TS toolchain invocation -// honours, resolved once at the entry points rather than ad hoc deep in the -// call paths. -type overrides struct { - pipelineBin string - oxfmtBin string - oxlintBin string - oxfmtConfig string - oxlintConfig string - sourcesCwd string -} - -const ( - PipelineBinEnv = "FMTKIT_TS_PIPELINE_BIN" - OxfmtBinEnv = "OXFMT_BIN" - OxlintBinEnv = "OXLINT_BIN" - OxfmtConfigEnv = "FMTKIT_OXFMTRC" - OxlintConfigEnv = "FMTKIT_OXLINTRC" - SourcesCwdEnv = "FMTKIT_SOURCES_CWD" -) - -// readOverrides gathers every environment override in one place. -func readOverrides() overrides { - return overrides{ - pipelineBin: os.Getenv(PipelineBinEnv), - oxfmtBin: os.Getenv(OxfmtBinEnv), - oxlintBin: os.Getenv(OxlintBinEnv), - oxfmtConfig: os.Getenv(OxfmtConfigEnv), - oxlintConfig: os.Getenv(OxlintConfigEnv), - sourcesCwd: os.Getenv(SourcesCwdEnv), - } -} - -// RunPipeline runs the full TS/Vue formatting pipeline (blank-lines -> oxfmt -// -> fluent-chains -> blank-lines -> validate-syntax). oxfmt is an internal -// normalising step, not the last word: the project passes run after it and -// own the final style. -func (s Support) RunPipeline(ctx context.Context, opts RunOptions) error { - env := readOverrides() - - cwd, err := sourcesCwd(env) - - if err != nil { - return err - } - - formatFiles, warnings, err := collect(ctx, cwd, opts.Scopes, false, opts.Selection) - - if err != nil { - return err - } - - for _, warning := range warnings { - _, _ = fmt.Fprintf(opts.Stderr, "[sources] %s\n", warning) - } - - syntaxFiles, _, err := collect(ctx, cwd, opts.Scopes, true, opts.Selection) - - if err != nil { - return err - } - - args := []string{"pipeline"} - - if env.oxfmtBin != "" { - args = append(args, "--oxfmt-bin", env.oxfmtBin) - } else { - args = append(args, "--oxfmt-bin", s.Sidecar()) - } - - if config := s.oxfmtConfigFor(ctx, cwd, env, opts.Stderr); config != "" { - args = append(args, "--oxfmt-config", config) - } - - args = append(args, "--format-files") - args = append(args, formatFiles...) - args = append(args, "--syntax-files") - args = append(args, syntaxFiles...) - - return s.spawn(ctx, pipelineBin(env, s.Sidecar()), args, opts) -} - -// RunLint lints the collected TS/Vue files with oxlint. With opts.Fix it applies -// oxlint's safe fixes in place; otherwise it only reports violations. -func (s Support) RunLint(ctx context.Context, opts RunOptions) error { - env := readOverrides() - - cwd, err := sourcesCwd(env) - - if err != nil { - return err - } - - files, warnings, err := collectLintable(ctx, cwd, opts.Scopes, false, opts.Selection) - - if err != nil { - return err - } - - for _, warning := range warnings { - _, _ = fmt.Fprintf(opts.Stderr, "[sources] %s\n", warning) - } - - if len(files) == 0 { - _, _ = fmt.Fprintln(opts.Stdout, "[lint] no TS/Vue files to lint.") - - return nil - } - - var args []string - - bin := env.oxlintBin - - if bin == "" { - bin = s.Sidecar() - args = append(args, "oxlint") - } - - if opts.Fix { - args = append(args, "--fix") - } - - if config := s.oxlintConfigFor(cwd, env); config != "" { - args = append(args, "--config", config) - } - - args = append(args, files...) - - return s.spawn(ctx, bin, args, opts) -} - -func pipelineBin(env overrides, sidecar string) string { - if env.pipelineBin != "" { - return env.pipelineBin - } - - return sidecar -} - -func sourcesCwd(env overrides) (string, error) { - if env.sourcesCwd != "" { - return env.sourcesCwd, nil - } - - cwd, err := os.Getwd() - - if err != nil { - return "", fmt.Errorf("resolve cwd: %w", err) - } - - return cwd, nil -} - -func collect(ctx context.Context, cwd string, scopes []string, includeDeclarations bool, selection sourcefiles.Selection) ([]string, []string, error) { - return sourcefiles.Collect(ctx, sourcefiles.Options{ - Cwd: cwd, - IncludeDeclarations: includeDeclarations, - Scopes: scopes, - Selection: selection, - }) -} - -func collectLintable(ctx context.Context, cwd string, scopes []string, includeDeclarations bool, selection sourcefiles.Selection) ([]string, []string, error) { - return sourcefiles.CollectLintable(ctx, sourcefiles.Options{ - Cwd: cwd, - IncludeDeclarations: includeDeclarations, - Scopes: scopes, - Selection: selection, - }) -} - -// oxfmtConfigFor resolves the oxfmt config by precedence: the FMTKIT_OXFMTRC -// override, then a project-local .oxfmtrc.* (via oxfmt's own auto-discovery, -// signalled by ""), then a config derived from the project's Prettier -// configuration, and finally the bundled default. -func (s Support) oxfmtConfigFor(ctx context.Context, cwd string, env overrides, stderr io.Writer) string { - if env.oxfmtConfig != "" { - return existingFile(env.oxfmtConfig) - } - - if matches, err := filepath.Glob(filepath.Join(cwd, ".oxfmtrc.*")); err == nil && len(matches) > 0 { - return "" - } - - if derived := s.prettierDerivedConfig(ctx, cwd, env, stderr); derived != "" { - return derived - } - - return s.OxfmtConfig() -} - -// oxlintConfigFor treats both the extensionless .oxlintrc and .oxlintrc.* as -// project configuration. -func (s Support) oxlintConfigFor(cwd string, env overrides) string { - if env.oxlintConfig != "" { - return existingFile(env.oxlintConfig) - } - - if existingFile(filepath.Join(cwd, ".oxlintrc")) != "" { - return "" - } - - if matches, err := filepath.Glob(filepath.Join(cwd, ".oxlintrc.*")); err == nil && len(matches) > 0 { - return "" - } - - return s.OxlintConfig() -} - -func (s Support) spawn(ctx context.Context, bin string, args []string, opts RunOptions) error { - cmd := exec.CommandContext(ctx, bin, args...) - - cmd.Stdout = opts.Stdout - cmd.Stderr = opts.Stderr - - // Match the container entrypoints: let git treat any working tree as safe - // so file collection inside bind mounts and caches works. - cmd.Env = append(os.Environ(), - "GIT_CONFIG_COUNT=1", - "GIT_CONFIG_KEY_0=safe.directory", - "GIT_CONFIG_VALUE_0=*", - ) - - return cmd.Run() -} diff --git a/packages/go/driver/internal/embedded/doc.go b/packages/go/driver/internal/typescript/embedded/doc.go similarity index 78% rename from packages/go/driver/internal/embedded/doc.go rename to packages/go/driver/internal/typescript/embedded/doc.go index a224cd7..0232a06 100644 --- a/packages/go/driver/internal/embedded/doc.go +++ b/packages/go/driver/internal/typescript/embedded/doc.go @@ -1,7 +1,7 @@ // Package embedded carries the TS toolchain baked into release binaries. // // The assets are staged under bin/_/ by -// packages/ts/infra/stage-ts-assets.sh and are only compiled in under the +// packages/ts/toolchain/stage-ts-assets.sh and are only compiled in under the // fmtkit_sidecar build tag (see sidecar_*.go); ordinary builds get the // sidecar_dev.go stub instead, so the staged directories need not exist. package embedded diff --git a/packages/go/driver/internal/embedded/sidecar_darwin_amd64.go b/packages/go/driver/internal/typescript/embedded/sidecar_darwin_amd64.go similarity index 100% rename from packages/go/driver/internal/embedded/sidecar_darwin_amd64.go rename to packages/go/driver/internal/typescript/embedded/sidecar_darwin_amd64.go diff --git a/packages/go/driver/internal/embedded/sidecar_darwin_arm64.go b/packages/go/driver/internal/typescript/embedded/sidecar_darwin_arm64.go similarity index 100% rename from packages/go/driver/internal/embedded/sidecar_darwin_arm64.go rename to packages/go/driver/internal/typescript/embedded/sidecar_darwin_arm64.go diff --git a/packages/go/driver/internal/embedded/sidecar_dev.go b/packages/go/driver/internal/typescript/embedded/sidecar_dev.go similarity index 100% rename from packages/go/driver/internal/embedded/sidecar_dev.go rename to packages/go/driver/internal/typescript/embedded/sidecar_dev.go diff --git a/packages/go/driver/internal/embedded/sidecar_linux_amd64.go b/packages/go/driver/internal/typescript/embedded/sidecar_linux_amd64.go similarity index 100% rename from packages/go/driver/internal/embedded/sidecar_linux_amd64.go rename to packages/go/driver/internal/typescript/embedded/sidecar_linux_amd64.go diff --git a/packages/go/driver/internal/embedded/sidecar_linux_arm64.go b/packages/go/driver/internal/typescript/embedded/sidecar_linux_arm64.go similarity index 100% rename from packages/go/driver/internal/embedded/sidecar_linux_arm64.go rename to packages/go/driver/internal/typescript/embedded/sidecar_linux_arm64.go diff --git a/packages/go/driver/internal/typescript/filetypes/filetypes.go b/packages/go/driver/internal/typescript/filetypes/filetypes.go new file mode 100644 index 0000000..e28e674 --- /dev/null +++ b/packages/go/driver/internal/typescript/filetypes/filetypes.go @@ -0,0 +1,44 @@ +// Package filetypes is the extension taxonomy that decides which files the +// formatter and linter own. It is pure path classification — no git, no +// filesystem — so callers can filter a discovered file list without any I/O. +package filetypes + +import ( + "slices" + "strings" +) + +// Filter classifies paths by extension. IncludeDeclarations, when set, keeps +// .d.ts declaration files that would otherwise be dropped. +type Filter struct { + IncludeDeclarations bool +} + +// targetSuffixes are the extensions the sidecar knows how to format. The .ts +// entry also covers .d.ts; whether declarations are kept is decided separately. +var targetSuffixes = []string{".ts", ".vue", ".html", ".htm", ".md", ".markdown"} + +// Formattable reports whether path is one the formatter owns: the TS and Vue +// families plus the HTML and Markdown documents whose embedded scripts get +// formatted. +func (f Filter) Formattable(path string) bool { + matched := slices.ContainsFunc(targetSuffixes, func(suffix string) bool { + return strings.HasSuffix(path, suffix) + }) + + if !matched { + return false + } + + return f.IncludeDeclarations || !strings.HasSuffix(path, ".d.ts") +} + +// Lintable reports whether oxlint can lint path: the TS family (minus +// declarations unless IncludeDeclarations) and Vue, but not HTML or Markdown. +func (f Filter) Lintable(path string) bool { + if !strings.HasSuffix(path, ".ts") && !strings.HasSuffix(path, ".vue") { + return false + } + + return f.IncludeDeclarations || !strings.HasSuffix(path, ".d.ts") +} diff --git a/packages/go/driver/internal/typescript/filetypes/filetypes_test.go b/packages/go/driver/internal/typescript/filetypes/filetypes_test.go new file mode 100644 index 0000000..8c75405 --- /dev/null +++ b/packages/go/driver/internal/typescript/filetypes/filetypes_test.go @@ -0,0 +1,59 @@ +package filetypes + +import "testing" + +func TestFormattable(t *testing.T) { + cases := []struct { + name string + path string + includeDeclarations bool + want bool + }{ + {"typescript is formattable", "src/app.ts", false, true}, + {"vue is formattable", "src/component.vue", false, true}, + {"html is formattable", "src/index.html", false, true}, + {"htm is formattable", "src/index.htm", false, true}, + {"markdown md is formattable", "docs/notes.md", false, true}, + {"markdown long form is formattable", "docs/readme.markdown", false, true}, + {"unknown extensions are skipped", "src/app.go", false, false}, + {"declarations drop by default", "src/types.d.ts", false, false}, + {"declarations kept when requested", "src/types.d.ts", true, true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := Filter{IncludeDeclarations: tc.includeDeclarations} + + if got := f.Formattable(tc.path); got != tc.want { + t.Fatalf("Formattable(%q) = %v, want %v", tc.path, got, tc.want) + } + }) + } +} + +func TestLintable(t *testing.T) { + cases := []struct { + name string + path string + includeDeclarations bool + want bool + }{ + {"typescript is lintable", "src/app.ts", false, true}, + {"vue is lintable", "src/component.vue", false, true}, + {"html is not lintable", "src/index.html", false, false}, + {"markdown is not lintable", "docs/notes.md", false, false}, + {"unknown extensions are skipped", "src/app.go", false, false}, + {"declarations drop by default", "src/types.d.ts", false, false}, + {"declarations kept when requested", "src/types.d.ts", true, true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := Filter{IncludeDeclarations: tc.includeDeclarations} + + if got := f.Lintable(tc.path); got != tc.want { + t.Fatalf("Lintable(%q) = %v, want %v", tc.path, got, tc.want) + } + }) + } +} diff --git a/packages/go/driver/internal/sourcefiles/prettierignore.go b/packages/go/driver/internal/typescript/prettierignore/prettierignore.go similarity index 65% rename from packages/go/driver/internal/sourcefiles/prettierignore.go rename to packages/go/driver/internal/typescript/prettierignore/prettierignore.go index 1b9a89c..90b9cb7 100644 --- a/packages/go/driver/internal/sourcefiles/prettierignore.go +++ b/packages/go/driver/internal/typescript/prettierignore/prettierignore.go @@ -1,18 +1,22 @@ -package sourcefiles +// Package prettierignore matches repo-relative paths against a project's +// .prettierignore file using gitignore semantics, and filters absolute file +// lists by it. +package prettierignore import ( "os" + "path/filepath" "regexp" "strings" ) -// prettierIgnore matches repo-relative paths against a parsed .prettierignore -// file using gitignore semantics. Supported constructs: comments (#), blank -// lines, negation (!, last match wins), leading-/ anchoring, trailing-/ -// directory patterns, and the * ? [...] and ** wildcards. Exotic constructs — -// escaped leading #/! (\# and \!) and trailing-space escapes — are not -// supported: a leading # or ! is always read as a comment or a negation. -type prettierIgnore struct { +// Matcher matches repo-relative paths against a parsed .prettierignore file +// using gitignore semantics. Supported constructs: comments (#), blank lines, +// negation (!, last match wins), leading-/ anchoring, trailing-/ directory +// patterns, and the * ? [...] and ** wildcards. Exotic constructs — escaped +// leading #/! (\# and \!) and trailing-space escapes — are not supported: a +// leading # or ! is always read as a comment or a negation. +type Matcher struct { patterns []ignorePattern } @@ -23,10 +27,10 @@ type ignorePattern struct { dirOnly bool } -// loadPrettierIgnore reads the .prettierignore at path and compiles it. It -// returns nil (and no error) when the file is absent, so callers can treat "no -// file" as "nothing filtered". -func loadPrettierIgnore(path string) (*prettierIgnore, error) { +// Load reads the .prettierignore at path and compiles it. It returns nil (and +// no error) when the file is absent, so callers can treat "no file" as "nothing +// filtered". +func Load(path string) (*Matcher, error) { data, err := os.ReadFile(path) if err != nil { @@ -37,21 +41,47 @@ func loadPrettierIgnore(path string) (*prettierIgnore, error) { return nil, err } - return compilePrettierIgnore(data), nil + return Compile(data), nil } -// compilePrettierIgnore parses raw .prettierignore bytes into matchable -// patterns, skipping blank lines, comments, and lines that fail to compile. -func compilePrettierIgnore(data []byte) *prettierIgnore { - ignore := &prettierIgnore{} +// Compile parses raw .prettierignore bytes into matchable patterns, skipping +// blank lines, comments, and lines that fail to compile. +func Compile(data []byte) *Matcher { + matcher := &Matcher{} - for _, line := range strings.Split(string(data), "\n") { + for line := range strings.SplitSeq(string(data), "\n") { if pattern, ok := compilePattern(line); ok { - ignore.patterns = append(ignore.patterns, pattern) + matcher.patterns = append(matcher.patterns, pattern) } } - return ignore + return matcher +} + +// FilterAbs drops any path in files that the matcher excludes, treating each +// path as relative to root. Paths outside root are kept untouched: +// .prettierignore is anchored to the directory that holds it and cannot speak +// to files above it. +func (m *Matcher) FilterAbs(root string, files []string) ([]string, error) { + kept := make([]string, 0, len(files)) + + for _, path := range files { + rel, err := filepath.Rel(root, path) + + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + kept = append(kept, path) + + continue + } + + if m.Ignores(filepath.ToSlash(rel)) { + continue + } + + kept = append(kept, path) + } + + return kept, nil } // compilePattern turns one raw line into an ignorePattern, reporting false for @@ -108,13 +138,13 @@ func compilePattern(line string) (ignorePattern, bool) { }, true } -// ignores reports whether rel — a slash-separated path relative to the ignore +// Ignores reports whether rel — a slash-separated path relative to the ignore // file's directory — is excluded. Later matches win, so a negation can // re-include a path an earlier pattern excluded. -func (p *prettierIgnore) ignores(rel string) bool { +func (m *Matcher) Ignores(rel string) bool { ignored := false - for _, pattern := range p.patterns { + for _, pattern := range m.patterns { if pattern.matches(rel) { ignored = !pattern.negated } diff --git a/packages/go/driver/internal/typescript/prettierignore/prettierignore_test.go b/packages/go/driver/internal/typescript/prettierignore/prettierignore_test.go new file mode 100644 index 0000000..cf45efa --- /dev/null +++ b/packages/go/driver/internal/typescript/prettierignore/prettierignore_test.go @@ -0,0 +1,128 @@ +package prettierignore + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestMatcherIgnores(t *testing.T) { + cases := []struct { + name string + ignore string + path string + excluded bool + }{ + {"blank and comment lines are inert", "\n# comment\n", "app.ts", false}, + {"plain name matches at root", "app.ts\n", "app.ts", true}, + {"plain name matches at any depth", "app.ts\n", "src/nested/app.ts", true}, + {"plain name does not match a different file", "app.ts\n", "app.tsx", false}, + {"leading slash anchors to root", "/app.ts\n", "app.ts", true}, + {"leading slash rejects nested", "/app.ts\n", "src/app.ts", false}, + {"trailing slash matches under a directory", "dist/\n", "dist/app.ts", true}, + {"trailing slash does not match a file of that name", "dist/\n", "dist", false}, + {"star stays within a segment", "*.ts\n", "src/app.ts", true}, + {"star does not cross a slash", "src/*.ts\n", "src/nested/app.ts", false}, + {"question matches one char", "app.?s\n", "app.ts", true}, + {"question needs exactly one char", "app.?s\n", "app.tss", false}, + {"character class matches a member", "app.[jt]s\n", "app.ts", true}, + {"character class rejects a non-member", "app.[jt]s\n", "app.xs", false}, + {"negated class rejects a member", "app.[!t]s\n", "app.ts", false}, + {"negated class matches a non-member", "app.[!t]s\n", "app.js", true}, + {"double star spans segments", "src/**/app.ts\n", "src/a/b/app.ts", true}, + {"double star spans zero segments", "src/**/app.ts\n", "src/app.ts", true}, + {"trailing double star matches everything below", "logs/**\n", "logs/a/b.txt", true}, + {"leading double star matches at any depth", "**/gen.ts\n", "a/b/gen.ts", true}, + {"excluding a directory excludes its contents", "build\n", "build/x/y.ts", true}, + {"a similarly named directory is untouched", "build\n", "prebuild/x.ts", false}, + {"negation re-includes a previously excluded file", "*.ts\n!keep.ts\n", "keep.ts", false}, + {"order matters: re-exclude after negation", "*.ts\n!keep.ts\nkeep.ts\n", "keep.ts", true}, + {"unclosed bracket is a literal", "a[b.ts\n", "a[b.ts", true}, + {"invalid character class is skipped without panicking", "[z-a]\napp.ts\n", "app.ts", true}, + {"invalid character class does not match its own line", "[z-a]\n", "z", false}, + {"crlf line trims the carriage return before spaces", "foo \r\n", "foo", true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + matcher := Compile([]byte(tc.ignore)) + + if got := matcher.Ignores(tc.path); got != tc.excluded { + t.Fatalf("Ignores(%q) = %v, want %v", tc.path, got, tc.excluded) + } + }) + } +} + +func TestLoadAbsentFile(t *testing.T) { + matcher, err := Load(filepath.Join(t.TempDir(), ".prettierignore")) + + if err != nil { + t.Fatalf("Load: %v", err) + } + + if matcher != nil { + t.Fatalf("expected nil matcher for absent file, got %#v", matcher) + } +} + +func TestLoadCompilesPresentFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".prettierignore") + + if err := os.WriteFile(path, []byte("dist/\n"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + matcher, err := Load(path) + + if err != nil { + t.Fatalf("Load: %v", err) + } + + if matcher == nil || !matcher.Ignores("dist/bundle.ts") { + t.Fatalf("expected the loaded matcher to honour dist/, got %#v", matcher) + } +} + +func TestLoadSurfacesReadErrors(t *testing.T) { + dir := t.TempDir() + + // A directory named .prettierignore is not IsNotExist, so its read error must + // surface rather than being swallowed as "no file". + if err := os.Mkdir(filepath.Join(dir, ".prettierignore"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + if _, err := Load(filepath.Join(dir, ".prettierignore")); err == nil { + t.Fatal("expected an error reading a directory as .prettierignore") + } +} + +func TestFilterAbsDropsIgnoredAndKeepsOutsiders(t *testing.T) { + root := filepath.Join(string(filepath.Separator), "project", "root") + matcher := Compile([]byte("dist/\n")) + + files := []string{ + filepath.Join(root, "src", "app.ts"), + filepath.Join(root, "dist", "bundle.ts"), + // Above root: .prettierignore cannot speak to it, so it is kept untouched. + filepath.Join(string(filepath.Separator), "project", "sibling.ts"), + } + + kept, err := matcher.FilterAbs(root, files) + + if err != nil { + t.Fatalf("FilterAbs: %v", err) + } + + want := []string{ + filepath.Join(root, "src", "app.ts"), + filepath.Join(string(filepath.Separator), "project", "sibling.ts"), + } + + if !reflect.DeepEqual(kept, want) { + t.Fatalf("kept mismatch\nwant: %#v\n got: %#v", want, kept) + } +} diff --git a/packages/go/driver/internal/typescript/proto/command.go b/packages/go/driver/internal/typescript/proto/command.go new file mode 100644 index 0000000..3a398bc --- /dev/null +++ b/packages/go/driver/internal/typescript/proto/command.go @@ -0,0 +1,83 @@ +package proto + +// The command types below build the exact argument vectors each sidecar mode +// expects. The bin resolution (which executable to spawn) is the caller's +// concern; these types own only the argv the sidecar itself parses. + +// PipelineCommand describes a full-pipeline invocation. OxfmtBin is the +// already-resolved oxfmt executable the sidecar shells out to, and OxfmtConfig +// is the resolved config path, or "" to let oxfmt auto-discover. +type PipelineCommand struct { + OxfmtBin string + OxfmtConfig string + FormatFiles []string + SyntaxFiles []string +} + +// OxlintCommand describes an oxlint invocation. ViaSidecar is set when the +// sidecar dispatches oxlint (and so must be told the mode); a direct OXLINT_BIN +// override clears it. Config is the resolved config path, or "" for +// auto-discovery. +type OxlintCommand struct { + ViaSidecar bool + Fix bool + Config string + Files []string +} + +// MigrateCommand describes an `oxfmt --migrate=prettier` invocation. ViaSidecar +// is set when the sidecar dispatches oxfmt; a direct OXFMT_BIN override clears +// it. +type MigrateCommand struct { + ViaSidecar bool +} + +// Argv returns the pipeline mode's argument vector. +func (c PipelineCommand) Argv() []string { + args := []string{ModePipeline} + + args = append(args, "--oxfmt-bin", c.OxfmtBin) + + if c.OxfmtConfig != "" { + args = append(args, "--oxfmt-config", c.OxfmtConfig) + } + + args = append(args, "--format-files") + args = append(args, c.FormatFiles...) + args = append(args, "--syntax-files") + args = append(args, c.SyntaxFiles...) + + return args +} + +// Argv returns oxlint's argument vector. +func (c OxlintCommand) Argv() []string { + var args []string + + if c.ViaSidecar { + args = append(args, ModeOxlint) + } + + if c.Fix { + args = append(args, "--fix") + } + + if c.Config != "" { + args = append(args, "--config", c.Config) + } + + args = append(args, c.Files...) + + return args +} + +// Argv returns the migration argument vector. +func (c MigrateCommand) Argv() (args []string) { + if c.ViaSidecar { + args = append(args, ModeOxfmt) + } + + args = append(args, "--migrate=prettier") + + return args +} diff --git a/packages/go/driver/internal/typescript/proto/command_test.go b/packages/go/driver/internal/typescript/proto/command_test.go new file mode 100644 index 0000000..aca383d --- /dev/null +++ b/packages/go/driver/internal/typescript/proto/command_test.go @@ -0,0 +1,135 @@ +package proto + +import ( + "reflect" + "testing" +) + +func TestPipelineCommandArgv(t *testing.T) { + cmd := PipelineCommand{ + OxfmtBin: "/tools/fmtkit-ts-sidecar", + OxfmtConfig: "/cfg/.oxfmtrc.json", + FormatFiles: []string{"/work/app.ts", "/work/types.ts"}, + SyntaxFiles: []string{"/work/app.ts", "/work/decl.d.ts", "/work/types.ts"}, + } + + want := []string{ + "pipeline", + "--oxfmt-bin", "/tools/fmtkit-ts-sidecar", + "--oxfmt-config", "/cfg/.oxfmtrc.json", + "--format-files", + "/work/app.ts", "/work/types.ts", + "--syntax-files", + "/work/app.ts", "/work/decl.d.ts", "/work/types.ts", + } + + if got := cmd.Argv(); !reflect.DeepEqual(got, want) { + t.Fatalf("Argv() = %q, want %q", got, want) + } +} + +func TestPipelineCommandArgvOmitsEmptyConfig(t *testing.T) { + cmd := PipelineCommand{ + OxfmtBin: "/tools/fmtkit-ts-sidecar", + FormatFiles: []string{"/work/app.ts"}, + SyntaxFiles: []string{"/work/app.ts"}, + } + + want := []string{ + "pipeline", + "--oxfmt-bin", "/tools/fmtkit-ts-sidecar", + "--format-files", + "/work/app.ts", + "--syntax-files", + "/work/app.ts", + } + + if got := cmd.Argv(); !reflect.DeepEqual(got, want) { + t.Fatalf("Argv() = %q, want %q", got, want) + } +} + +func TestPipelineCommandArgvAlwaysCarriesFileSentinels(t *testing.T) { + // Even with no files, the --format-files/--syntax-files markers are present + // so the sidecar's parser sees empty lists rather than a missing section. + cmd := PipelineCommand{OxfmtBin: "sidecar"} + + want := []string{ + "pipeline", + "--oxfmt-bin", "sidecar", + "--format-files", + "--syntax-files", + } + + if got := cmd.Argv(); !reflect.DeepEqual(got, want) { + t.Fatalf("Argv() = %q, want %q", got, want) + } +} + +func TestOxlintCommandArgvViaSidecar(t *testing.T) { + cmd := OxlintCommand{ + ViaSidecar: true, + Config: "/cfg/.oxlintrc.json", + Files: []string{"/work/app.ts"}, + } + + want := []string{ + "oxlint", + "--config", "/cfg/.oxlintrc.json", + "/work/app.ts", + } + + if got := cmd.Argv(); !reflect.DeepEqual(got, want) { + t.Fatalf("Argv() = %q, want %q", got, want) + } +} + +func TestOxlintCommandArgvWithFix(t *testing.T) { + cmd := OxlintCommand{ + ViaSidecar: true, + Fix: true, + Config: "/cfg/.oxlintrc.json", + Files: []string{"/work/app.ts"}, + } + + want := []string{ + "oxlint", + "--fix", + "--config", "/cfg/.oxlintrc.json", + "/work/app.ts", + } + + if got := cmd.Argv(); !reflect.DeepEqual(got, want) { + t.Fatalf("Argv() = %q, want %q", got, want) + } +} + +func TestOxlintCommandArgvDirectBinOmitsMode(t *testing.T) { + // A direct OXLINT_BIN override runs oxlint without the sidecar's mode word. + cmd := OxlintCommand{ + ViaSidecar: false, + Files: []string{"/work/app.ts"}, + } + + want := []string{"/work/app.ts"} + + if got := cmd.Argv(); !reflect.DeepEqual(got, want) { + t.Fatalf("Argv() = %q, want %q", got, want) + } +} + +func TestMigrateCommandArgvViaSidecar(t *testing.T) { + want := []string{"oxfmt", "--migrate=prettier"} + + if got := (MigrateCommand{ViaSidecar: true}).Argv(); !reflect.DeepEqual(got, want) { + t.Fatalf("Argv() = %q, want %q", got, want) + } +} + +func TestMigrateCommandArgvDirectBin(t *testing.T) { + want := []string{"--migrate=prettier"} + + if got := (MigrateCommand{ViaSidecar: false}).Argv(); !reflect.DeepEqual(got, want) { + t.Fatalf("Argv() = %q, want %q", got, want) + } +} diff --git a/packages/go/driver/internal/typescript/proto/sidecarproto.go b/packages/go/driver/internal/typescript/proto/sidecarproto.go new file mode 100644 index 0000000..1066382 --- /dev/null +++ b/packages/go/driver/internal/typescript/proto/sidecarproto.go @@ -0,0 +1,88 @@ +// Package proto is the single source of truth for the stringly-typed +// wire protocol between the Go driver and the bun-compiled TS sidecar: the +// asset filenames, the sidecar's dispatch modes, the environment variables that +// override toolchain resolution, the exact argument vectors each mode expects, +// and the summary lines the sidecar prints back. +// +// Every value here is frozen byte-for-byte: the TS sidecar parses these argv +// forms and reads these environment names, and CI's smoke test plus the Go +// fake-bin tests prove both ends agree. Change a constant here only in lockstep +// with packages/ts/sidecar. +package proto + +import "os" + +// Overrides carries every environment override a TS toolchain invocation +// honours, resolved once rather than ad hoc deep in the call paths. +type Overrides struct { + PipelineBin string + OxfmtBin string + OxlintBin string + OxfmtConfig string + OxlintConfig string + SourcesCwd string +} + +// Asset filenames staged alongside the sidecar and read by both ends. +const ( + // SidecarName is the multiplexed toolchain executable's filename. + SidecarName = "fmtkit-ts-sidecar" + + // OxfmtRCName is the bundled oxfmt configuration filename. + OxfmtRCName = ".oxfmtrc.json" + + // OxlintRCName is the bundled oxlint configuration filename. + OxlintRCName = ".oxlintrc.json" +) + +// Dispatch modes: the sidecar selects a toolchain by its first positional +// argument (process.argv[2]) or, equivalently, by SidecarModeEnv. +const ( + ModePipeline = "pipeline" + ModeOxfmt = "oxfmt" + ModeOxlint = "oxlint" +) + +// Environment variable names that cross the Go/TS boundary or steer toolchain +// resolution. These are the complete set the driver honours; ReadOverrides is +// the only place the process environment is consulted for the override subset. +const ( + // SupportDirEnv points at a pre-extracted toolchain directory, skipping + // both the embedded assets and the per-version cache. + SupportDirEnv = "FMTKIT_SUPPORT_DIR" + + // SidecarModeEnv is the sidecar's alternate mode selector, read by the TS + // entrypoint when no positional mode is supplied. + SidecarModeEnv = "FMTKIT_SIDECAR_MODE" + + // PipelineBinEnv overrides the executable spawned for the pipeline mode. + PipelineBinEnv = "FMTKIT_TS_PIPELINE_BIN" + + // OxfmtBinEnv runs oxfmt directly instead of through the sidecar. + OxfmtBinEnv = "OXFMT_BIN" + + // OxlintBinEnv runs oxlint directly instead of through the sidecar. + OxlintBinEnv = "OXLINT_BIN" + + // OxfmtConfigEnv forces a specific oxfmt configuration path. + OxfmtConfigEnv = "FMTKIT_OXFMTRC" + + // OxlintConfigEnv forces a specific oxlint configuration path. + OxlintConfigEnv = "FMTKIT_OXLINTRC" + + // SourcesCwdEnv overrides the working directory file collection runs in. + SourcesCwdEnv = "FMTKIT_SOURCES_CWD" +) + +// ReadOverrides gathers every environment override in one place. It is the sole +// os.Getenv site for the override variables above. +func ReadOverrides() Overrides { + return Overrides{ + PipelineBin: os.Getenv(PipelineBinEnv), + OxfmtBin: os.Getenv(OxfmtBinEnv), + OxlintBin: os.Getenv(OxlintBinEnv), + OxfmtConfig: os.Getenv(OxfmtConfigEnv), + OxlintConfig: os.Getenv(OxlintConfigEnv), + SourcesCwd: os.Getenv(SourcesCwdEnv), + } +} diff --git a/packages/go/driver/internal/typescript/proto/sidecarproto_test.go b/packages/go/driver/internal/typescript/proto/sidecarproto_test.go new file mode 100644 index 0000000..d5f3a8a --- /dev/null +++ b/packages/go/driver/internal/typescript/proto/sidecarproto_test.go @@ -0,0 +1,83 @@ +package proto + +import "testing" + +// TestWireConstantsAreFrozen pins every wire value byte-for-byte. The TS sidecar +// parses these; a change here that is not mirrored in packages/ts/sidecar breaks +// compatibility silently, so this test is the tripwire. +func TestWireConstantsAreFrozen(t *testing.T) { + cases := map[string]string{ + "SidecarName": SidecarName, + "OxfmtRCName": OxfmtRCName, + "OxlintRCName": OxlintRCName, + "ModePipeline": ModePipeline, + "ModeOxfmt": ModeOxfmt, + "ModeOxlint": ModeOxlint, + "SupportDirEnv": SupportDirEnv, + "SidecarModeEnv": SidecarModeEnv, + "PipelineBinEnv": PipelineBinEnv, + "OxfmtBinEnv": OxfmtBinEnv, + "OxlintBinEnv": OxlintBinEnv, + "OxfmtConfigEnv": OxfmtConfigEnv, + "OxlintConfigEnv": OxlintConfigEnv, + "SourcesCwdEnv": SourcesCwdEnv, + } + + want := map[string]string{ + "SidecarName": "fmtkit-ts-sidecar", + "OxfmtRCName": ".oxfmtrc.json", + "OxlintRCName": ".oxlintrc.json", + "ModePipeline": "pipeline", + "ModeOxfmt": "oxfmt", + "ModeOxlint": "oxlint", + "SupportDirEnv": "FMTKIT_SUPPORT_DIR", + "SidecarModeEnv": "FMTKIT_SIDECAR_MODE", + "PipelineBinEnv": "FMTKIT_TS_PIPELINE_BIN", + "OxfmtBinEnv": "OXFMT_BIN", + "OxlintBinEnv": "OXLINT_BIN", + "OxfmtConfigEnv": "FMTKIT_OXFMTRC", + "OxlintConfigEnv": "FMTKIT_OXLINTRC", + "SourcesCwdEnv": "FMTKIT_SOURCES_CWD", + } + + for name, got := range cases { + if got != want[name] { + t.Errorf("%s = %q, want %q", name, got, want[name]) + } + } +} + +func TestReadOverridesReadsEveryVar(t *testing.T) { + t.Setenv(PipelineBinEnv, "/bin/pipeline") + t.Setenv(OxfmtBinEnv, "/bin/oxfmt") + t.Setenv(OxlintBinEnv, "/bin/oxlint") + t.Setenv(OxfmtConfigEnv, "/cfg/oxfmt.json") + t.Setenv(OxlintConfigEnv, "/cfg/oxlint.json") + t.Setenv(SourcesCwdEnv, "/work") + + want := Overrides{ + PipelineBin: "/bin/pipeline", + OxfmtBin: "/bin/oxfmt", + OxlintBin: "/bin/oxlint", + OxfmtConfig: "/cfg/oxfmt.json", + OxlintConfig: "/cfg/oxlint.json", + SourcesCwd: "/work", + } + + if got := ReadOverrides(); got != want { + t.Fatalf("ReadOverrides() = %+v, want %+v", got, want) + } +} + +func TestReadOverridesDefaultsToEmpty(t *testing.T) { + for _, name := range []string{ + PipelineBinEnv, OxfmtBinEnv, OxlintBinEnv, + OxfmtConfigEnv, OxlintConfigEnv, SourcesCwdEnv, + } { + t.Setenv(name, "") + } + + if got := ReadOverrides(); got != (Overrides{}) { + t.Fatalf("ReadOverrides() = %+v, want zero value", got) + } +} diff --git a/packages/go/driver/internal/typescript/proto/summary.go b/packages/go/driver/internal/typescript/proto/summary.go new file mode 100644 index 0000000..daaecff --- /dev/null +++ b/packages/go/driver/internal/typescript/proto/summary.go @@ -0,0 +1,112 @@ +package proto + +import ( + "regexp" + "strings" +) + +// PipelineSummary holds the sidecar's pipeline progress, each field carrying the +// detail text the caller shows (already stripped of its scrape prefix, except +// Oxfmt which is oxfmt's own full line). Empty fields mean the sidecar printed +// no such line. +type PipelineSummary struct { + // BlankLines is the last "[blank-lines] processed ..." line, without its + // "[blank-lines] " prefix. + BlankLines string + + // Oxfmt is the last "Finished in ..." line oxfmt printed, verbatim. + Oxfmt string + + // FluentChains is the last "[fluent-chains] processed ..." line, without its + // "[fluent-chains] " prefix. + FluentChains string + + // ValidateSyntax is the last "[validate-syntax] checked ..." line, without + // its "[validate-syntax] " prefix. + ValidateSyntax string +} + +// LintSummary holds the sidecar's oxlint result line. +type LintSummary struct { + // Result is the last line matching oxlint's summary pattern, verbatim, or + // "" when the log carries none. + Result string +} + +// The sidecar prints progress lines the driver scrapes into a step summary. +// These prefixes and the oxlint result pattern are the sidecar's output +// contract; only the lines the TS toolchain itself emits live here. Lines the +// Go driver prints about its own bookkeeping (source-collection warnings, the +// no-files notice, the Go formatter report) stay with the pipeline steps. +const ( + blankLinesMatch = "[blank-lines] processed " + blankLinesTrim = "[blank-lines] " + + oxfmtFinishedMatch = "Finished in " + + fluentChainsMatch = "[fluent-chains] processed " + fluentChainsTrim = "[fluent-chains] " + + validateSyntaxMatch = "[validate-syntax] checked " + validateSyntaxTrim = "[validate-syntax] " +) + +// lintResultPattern matches oxlint's summary line, e.g. +// "Found 0 warnings and 0 errors." +var lintResultPattern = regexp.MustCompile(`Found [0-9]+ warning|[0-9]+ error`) + +// ParsePipelineSummary scrapes the sidecar's pipeline output, taking the last +// occurrence of each progress line. +func ParsePipelineSummary(log string) PipelineSummary { + logLines := lines(log) + + summary := PipelineSummary{} + + if line := lastWithPrefix(logLines, blankLinesMatch); line != "" { + summary.BlankLines = strings.TrimPrefix(line, blankLinesTrim) + } + + if line := lastWithPrefix(logLines, oxfmtFinishedMatch); line != "" { + summary.Oxfmt = line + } + + if line := lastWithPrefix(logLines, fluentChainsMatch); line != "" { + summary.FluentChains = strings.TrimPrefix(line, fluentChainsTrim) + } + + if line := lastWithPrefix(logLines, validateSyntaxMatch); line != "" { + summary.ValidateSyntax = strings.TrimPrefix(line, validateSyntaxTrim) + } + + return summary +} + +// ParseLintSummary scrapes the sidecar's oxlint output, taking the last line +// matching oxlint's summary pattern. +func ParseLintSummary(log string) LintSummary { + var match string + + for _, line := range lines(log) { + if lintResultPattern.MatchString(line) { + match = line + } + } + + return LintSummary{Result: match} +} + +func lines(log string) []string { + return strings.Split(log, "\n") +} + +func lastWithPrefix(logLines []string, prefix string) string { + var match string + + for _, line := range logLines { + if strings.HasPrefix(line, prefix) { + match = line + } + } + + return match +} diff --git a/packages/go/driver/internal/typescript/proto/summary_test.go b/packages/go/driver/internal/typescript/proto/summary_test.go new file mode 100644 index 0000000..abbf1b6 --- /dev/null +++ b/packages/go/driver/internal/typescript/proto/summary_test.go @@ -0,0 +1,58 @@ +package proto + +import "testing" + +// sampleTSOutput mirrors the sidecar's pipeline stdout, lifted from the +// pipeline's fake-tool fixtures. +const sampleTSOutput = "[blank-lines] processed 3 file(s) in /work, 0 changed\n" + + "Finished in 10ms on 3 files using 8 threads.\n" + + "[fluent-chains] processed 3 file(s) in /work, 1 changed\n" + + "[validate-syntax] checked 3 file(s), all valid\n" + +func TestParsePipelineSummary(t *testing.T) { + got := ParsePipelineSummary(sampleTSOutput) + + want := PipelineSummary{ + BlankLines: "processed 3 file(s) in /work, 0 changed", + Oxfmt: "Finished in 10ms on 3 files using 8 threads.", + FluentChains: "processed 3 file(s) in /work, 1 changed", + ValidateSyntax: "checked 3 file(s), all valid", + } + + if got != want { + t.Fatalf("ParsePipelineSummary() = %+v, want %+v", got, want) + } +} + +func TestParsePipelineSummaryTakesLastOccurrence(t *testing.T) { + log := "[blank-lines] processed 1 file(s) in /work, 0 changed\n" + + "[blank-lines] processed 2 file(s) in /work, 1 changed\n" + + if got := ParsePipelineSummary(log).BlankLines; got != "processed 2 file(s) in /work, 1 changed" { + t.Fatalf("BlankLines = %q, want the last occurrence", got) + } +} + +func TestParsePipelineSummaryEmptyLog(t *testing.T) { + if got := ParsePipelineSummary(""); got != (PipelineSummary{}) { + t.Fatalf("ParsePipelineSummary(\"\") = %+v, want zero value", got) + } +} + +func TestParseLintSummary(t *testing.T) { + if got := ParseLintSummary("Found 0 warnings and 0 errors.\n").Result; got != "Found 0 warnings and 0 errors." { + t.Fatalf("Result = %q, want the oxlint summary line", got) + } +} + +func TestParseLintSummaryMatchesErrorLine(t *testing.T) { + if got := ParseLintSummary("noise\nFound 2 warnings and 1 error.\n").Result; got != "Found 2 warnings and 1 error." { + t.Fatalf("Result = %q, want the matching line", got) + } +} + +func TestParseLintSummaryNoMatch(t *testing.T) { + if got := ParseLintSummary("nothing interesting\n").Result; got != "" { + t.Fatalf("Result = %q, want empty when no summary line", got) + } +} diff --git a/packages/go/driver/internal/tsruntime/support.go b/packages/go/driver/internal/typescript/runtime/assets.go similarity index 69% rename from packages/go/driver/internal/tsruntime/support.go rename to packages/go/driver/internal/typescript/runtime/assets.go index 081f783..637ecc2 100644 --- a/packages/go/driver/internal/tsruntime/support.go +++ b/packages/go/driver/internal/typescript/runtime/assets.go @@ -1,8 +1,13 @@ -// Package tsruntime manages the self-contained TS toolchain shipped inside +// Package runtime manages the self-contained TS toolchain shipped inside // release binaries: a bun-compiled sidecar plus the oxc-parser, oxfmt, and // oxlint napi bindings. On first use the embedded assets are extracted to a // per-version cache directory and spawned as child processes from there. -package tsruntime +// +// The type split mirrors the three responsibilities: Assets owns the extracted +// directory (extraction, caching, lookup); Invoker spawns the toolchain; and +// PrettierMigration derives an oxfmt config from a project's Prettier setup. All +// argv and environment construction goes through the proto package. +package runtime import ( "crypto/sha256" @@ -13,41 +18,36 @@ import ( "io/fs" "os" "path/filepath" - "sort" + "slices" - "go.ollin.sh/fmtkit/driver/internal/embedded" + "go.ollin.sh/fmtkit/driver/internal/typescript/embedded" + "go.ollin.sh/fmtkit/driver/internal/typescript/proto" ) -// SupportDirEnv points at a pre-extracted toolchain directory and skips -// both the embedded assets and the cache. - -// Support locates the extracted TS toolchain on disk. -type Support struct { +// Assets locates the extracted TS toolchain on disk. +type Assets struct { Dir string } -const ( - SupportDirEnv = "FMTKIT_SUPPORT_DIR" - - sidecarName = "fmtkit-ts-sidecar" - sentinelName = ".fmtkit-complete" -) +// sentinelName marks a completed extraction; it is the runtime's own +// bookkeeping, not part of the sidecar wire protocol. +const sentinelName = ".fmtkit-complete" // Sidecar returns the path of the multiplexed toolchain executable. -func (s Support) Sidecar() string { - return filepath.Join(s.Dir, sidecarName) +func (a Assets) Sidecar() string { + return filepath.Join(a.Dir, proto.SidecarName) } // OxfmtConfig returns the bundled oxfmt configuration path, or "" when the // support directory carries none. -func (s Support) OxfmtConfig() string { - return existingFile(filepath.Join(s.Dir, ".oxfmtrc.json")) +func (a Assets) OxfmtConfig() string { + return existingFile(filepath.Join(a.Dir, proto.OxfmtRCName)) } // OxlintConfig returns the bundled oxlint configuration path, or "" when the // support directory carries none. -func (s Support) OxlintConfig() string { - return existingFile(filepath.Join(s.Dir, ".oxlintrc.json")) +func (a Assets) OxlintConfig() string { + return existingFile(filepath.Join(a.Dir, proto.OxlintRCName)) } func existingFile(path string) string { @@ -61,32 +61,32 @@ func existingFile(path string) string { // Resolve locates the TS toolchain, extracting the embedded assets into the // user cache on first use. version tells extractions of different releases // apart; dev builds derive a digest from the assets instead. -func Resolve(version string) (Support, error) { - if dir := os.Getenv(SupportDirEnv); dir != "" { - support := Support{Dir: dir} +func Resolve(version string) (Assets, error) { + if dir := os.Getenv(proto.SupportDirEnv); dir != "" { + assets := Assets{Dir: dir} - if existingFile(support.Sidecar()) == "" { - return Support{}, fmt.Errorf("%s (%s) does not contain %s", SupportDirEnv, dir, sidecarName) + if existingFile(assets.Sidecar()) == "" { + return Assets{}, fmt.Errorf("%s (%s) does not contain %s", proto.SupportDirEnv, dir, proto.SidecarName) } - return support, nil + return assets, nil } - assets, ok := embedded.SidecarAssets() + embeddedAssets, ok := embedded.SidecarAssets() if !ok { - return Support{}, errors.New( + return Assets{}, errors.New( "this fmtkit build carries no TS toolchain (built without the fmtkit_sidecar tag); " + - "point " + SupportDirEnv + " at a staged toolchain directory " + - "(see packages/ts/infra/stage-ts-assets.sh), or use a release binary", + "point " + proto.SupportDirEnv + " at a staged toolchain directory " + + "(see packages/ts/toolchain/stage-ts-assets.sh), or use a release binary", ) } if version == "" || version == "dev" { - digest, err := assetsDigest(assets) + digest, err := assetsDigest(embeddedAssets) if err != nil { - return Support{}, err + return Assets{}, err } version = "dev-" + digest @@ -95,16 +95,16 @@ func Resolve(version string) (Support, error) { cacheRoot, err := os.UserCacheDir() if err != nil { - return Support{}, fmt.Errorf("resolve user cache dir: %w", err) + return Assets{}, fmt.Errorf("resolve user cache dir: %w", err) } dir := filepath.Join(cacheRoot, "fmtkit", version) - if err := extractOnce(dir, assets); err != nil { - return Support{}, err + if err := extractOnce(dir, embeddedAssets); err != nil { + return Assets{}, err } - return Support{Dir: dir}, nil + return Assets{Dir: dir}, nil } // extractOnce materializes the toolchain into dir unless a completed @@ -160,7 +160,7 @@ func extract(dst string, assets fs.FS) error { mode := os.FileMode(0o644) - if entry.Name() == sidecarName { + if entry.Name() == proto.SidecarName { mode = 0o755 } @@ -219,7 +219,7 @@ func assetsDigest(assets fs.FS) (string, error) { } } - sort.Strings(names) + slices.Sort(names) for _, name := range names { hash.Write([]byte(name)) diff --git a/packages/go/driver/internal/typescript/runtime/invoker.go b/packages/go/driver/internal/typescript/runtime/invoker.go new file mode 100644 index 0000000..82e19c9 --- /dev/null +++ b/packages/go/driver/internal/typescript/runtime/invoker.go @@ -0,0 +1,236 @@ +package runtime + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + + "go.ollin.sh/fmtkit/driver/internal/gitfiles" + "go.ollin.sh/fmtkit/driver/internal/typescript/proto" + "go.ollin.sh/fmtkit/driver/internal/typescript/sourcefiles" +) + +// Request describes one TS toolchain invocation. +type Request struct { + // Scopes are the paths to process, defaulting to ".". + Scopes []string + + // Selection is how much of the working tree to cover within Scopes. It + // defaults to gitfiles.SelectionAll. + Selection gitfiles.Selection + + // Fix, when set, lets RunLint apply oxlint's safe fixes (--fix) rather + // than only reporting violations. + Fix bool + + Stdout io.Writer + Stderr io.Writer +} + +// Invoker spawns the TS toolchain against an extracted Assets directory. It +// resolves the environment overrides once at construction rather than ad hoc +// deep in the call paths. +type Invoker struct { + Assets Assets + Env proto.Overrides +} + +// NewInvoker builds an Invoker for the given assets, reading the environment +// overrides once. +func NewInvoker(a Assets) Invoker { + return Invoker{Assets: a, Env: proto.ReadOverrides()} +} + +// RunPipeline runs the full TS/Vue formatting pipeline (blank-lines -> oxfmt +// -> fluent-chains -> blank-lines -> validate-syntax). oxfmt is an internal +// normalising step, not the last word: the project passes run after it and +// own the final style. +func (i Invoker) RunPipeline(ctx context.Context, req Request) error { + cwd, err := i.sourcesCwd() + + if err != nil { + return err + } + + formatFiles, warnings, err := collect(ctx, cwd, req.Scopes, false, req.Selection) + + if err != nil { + return err + } + + for _, warning := range warnings { + _, _ = fmt.Fprintf(req.Stderr, "[sources] %s\n", warning) + } + + syntaxFiles, _, err := collect(ctx, cwd, req.Scopes, true, req.Selection) + + if err != nil { + return err + } + + oxfmtBin := i.Env.OxfmtBin + + if oxfmtBin == "" { + oxfmtBin = i.Assets.Sidecar() + } + + command := proto.PipelineCommand{ + OxfmtBin: oxfmtBin, + OxfmtConfig: i.oxfmtConfigFor(ctx, cwd, req.Stderr), + FormatFiles: formatFiles, + SyntaxFiles: syntaxFiles, + } + + return i.spawn(ctx, i.pipelineExecutable(), command.Argv(), req) +} + +// RunLint lints the collected TS/Vue files with oxlint. With req.Fix it applies +// oxlint's safe fixes in place; otherwise it only reports violations. +func (i Invoker) RunLint(ctx context.Context, req Request) error { + cwd, err := i.sourcesCwd() + + if err != nil { + return err + } + + files, warnings, err := collectLintable(ctx, cwd, req.Scopes, false, req.Selection) + + if err != nil { + return err + } + + for _, warning := range warnings { + _, _ = fmt.Fprintf(req.Stderr, "[sources] %s\n", warning) + } + + if len(files) == 0 { + _, _ = fmt.Fprintln(req.Stdout, "[lint] no TS/Vue files to lint.") + + return nil + } + + bin := i.Env.OxlintBin + viaSidecar := bin == "" + + if viaSidecar { + bin = i.Assets.Sidecar() + } + + command := proto.OxlintCommand{ + ViaSidecar: viaSidecar, + Fix: req.Fix, + Config: i.oxlintConfigFor(cwd), + Files: files, + } + + return i.spawn(ctx, bin, command.Argv(), req) +} + +// pipelineExecutable resolves the executable spawned for the pipeline: a +// FMTKIT_TS_PIPELINE_BIN override, otherwise the sidecar. +func (i Invoker) pipelineExecutable() string { + if i.Env.PipelineBin != "" { + return i.Env.PipelineBin + } + + return i.Assets.Sidecar() +} + +func (i Invoker) sourcesCwd() (string, error) { + if i.Env.SourcesCwd != "" { + return i.Env.SourcesCwd, nil + } + + cwd, err := os.Getwd() + + if err != nil { + return "", fmt.Errorf("resolve cwd: %w", err) + } + + return cwd, nil +} + +func collect(ctx context.Context, cwd string, scopes []string, includeDeclarations bool, selection gitfiles.Selection) ([]string, []string, error) { + collector, err := sourcefiles.New(cwd, selection, includeDeclarations) + + if err != nil { + return nil, nil, err + } + + return collector.Formattable(ctx, scopes) +} + +func collectLintable(ctx context.Context, cwd string, scopes []string, includeDeclarations bool, selection gitfiles.Selection) ([]string, []string, error) { + collector, err := sourcefiles.New(cwd, selection, includeDeclarations) + + if err != nil { + return nil, nil, err + } + + return collector.Lintable(ctx, scopes) +} + +// oxfmtConfigFor resolves the oxfmt config by precedence: the FMTKIT_OXFMTRC +// override, then a project-local .oxfmtrc.* (via oxfmt's own auto-discovery, +// signalled by ""), then a config derived from the project's Prettier +// configuration, and finally the bundled default. +func (i Invoker) oxfmtConfigFor(ctx context.Context, cwd string, stderr io.Writer) string { + if i.Env.OxfmtConfig != "" { + return existingFile(i.Env.OxfmtConfig) + } + + if matches, err := filepath.Glob(filepath.Join(cwd, ".oxfmtrc.*")); err == nil && len(matches) > 0 { + return "" + } + + if derived := i.migration().DerivedConfig(ctx, cwd, stderr); derived != "" { + return derived + } + + return i.Assets.OxfmtConfig() +} + +// oxlintConfigFor treats both the extensionless .oxlintrc and .oxlintrc.* as +// project configuration. +func (i Invoker) oxlintConfigFor(cwd string) string { + if i.Env.OxlintConfig != "" { + return existingFile(i.Env.OxlintConfig) + } + + if existingFile(filepath.Join(cwd, ".oxlintrc")) != "" { + return "" + } + + if matches, err := filepath.Glob(filepath.Join(cwd, ".oxlintrc.*")); err == nil && len(matches) > 0 { + return "" + } + + return i.Assets.OxlintConfig() +} + +// migration views this invoker as the PrettierMigration that shares its assets +// and environment. The two carry the same data; if their fields ever diverge +// the compiler rejects this conversion, which is the intended tripwire. +func (i Invoker) migration() PrettierMigration { + return PrettierMigration(i) +} + +func (i Invoker) spawn(ctx context.Context, bin string, args []string, req Request) error { + cmd := exec.CommandContext(ctx, bin, args...) + + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + + // Match the container entrypoints: let git treat any working tree as safe + // so file collection inside bind mounts and caches works. + cmd.Env = append(os.Environ(), + "GIT_CONFIG_COUNT=1", + "GIT_CONFIG_KEY_0=safe.directory", + "GIT_CONFIG_VALUE_0=*", + ) + + return cmd.Run() +} diff --git a/packages/go/driver/internal/tsruntime/prettier.go b/packages/go/driver/internal/typescript/runtime/prettier.go similarity index 68% rename from packages/go/driver/internal/tsruntime/prettier.go rename to packages/go/driver/internal/typescript/runtime/prettier.go index 2c5509a..9901291 100644 --- a/packages/go/driver/internal/tsruntime/prettier.go +++ b/packages/go/driver/internal/typescript/runtime/prettier.go @@ -1,4 +1,4 @@ -package tsruntime +package runtime import ( "context" @@ -10,8 +10,19 @@ import ( "os" "os/exec" "path/filepath" + + "go.ollin.sh/fmtkit/driver/internal/typescript/proto" ) +// PrettierMigration derives an oxfmt config from a project's Prettier setup by +// running oxfmt's own --migrate=prettier translator. It shares the invoker's +// assets (for the sidecar path and the cache directory) and environment (for an +// OXFMT_BIN override). +type PrettierMigration struct { + Assets Assets + Env proto.Overrides +} + // prettierConfigNames are the standalone Prettier configuration filenames, in // the order Prettier itself resolves them. package.json's "prettier" key is // checked separately, after these. @@ -70,12 +81,12 @@ func packageJSONHasPrettierKey(path string) bool { return ok && string(value) != "null" } -// prettierDerivedConfig returns the path of an oxfmt config derived from cwd's -// Prettier configuration, or "" when there is no Prettier config or the -// migration fails. Failures print a one-line warning to stderr and leave the -// caller to fall back to the bundled config; a translated config is cached by -// the source config's content hash so migration runs at most once per config. -func (s Support) prettierDerivedConfig(ctx context.Context, cwd string, env overrides, stderr io.Writer) string { +// DerivedConfig returns the path of an oxfmt config derived from cwd's Prettier +// configuration, or "" when there is no Prettier config or the migration fails. +// Failures print a one-line warning to stderr and leave the caller to fall back +// to the bundled config; a translated config is cached by the source config's +// content hash so migration runs at most once per config. +func (m PrettierMigration) DerivedConfig(ctx context.Context, cwd string, stderr io.Writer) string { source := detectPrettierConfig(cwd) if source == "" { @@ -91,13 +102,13 @@ func (s Support) prettierDerivedConfig(ctx context.Context, cwd string, env over } sum := sha256.Sum256(data) - cachePath := filepath.Join(s.Dir, "prettier-derived", hex.EncodeToString(sum[:])+".json") + cachePath := filepath.Join(m.Assets.Dir, "prettier-derived", hex.EncodeToString(sum[:])+".json") if existingFile(cachePath) != "" { return cachePath } - derived, err := s.migratePrettierConfig(ctx, source, env) + derived, err := m.migrate(ctx, source) if err != nil { _, _ = fmt.Fprintf(stderr, "[oxfmt] could not derive oxfmt config from %s: %v; using bundled config\n", source, err) @@ -114,11 +125,11 @@ func (s Support) prettierDerivedConfig(ctx context.Context, cwd string, env over return cachePath } -// migratePrettierConfig copies the Prettier config into a private temp dir, -// runs oxfmt --migrate=prettier there, and returns the resulting .oxfmtrc.json -// bytes. The temp dir starts empty so the migrator never trips over a -// pre-existing oxfmt config. -func (s Support) migratePrettierConfig(ctx context.Context, source string, env overrides) ([]byte, error) { +// migrate copies the Prettier config into a private temp dir, runs oxfmt +// --migrate=prettier there, and returns the resulting .oxfmtrc.json bytes. The +// temp dir starts empty so the migrator never trips over a pre-existing oxfmt +// config. +func (m PrettierMigration) migrate(ctx context.Context, source string) ([]byte, error) { dir, err := os.MkdirTemp("", "fmtkit-prettier-migrate-") if err != nil { @@ -133,8 +144,8 @@ func (s Support) migratePrettierConfig(ctx context.Context, source string, env o return nil, err } - bin, args := s.migrateCommand(env) - args = append(args, "--migrate=prettier") + bin, viaSidecar := m.oxfmtExecutable() + args := proto.MigrateCommand{ViaSidecar: viaSidecar}.Argv() cmd := exec.CommandContext(ctx, bin, args...) cmd.Dir = dir @@ -145,7 +156,7 @@ func (s Support) migratePrettierConfig(ctx context.Context, source string, env o return nil, fmt.Errorf("oxfmt --migrate=prettier: %w", err) } - derived, err := os.ReadFile(filepath.Join(dir, ".oxfmtrc.json")) + derived, err := os.ReadFile(filepath.Join(dir, proto.OxfmtRCName)) if err != nil { return nil, fmt.Errorf("read migrated config: %w", err) @@ -154,15 +165,15 @@ func (s Support) migratePrettierConfig(ctx context.Context, source string, env o return derived, nil } -// migrateCommand resolves the oxfmt invocation for a migration, mirroring -// RunPipeline: an OXFMT_BIN override runs directly, otherwise the sidecar runs -// in its oxfmt pass-through mode. -func (s Support) migrateCommand(env overrides) (string, []string) { - if env.oxfmtBin != "" { - return env.oxfmtBin, nil +// oxfmtExecutable resolves the oxfmt invocation for a migration, mirroring +// Invoker.RunPipeline: an OXFMT_BIN override runs directly, otherwise the +// sidecar runs in its oxfmt pass-through mode. +func (m PrettierMigration) oxfmtExecutable() (bin string, viaSidecar bool) { + if m.Env.OxfmtBin != "" { + return m.Env.OxfmtBin, false } - return s.Sidecar(), []string{"oxfmt"} + return m.Assets.Sidecar(), true } func copyFileContents(source, dst string) error { diff --git a/packages/go/driver/internal/tsruntime/prettier_internal_test.go b/packages/go/driver/internal/typescript/runtime/prettier_internal_test.go similarity index 66% rename from packages/go/driver/internal/tsruntime/prettier_internal_test.go rename to packages/go/driver/internal/typescript/runtime/prettier_internal_test.go index 7d9e61c..dd6fb8b 100644 --- a/packages/go/driver/internal/tsruntime/prettier_internal_test.go +++ b/packages/go/driver/internal/typescript/runtime/prettier_internal_test.go @@ -1,4 +1,4 @@ -package tsruntime +package runtime import ( "context" @@ -6,27 +6,31 @@ import ( "path/filepath" "strings" "testing" + + "go.ollin.sh/fmtkit/driver/internal/typescript/proto" ) -func TestMigrateCommandDefaultsToSidecar(t *testing.T) { - support := Support{Dir: filepath.Join("some", "dir")} +func TestOxfmtExecutableDefaultsToSidecar(t *testing.T) { + migration := PrettierMigration{Assets: Assets{Dir: filepath.Join("some", "dir")}} - bin, args := support.migrateCommand(overrides{}) + bin, viaSidecar := migration.oxfmtExecutable() - if bin != support.Sidecar() { - t.Fatalf("bin = %q, want sidecar %q", bin, support.Sidecar()) + if bin != migration.Assets.Sidecar() { + t.Fatalf("bin = %q, want sidecar %q", bin, migration.Assets.Sidecar()) } - if len(args) != 1 || args[0] != "oxfmt" { - t.Fatalf("args = %q, want [oxfmt]", args) + if !viaSidecar { + t.Fatal("expected the sidecar dispatch path (viaSidecar = true)") } } -func TestMigrateCommandHonorsOxfmtBin(t *testing.T) { - bin, args := Support{}.migrateCommand(overrides{oxfmtBin: "/usr/bin/oxfmt"}) +func TestOxfmtExecutableHonorsOxfmtBin(t *testing.T) { + migration := PrettierMigration{Env: proto.Overrides{OxfmtBin: "/usr/bin/oxfmt"}} + + bin, viaSidecar := migration.oxfmtExecutable() - if bin != "/usr/bin/oxfmt" || len(args) != 0 { - t.Fatalf("migrateCommand = (%q, %q), want (/usr/bin/oxfmt, [])", bin, args) + if bin != "/usr/bin/oxfmt" || viaSidecar { + t.Fatalf("oxfmtExecutable = (%q, %v), want (/usr/bin/oxfmt, false)", bin, viaSidecar) } } @@ -61,7 +65,7 @@ func TestPackageJSONHasPrettierKeyMissingFile(t *testing.T) { } } -func TestPrettierDerivedConfigWarnsWhenCacheWriteFails(t *testing.T) { +func TestDerivedConfigWarnsWhenCacheWriteFails(t *testing.T) { support := supportWithStub(t) oxfmt := filepath.Join(t.TempDir(), "oxfmt") @@ -78,11 +82,11 @@ func TestPrettierDerivedConfigWarnsWhenCacheWriteFails(t *testing.T) { t.Fatalf("write prettier config: %v", err) } - env := overrides{oxfmtBin: oxfmt} + migration := PrettierMigration{Assets: support, Env: proto.Overrides{OxfmtBin: oxfmt}} var stderr strings.Builder - if got := support.prettierDerivedConfig(context.Background(), cwd, env, &stderr); got != "" { + if got := migration.DerivedConfig(context.Background(), cwd, &stderr); got != "" { t.Fatalf("expected empty result on cache failure, got %q", got) } @@ -91,7 +95,7 @@ func TestPrettierDerivedConfigWarnsWhenCacheWriteFails(t *testing.T) { } } -func TestPrettierDerivedConfigWarnsWhenMigrationWritesNoConfig(t *testing.T) { +func TestDerivedConfigWarnsWhenMigrationWritesNoConfig(t *testing.T) { support := supportWithStub(t) // A stub that exits 0 but writes no .oxfmtrc.json: the read-back must fail. @@ -107,9 +111,11 @@ func TestPrettierDerivedConfigWarnsWhenMigrationWritesNoConfig(t *testing.T) { t.Fatalf("write prettier config: %v", err) } + migration := PrettierMigration{Assets: support, Env: proto.Overrides{OxfmtBin: silent}} + var stderr strings.Builder - got := support.prettierDerivedConfig(context.Background(), cwd, overrides{oxfmtBin: silent}, &stderr) + got := migration.DerivedConfig(context.Background(), cwd, &stderr) if got != "" { t.Fatalf("expected empty result when no config is produced, got %q", got) diff --git a/packages/go/driver/internal/tsruntime/prettier_test.go b/packages/go/driver/internal/typescript/runtime/prettier_test.go similarity index 85% rename from packages/go/driver/internal/tsruntime/prettier_test.go rename to packages/go/driver/internal/typescript/runtime/prettier_test.go index 7851cfc..ec84b2a 100644 --- a/packages/go/driver/internal/tsruntime/prettier_test.go +++ b/packages/go/driver/internal/typescript/runtime/prettier_test.go @@ -1,4 +1,4 @@ -package tsruntime +package runtime import ( "bytes" @@ -7,6 +7,8 @@ import ( "path/filepath" "strings" "testing" + + "go.ollin.sh/fmtkit/driver/internal/typescript/proto" ) // writeMigrateStub creates a fake oxfmt that, on --migrate=prettier, writes an @@ -152,13 +154,13 @@ func TestOxfmtConfigForDerivesFromPrettier(t *testing.T) { t.Fatalf("write prettier config: %v", err) } - t.Setenv(OxfmtBinEnv, oxfmt) + t.Setenv(proto.OxfmtBinEnv, oxfmt) - env := readOverrides() + env := proto.ReadOverrides() var stderr bytes.Buffer - config := support.oxfmtConfigFor(context.Background(), cwd, env, &stderr) + config := Invoker{Assets: support, Env: env}.oxfmtConfigFor(context.Background(), cwd, &stderr) if config == "" || existingFile(config) == "" { t.Fatalf("expected a derived config path, got %q (stderr: %s)", config, stderr.String()) @@ -191,14 +193,14 @@ func TestOxfmtConfigForCachesDerivedConfig(t *testing.T) { t.Fatalf("write prettier config: %v", err) } - t.Setenv(OxfmtBinEnv, oxfmt) + t.Setenv(proto.OxfmtBinEnv, oxfmt) - env := readOverrides() + env := proto.ReadOverrides() var stderr bytes.Buffer - first := support.oxfmtConfigFor(context.Background(), cwd, env, &stderr) - second := support.oxfmtConfigFor(context.Background(), cwd, env, &stderr) + first := Invoker{Assets: support, Env: env}.oxfmtConfigFor(context.Background(), cwd, &stderr) + second := Invoker{Assets: support, Env: env}.oxfmtConfigFor(context.Background(), cwd, &stderr) if first != second { t.Fatalf("cache miss on second call: %q vs %q", first, second) @@ -223,19 +225,19 @@ func TestOxfmtConfigForRemigratesWhenConfigChanges(t *testing.T) { t.Fatalf("write prettier config: %v", err) } - t.Setenv(OxfmtBinEnv, oxfmt) + t.Setenv(proto.OxfmtBinEnv, oxfmt) - env := readOverrides() + env := proto.ReadOverrides() var stderr bytes.Buffer - support.oxfmtConfigFor(context.Background(), cwd, env, &stderr) + Invoker{Assets: support, Env: env}.oxfmtConfigFor(context.Background(), cwd, &stderr) if err := os.WriteFile(prettier, []byte(`{"semi":true}`), 0o644); err != nil { t.Fatalf("rewrite prettier config: %v", err) } - support.oxfmtConfigFor(context.Background(), cwd, env, &stderr) + Invoker{Assets: support, Env: env}.oxfmtConfigFor(context.Background(), cwd, &stderr) if got := migrateInvocations(t, filepath.Dir(oxfmt)); got != 2 { t.Fatalf("migration ran %d times, want 2 (content hash should change)", got) @@ -252,7 +254,7 @@ func TestOxfmtConfigForPrecedence(t *testing.T) { oxfmt := filepath.Join(t.TempDir(), "oxfmt") writeMigrateStub(t, oxfmt) - t.Setenv(OxfmtBinEnv, oxfmt) + t.Setenv(proto.OxfmtBinEnv, oxfmt) t.Run("project .oxfmtrc beats prettier", func(t *testing.T) { cwd := t.TempDir() @@ -267,7 +269,7 @@ func TestOxfmtConfigForPrecedence(t *testing.T) { var stderr bytes.Buffer - if got := support.oxfmtConfigFor(context.Background(), cwd, readOverrides(), &stderr); got != "" { + if got := (Invoker{Assets: support, Env: proto.ReadOverrides()}).oxfmtConfigFor(context.Background(), cwd, &stderr); got != "" { t.Fatalf("expected auto-discovery signal for project config, got %q", got) } }) @@ -281,7 +283,7 @@ func TestOxfmtConfigForPrecedence(t *testing.T) { var stderr bytes.Buffer - got := support.oxfmtConfigFor(context.Background(), cwd, readOverrides(), &stderr) + got := Invoker{Assets: support, Env: proto.ReadOverrides()}.oxfmtConfigFor(context.Background(), cwd, &stderr) if !strings.HasPrefix(got, filepath.Join(support.Dir, "prettier-derived")) { t.Fatalf("expected derived config, got %q", got) @@ -293,7 +295,7 @@ func TestOxfmtConfigForPrecedence(t *testing.T) { var stderr bytes.Buffer - if got := support.oxfmtConfigFor(context.Background(), cwd, readOverrides(), &stderr); got != support.OxfmtConfig() { + if got := (Invoker{Assets: support, Env: proto.ReadOverrides()}).oxfmtConfigFor(context.Background(), cwd, &stderr); got != support.OxfmtConfig() { t.Fatalf("expected bundled config %q, got %q", support.OxfmtConfig(), got) } }) @@ -311,10 +313,10 @@ func TestOxfmtConfigForPrecedence(t *testing.T) { t.Fatalf("write override config: %v", err) } - env := readOverrides() - env.oxfmtConfig = override + env := proto.ReadOverrides() + env.OxfmtConfig = override - if got := support.oxfmtConfigFor(context.Background(), cwd, env, &bytes.Buffer{}); got != override { + if got := (Invoker{Assets: support, Env: env}).oxfmtConfigFor(context.Background(), cwd, &bytes.Buffer{}); got != override { t.Fatalf("expected override %q, got %q", override, got) } }) @@ -340,11 +342,11 @@ func TestOxfmtConfigForFallsBackWhenMigrationFails(t *testing.T) { t.Fatalf("write prettier config: %v", err) } - t.Setenv(OxfmtBinEnv, failing) + t.Setenv(proto.OxfmtBinEnv, failing) var stderr bytes.Buffer - got := support.oxfmtConfigFor(context.Background(), cwd, readOverrides(), &stderr) + got := Invoker{Assets: support, Env: proto.ReadOverrides()}.oxfmtConfigFor(context.Background(), cwd, &stderr) if got != support.OxfmtConfig() { t.Fatalf("expected bundled fallback %q, got %q", support.OxfmtConfig(), got) diff --git a/packages/go/driver/internal/tsruntime/run_test.go b/packages/go/driver/internal/typescript/runtime/run_test.go similarity index 80% rename from packages/go/driver/internal/tsruntime/run_test.go rename to packages/go/driver/internal/typescript/runtime/run_test.go index 621e37f..cea8122 100644 --- a/packages/go/driver/internal/tsruntime/run_test.go +++ b/packages/go/driver/internal/typescript/runtime/run_test.go @@ -1,4 +1,4 @@ -package tsruntime +package runtime import ( "bytes" @@ -9,6 +9,8 @@ import ( "path/filepath" "strings" "testing" + + "go.ollin.sh/fmtkit/driver/internal/typescript/proto" ) // writeStub creates an executable that echoes its argv, one per line, so @@ -48,14 +50,14 @@ func gitScratchRepo(t *testing.T, files map[string]string) string { return dir } -func supportWithStub(t *testing.T) Support { +func supportWithStub(t *testing.T) Assets { t.Helper() dir := t.TempDir() - writeStub(t, filepath.Join(dir, sidecarName)) + writeStub(t, filepath.Join(dir, proto.SidecarName)) - return Support{Dir: dir} + return Assets{Dir: dir} } func TestRunPipelineInvokesSidecar(t *testing.T) { @@ -72,11 +74,11 @@ func TestRunPipelineInvokesSidecar(t *testing.T) { t.Fatalf("write bundled config: %v", err) } - t.Setenv(SourcesCwdEnv, repo) + t.Setenv(proto.SourcesCwdEnv, repo) var stdout, stderr bytes.Buffer - err := support.RunPipeline(context.Background(), RunOptions{Stdout: &stdout, Stderr: &stderr}) + err := NewInvoker(support).RunPipeline(context.Background(), Request{Stdout: &stdout, Stderr: &stderr}) if err != nil { t.Fatalf("RunPipeline: %v\nstderr: %s", err, stderr.String()) @@ -120,11 +122,11 @@ func TestRunPipelineSkipsBundledConfigWhenProjectHasOne(t *testing.T) { t.Fatalf("write bundled config: %v", err) } - t.Setenv(SourcesCwdEnv, repo) + t.Setenv(proto.SourcesCwdEnv, repo) var stdout, stderr bytes.Buffer - if err := support.RunPipeline(context.Background(), RunOptions{Stdout: &stdout, Stderr: &stderr}); err != nil { + if err := NewInvoker(support).RunPipeline(context.Background(), Request{Stdout: &stdout, Stderr: &stderr}); err != nil { t.Fatalf("RunPipeline: %v\nstderr: %s", err, stderr.String()) } @@ -138,11 +140,11 @@ func TestRunPipelineReportsMissingScopes(t *testing.T) { support := supportWithStub(t) - t.Setenv(SourcesCwdEnv, repo) + t.Setenv(proto.SourcesCwdEnv, repo) var stdout, stderr bytes.Buffer - err := support.RunPipeline(context.Background(), RunOptions{ + err := NewInvoker(support).RunPipeline(context.Background(), Request{ Scopes: []string{"missing-dir"}, Stdout: &stdout, Stderr: &stderr, @@ -168,11 +170,11 @@ func TestRunLintInvokesOxlintMode(t *testing.T) { t.Fatalf("write bundled config: %v", err) } - t.Setenv(SourcesCwdEnv, repo) + t.Setenv(proto.SourcesCwdEnv, repo) var stdout, stderr bytes.Buffer - if err := support.RunLint(context.Background(), RunOptions{Stdout: &stdout, Stderr: &stderr}); err != nil { + if err := NewInvoker(support).RunLint(context.Background(), Request{Stdout: &stdout, Stderr: &stderr}); err != nil { t.Fatalf("RunLint: %v\nstderr: %s", err, stderr.String()) } @@ -204,11 +206,11 @@ func TestRunLintFixPassesFixFlag(t *testing.T) { t.Fatalf("write bundled config: %v", err) } - t.Setenv(SourcesCwdEnv, repo) + t.Setenv(proto.SourcesCwdEnv, repo) var stdout, stderr bytes.Buffer - if err := support.RunLint(context.Background(), RunOptions{Fix: true, Stdout: &stdout, Stderr: &stderr}); err != nil { + if err := NewInvoker(support).RunLint(context.Background(), Request{Fix: true, Stdout: &stdout, Stderr: &stderr}); err != nil { t.Fatalf("RunLint: %v\nstderr: %s", err, stderr.String()) } @@ -244,11 +246,11 @@ func TestRunLintSkipsBundledConfigWhenProjectHasOne(t *testing.T) { t.Fatalf("write bundled config: %v", err) } - t.Setenv(SourcesCwdEnv, repo) + t.Setenv(proto.SourcesCwdEnv, repo) var stdout, stderr bytes.Buffer - if err := support.RunLint(context.Background(), RunOptions{Stdout: &stdout, Stderr: &stderr}); err != nil { + if err := NewInvoker(support).RunLint(context.Background(), Request{Stdout: &stdout, Stderr: &stderr}); err != nil { t.Fatalf("RunLint: %v\nstderr: %s", err, stderr.String()) } @@ -260,13 +262,13 @@ func TestRunLintSkipsBundledConfigWhenProjectHasOne(t *testing.T) { func TestRunLintSkipsSpawnWithoutFiles(t *testing.T) { repo := gitScratchRepo(t, map[string]string{"main.go": "package main\n"}) - support := Support{Dir: t.TempDir()} // no sidecar: spawning would fail + support := Assets{Dir: t.TempDir()} // no sidecar: spawning would fail - t.Setenv(SourcesCwdEnv, repo) + t.Setenv(proto.SourcesCwdEnv, repo) var stdout, stderr bytes.Buffer - if err := support.RunLint(context.Background(), RunOptions{Stdout: &stdout, Stderr: &stderr}); err != nil { + if err := NewInvoker(support).RunLint(context.Background(), Request{Stdout: &stdout, Stderr: &stderr}); err != nil { t.Fatalf("RunLint: %v", err) } @@ -284,13 +286,13 @@ func TestRunLintSkipsSpawnForFormatOnlyDocuments(t *testing.T) { "notes.md": "# Notes\n", }) - support := Support{Dir: t.TempDir()} // no sidecar: spawning would fail + support := Assets{Dir: t.TempDir()} // no sidecar: spawning would fail - t.Setenv(SourcesCwdEnv, repo) + t.Setenv(proto.SourcesCwdEnv, repo) var stdout, stderr bytes.Buffer - if err := support.RunLint(context.Background(), RunOptions{Stdout: &stdout, Stderr: &stderr}); err != nil { + if err := NewInvoker(support).RunLint(context.Background(), Request{Stdout: &stdout, Stderr: &stderr}); err != nil { t.Fatalf("RunLint: %v", err) } @@ -308,12 +310,12 @@ func TestRunLintHonorsOxlintBinOverride(t *testing.T) { writeStub(t, override) - t.Setenv(SourcesCwdEnv, repo) - t.Setenv(OxlintBinEnv, override) + t.Setenv(proto.SourcesCwdEnv, repo) + t.Setenv(proto.OxlintBinEnv, override) var stdout, stderr bytes.Buffer - if err := support.RunLint(context.Background(), RunOptions{Stdout: &stdout, Stderr: &stderr}); err != nil { + if err := NewInvoker(support).RunLint(context.Background(), Request{Stdout: &stdout, Stderr: &stderr}); err != nil { t.Fatalf("RunLint: %v\nstderr: %s", err, stderr.String()) } diff --git a/packages/go/driver/internal/tsruntime/support_test.go b/packages/go/driver/internal/typescript/runtime/support_test.go similarity index 90% rename from packages/go/driver/internal/tsruntime/support_test.go rename to packages/go/driver/internal/typescript/runtime/support_test.go index 210d7cd..f058664 100644 --- a/packages/go/driver/internal/tsruntime/support_test.go +++ b/packages/go/driver/internal/typescript/runtime/support_test.go @@ -1,17 +1,19 @@ -package tsruntime +package runtime import ( "os" "path/filepath" "testing" "testing/fstest" + + "go.ollin.sh/fmtkit/driver/internal/typescript/proto" ) // fakeAssets mirrors a directory staged by stage-ts-assets.sh: the bindings // and the sidecar, plus the configs that ride along with them. func fakeAssets() fstest.MapFS { return fstest.MapFS{ - sidecarName: &fstest.MapFile{Data: []byte("#!/bin/sh\n"), Mode: 0o755}, + proto.SidecarName: &fstest.MapFile{Data: []byte("#!/bin/sh\n"), Mode: 0o755}, "oxc-parser.node": &fstest.MapFile{Data: []byte("parser")}, "oxfmt.node": &fstest.MapFile{Data: []byte("fmt")}, "oxlint.node": &fstest.MapFile{Data: []byte("lint")}, @@ -27,7 +29,7 @@ func TestExtractOncePopulatesSupportDir(t *testing.T) { t.Fatalf("extractOnce: %v", err) } - support := Support{Dir: dir} + support := Assets{Dir: dir} if _, err := os.Stat(support.Sidecar()); err != nil { t.Fatalf("sidecar missing: %v", err) @@ -97,11 +99,11 @@ func TestExtractOnceLosingRaceKeepsWinner(t *testing.T) { func TestResolvePrefersSupportDirEnv(t *testing.T) { dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, sidecarName), []byte("#!/bin/sh\n"), 0o755); err != nil { + if err := os.WriteFile(filepath.Join(dir, proto.SidecarName), []byte("#!/bin/sh\n"), 0o755); err != nil { t.Fatalf("write sidecar: %v", err) } - t.Setenv(SupportDirEnv, dir) + t.Setenv(proto.SupportDirEnv, dir) support, err := Resolve("v1.0.0") @@ -115,7 +117,7 @@ func TestResolvePrefersSupportDirEnv(t *testing.T) { } func TestResolveRejectsSupportDirWithoutSidecar(t *testing.T) { - t.Setenv(SupportDirEnv, t.TempDir()) + t.Setenv(proto.SupportDirEnv, t.TempDir()) if _, err := Resolve("v1.0.0"); err == nil { t.Fatal("expected error for support dir without sidecar") diff --git a/packages/go/driver/internal/sourcefiles/command.go b/packages/go/driver/internal/typescript/sourcefiles/command.go similarity index 68% rename from packages/go/driver/internal/sourcefiles/command.go rename to packages/go/driver/internal/typescript/sourcefiles/command.go index 4c73f84..9aef8fc 100644 --- a/packages/go/driver/internal/sourcefiles/command.go +++ b/packages/go/driver/internal/typescript/sourcefiles/command.go @@ -6,8 +6,12 @@ import ( "fmt" "io" "os" + + "go.ollin.sh/fmtkit/driver/internal/gitfiles" ) +// Run parses the `sources` subcommand flags, collects the formattable files +// under the given scopes, and prints them NUL-separated to stdout. func Run(ctx context.Context, args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("sources", flag.ContinueOnError) fs.SetOutput(stderr) @@ -33,11 +37,15 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) int { } } - files, warnings, err := Collect(ctx, Options{ - Cwd: cwd, - IncludeDeclarations: *includeDeclarations, - Scopes: fs.Args(), - }) + collector, err := New(cwd, gitfiles.SelectionAll, *includeDeclarations) + + if err != nil { + _, _ = fmt.Fprintf(stderr, "[sources] %v\n", err) + + return 1 + } + + files, warnings, err := collector.Formattable(ctx, fs.Args()) for _, warning := range warnings { _, _ = fmt.Fprintf(stderr, "[sources] %s\n", warning) diff --git a/packages/go/driver/internal/typescript/sourcefiles/command_test.go b/packages/go/driver/internal/typescript/sourcefiles/command_test.go new file mode 100644 index 0000000..e611622 --- /dev/null +++ b/packages/go/driver/internal/typescript/sourcefiles/command_test.go @@ -0,0 +1,129 @@ +package sourcefiles + +import ( + "bytes" + "context" + "path/filepath" + "strings" + "testing" + + "go.ollin.sh/fmtkit/driver/testutil" +) + +func TestRunPrintsNULSeparatedFiles(t *testing.T) { + dir := testutil.InitRepo(t) + testutil.WriteFile(t, filepath.Join(dir, "src", "app.ts"), "const value = 1;\n") + testutil.WriteFile(t, filepath.Join(dir, "src", "notes.md"), "# Notes\n") + testutil.WriteFile(t, filepath.Join(dir, "src", "types.d.ts"), "declare const value: string;\n") + testutil.GitAdd(t, dir, ".") + + var stdout, stderr bytes.Buffer + + code := Run(context.Background(), []string{"--cwd", dir, "src"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("RunCLI exit = %d, stderr: %s", code, stderr.String()) + } + + got := splitNUL(stdout.String()) + + want := []string{ + filepath.Join(dir, "src", "app.ts"), + filepath.Join(dir, "src", "notes.md"), + } + + if len(got) != len(want) { + t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, got) + } + + for i := range want { + if got[i] != want[i] { + t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, got) + } + } +} + +func TestRunIncludesDeclarationsFlag(t *testing.T) { + dir := testutil.InitRepo(t) + testutil.WriteFile(t, filepath.Join(dir, "types.d.ts"), "declare const value: string;\n") + testutil.GitAdd(t, dir, ".") + + var stdout, stderr bytes.Buffer + + code := Run(context.Background(), []string{"--cwd", dir, "--include-declarations"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("RunCLI exit = %d, stderr: %s", code, stderr.String()) + } + + got := splitNUL(stdout.String()) + + if len(got) != 1 || got[0] != filepath.Join(dir, "types.d.ts") { + t.Fatalf("expected the declaration file, got %#v", got) + } +} + +func TestRunWarnsOnMissingScopes(t *testing.T) { + dir := testutil.InitRepo(t) + testutil.WriteFile(t, filepath.Join(dir, "app.ts"), "const value = 1;\n") + testutil.GitAdd(t, dir, ".") + + var stdout, stderr bytes.Buffer + + code := Run(context.Background(), []string{"--cwd", dir, "missing"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("RunCLI exit = %d", code) + } + + if !strings.Contains(stderr.String(), "[sources] path not found, skipping") { + t.Fatalf("expected a missing-path warning, got stderr: %q", stderr.String()) + } +} + +func TestRunDefaultsToWorkingDirectory(t *testing.T) { + dir := testutil.InitRepo(t) + testutil.WriteFile(t, filepath.Join(dir, "app.ts"), "const value = 1;\n") + testutil.GitAdd(t, dir, ".") + t.Chdir(dir) + + var stdout, stderr bytes.Buffer + + if code := Run(context.Background(), nil, &stdout, &stderr); code != 0 { + t.Fatalf("RunCLI exit = %d, stderr: %s", code, stderr.String()) + } + + got := splitNUL(stdout.String()) + + // With no --cwd, RunCLI resolves the process working directory itself. + if len(got) != 1 || got[0] != filepath.Join(dir, "app.ts") { + t.Fatalf("expected app.ts under the resolved cwd, got %#v", got) + } +} + +func TestRunReportsBadFlags(t *testing.T) { + var stdout, stderr bytes.Buffer + + if code := Run(context.Background(), []string{"--nope"}, &stdout, &stderr); code != 1 { + t.Fatalf("expected exit 1 for an unknown flag, got %d", code) + } +} + +func splitNUL(s string) []string { + if s == "" { + return nil + } + + parts := strings.Split(s, "\x00") + out := make([]string, 0, len(parts)) + + for _, part := range parts { + if part == "" { + continue + } + + out = append(out, part) + } + + return out +} diff --git a/packages/go/driver/internal/typescript/sourcefiles/prettierignore_test.go b/packages/go/driver/internal/typescript/sourcefiles/prettierignore_test.go new file mode 100644 index 0000000..9bac0d0 --- /dev/null +++ b/packages/go/driver/internal/typescript/sourcefiles/prettierignore_test.go @@ -0,0 +1,72 @@ +package sourcefiles + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "go.ollin.sh/fmtkit/driver/internal/gitfiles" + "go.ollin.sh/fmtkit/driver/testutil" +) + +func TestCollectSurfacesUnreadablePrettierIgnore(t *testing.T) { + dir := testutil.InitRepo(t) + testutil.WriteFile(t, filepath.Join(dir, "app.ts"), "const value = 1;\n") + testutil.GitAdd(t, dir, ".") + + // A directory named .prettierignore is not IsNotExist, so the read error must + // surface rather than being swallowed. + if err := os.Mkdir(filepath.Join(dir, ".prettierignore"), 0o755); err != nil { + t.Fatalf("mkdir .prettierignore: %v", err) + } + + if _, _, err := collectFormattable(t, dir, false, gitfiles.SelectionAll); err == nil { + t.Fatal("expected an error from an unreadable .prettierignore") + } +} + +func TestCollectHonorsPrettierIgnore(t *testing.T) { + dir := testutil.InitRepo(t) + testutil.WriteFile(t, filepath.Join(dir, ".prettierignore"), "generated.ts\ndist/\n") + testutil.WriteFile(t, filepath.Join(dir, "app.ts"), "const value = 1;\n") + testutil.WriteFile(t, filepath.Join(dir, "generated.ts"), "const generated = 1;\n") + testutil.WriteFile(t, filepath.Join(dir, "dist", "bundle.ts"), "const bundle = 1;\n") + testutil.GitAdd(t, dir, ".") + + files, warnings, err := collectFormattable(t, dir, false, gitfiles.SelectionAll) + + if err != nil { + t.Fatalf("collect: %v", err) + } + + if len(warnings) != 0 { + t.Fatalf("unexpected warnings: %v", warnings) + } + + want := []string{filepath.Join(dir, "app.ts")} + + if !reflect.DeepEqual(files, want) { + t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, files) + } +} + +func TestCollectLintableHonorsPrettierIgnore(t *testing.T) { + dir := testutil.InitRepo(t) + testutil.WriteFile(t, filepath.Join(dir, ".prettierignore"), "vendor/\n") + testutil.WriteFile(t, filepath.Join(dir, "app.ts"), "const value = 1;\n") + testutil.WriteFile(t, filepath.Join(dir, "vendor", "lib.ts"), "const lib = 1;\n") + testutil.GitAdd(t, dir, ".") + + files, _, err := collectLintable(t, dir, false, gitfiles.SelectionAll) + + if err != nil { + t.Fatalf("collect lintable: %v", err) + } + + want := []string{filepath.Join(dir, "app.ts")} + + if !reflect.DeepEqual(files, want) { + t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, files) + } +} diff --git a/packages/go/driver/internal/typescript/sourcefiles/sourcefiles.go b/packages/go/driver/internal/typescript/sourcefiles/sourcefiles.go new file mode 100644 index 0000000..a5ed618 --- /dev/null +++ b/packages/go/driver/internal/typescript/sourcefiles/sourcefiles.go @@ -0,0 +1,97 @@ +// Package sourcefiles composes the three source-discovery engines — git file +// discovery (gitfiles), the extension taxonomy (filetypes), and the +// .prettierignore matcher (prettierignore) — into the file lists the TS +// toolchain formats and lints. +package sourcefiles + +import ( + "context" + "fmt" + "path/filepath" + + "go.ollin.sh/fmtkit/driver/internal/gitfiles" + "go.ollin.sh/fmtkit/driver/internal/typescript/filetypes" + "go.ollin.sh/fmtkit/driver/internal/typescript/prettierignore" +) + +// Collector composes git discovery, the extension taxonomy, and the +// .prettierignore matcher into the formatter's and linter's file lists. +type Collector struct { + Tree gitfiles.Tree + Selection gitfiles.Selection + Filter filetypes.Filter +} + +// New builds a Collector rooted at cwd covering selection, keeping the files +// the taxonomy classifies. IncludeDeclarations keeps .d.ts declaration files. +func New(cwd string, selection gitfiles.Selection, includeDeclarations bool) (Collector, error) { + tree, err := gitfiles.NewTree(cwd) + + if err != nil { + return Collector{}, err + } + + return Collector{ + Tree: tree, + Selection: selection, + Filter: filetypes.Filter{IncludeDeclarations: includeDeclarations}, + }, nil +} + +// Formattable lists the files the formatter owns under the given scopes: the TS +// and Vue families plus the HTML and Markdown documents whose embedded scripts +// get formatted. It returns warnings for scopes that do not exist. +func (c Collector) Formattable(ctx context.Context, scopes []string) ([]string, []string, error) { + return c.collect(ctx, scopes, c.Filter.Formattable) +} + +// Lintable lists only the files oxlint can lint under the given scopes: the TS +// and Vue families. It is a subset of Formattable — HTML and Markdown are +// formattable but not lintable. +func (c Collector) Lintable(ctx context.Context, scopes []string) ([]string, []string, error) { + return c.collect(ctx, scopes, c.Filter.Lintable) +} + +func (c Collector) collect(ctx context.Context, scopes []string, keep func(string) bool) ([]string, []string, error) { + cwd := c.Tree.Dir + + files, missing, err := c.Tree.Walk(ctx, scopes, c.Selection, keep) + + // A scope that is not there is a warning here rather than the silent skip + // the git lane wants: the TS lane is driven by user-supplied paths, so a + // typo should say so instead of quietly formatting nothing. + warnings := make([]string, 0, len(missing)) + + for _, absolute := range missing { + warnings = append(warnings, fmt.Sprintf("path not found, skipping: %s", absolute)) + } + + if err != nil { + return nil, warnings, err + } + + // Walk already sorted; dropping ignored paths preserves that order. + kept, err := c.honorPrettierIgnore(cwd, files) + + if err != nil { + return nil, warnings, err + } + + return kept, warnings, nil +} + +// honorPrettierIgnore drops any collected path the project's .prettierignore +// excludes. When there is no .prettierignore, the files pass through unchanged. +func (c Collector) honorPrettierIgnore(cwd string, files []string) ([]string, error) { + matcher, err := prettierignore.Load(filepath.Join(cwd, ".prettierignore")) + + if err != nil { + return nil, err + } + + if matcher == nil { + return files, nil + } + + return matcher.FilterAbs(cwd, files) +} diff --git a/packages/go/driver/internal/typescript/sourcefiles/sourcefiles_test.go b/packages/go/driver/internal/typescript/sourcefiles/sourcefiles_test.go new file mode 100644 index 0000000..196855a --- /dev/null +++ b/packages/go/driver/internal/typescript/sourcefiles/sourcefiles_test.go @@ -0,0 +1,357 @@ +package sourcefiles + +import ( + "context" + "path/filepath" + "reflect" + "testing" + + "go.ollin.sh/fmtkit/driver/internal/gitfiles" + "go.ollin.sh/fmtkit/driver/testutil" +) + +// collectFormattable and collectLintable build a Collector rooted at cwd and +// run the corresponding discovery, so each test names only the axes it cares +// about (declarations, selection, scopes). +func collectFormattable(t *testing.T, cwd string, includeDeclarations bool, selection gitfiles.Selection, scopes ...string) ([]string, []string, error) { + t.Helper() + + collector, err := New(cwd, selection, includeDeclarations) + + if err != nil { + t.Fatalf("new collector: %v", err) + } + + return collector.Formattable(context.Background(), scopes) +} + +func collectLintable(t *testing.T, cwd string, includeDeclarations bool, selection gitfiles.Selection, scopes ...string) ([]string, []string, error) { + t.Helper() + + collector, err := New(cwd, selection, includeDeclarations) + + if err != nil { + t.Fatalf("new collector: %v", err) + } + + return collector.Lintable(context.Background(), scopes) +} + +func TestCollectFiltersSourceFiles(t *testing.T) { + dir := testutil.InitRepo(t) + testutil.WriteFile(t, filepath.Join(dir, "src", "app.ts"), "const value = 1;\n") + testutil.WriteFile(t, filepath.Join(dir, "src", "component.vue"), "\n") + testutil.WriteFile(t, filepath.Join(dir, "src", "types.d.ts"), "declare const value: string;\n") + testutil.WriteFile(t, filepath.Join(dir, "src", "notes.md"), "# Notes\n") + testutil.WriteFile(t, filepath.Join(dir, "src", "index.html"), "\n") + testutil.GitAdd(t, dir, ".") + + files, warnings, err := collectFormattable(t, dir, false, gitfiles.SelectionAll, "src") + + if err != nil { + t.Fatalf("collect: %v", err) + } + + if len(warnings) != 0 { + t.Fatalf("unexpected warnings: %v", warnings) + } + + want := []string{ + filepath.Join(dir, "src", "app.ts"), + filepath.Join(dir, "src", "component.vue"), + filepath.Join(dir, "src", "index.html"), + filepath.Join(dir, "src", "notes.md"), + } + + if !reflect.DeepEqual(files, want) { + t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, files) + } +} + +func TestCollectCanIncludeDeclarationFiles(t *testing.T) { + dir := testutil.InitRepo(t) + testutil.WriteFile(t, filepath.Join(dir, "src", "app.ts"), "const value = 1;\n") + testutil.WriteFile(t, filepath.Join(dir, "src", "types.d.ts"), "declare const value: string;\n") + testutil.GitAdd(t, dir, ".") + + files, warnings, err := collectFormattable(t, dir, true, gitfiles.SelectionAll, "src") + + if err != nil { + t.Fatalf("collect: %v", err) + } + + if len(warnings) != 0 { + t.Fatalf("unexpected warnings: %v", warnings) + } + + want := []string{ + filepath.Join(dir, "src", "app.ts"), + filepath.Join(dir, "src", "types.d.ts"), + } + + if !reflect.DeepEqual(files, want) { + t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, files) + } +} + +func TestCollectLintableExcludesNonScriptDocuments(t *testing.T) { + dir := testutil.InitRepo(t) + testutil.WriteFile(t, filepath.Join(dir, "src", "app.ts"), "const value = 1;\n") + testutil.WriteFile(t, filepath.Join(dir, "src", "component.vue"), "\n") + testutil.WriteFile(t, filepath.Join(dir, "src", "types.d.ts"), "declare const value: string;\n") + testutil.WriteFile(t, filepath.Join(dir, "src", "notes.md"), "# Notes\n") + testutil.WriteFile(t, filepath.Join(dir, "src", "readme.markdown"), "# Readme\n") + testutil.WriteFile(t, filepath.Join(dir, "src", "index.html"), "\n") + testutil.GitAdd(t, dir, ".") + + // Formatting owns the HTML and Markdown documents alongside the TS/Vue files. + formatFiles, _, err := collectFormattable(t, dir, false, gitfiles.SelectionAll, "src") + + if err != nil { + t.Fatalf("collect: %v", err) + } + + wantFormat := []string{ + filepath.Join(dir, "src", "app.ts"), + filepath.Join(dir, "src", "component.vue"), + filepath.Join(dir, "src", "index.html"), + filepath.Join(dir, "src", "notes.md"), + filepath.Join(dir, "src", "readme.markdown"), + } + + if !reflect.DeepEqual(formatFiles, wantFormat) { + t.Fatalf("format files mismatch\nwant: %#v\n got: %#v", wantFormat, formatFiles) + } + + // Linting sees only the TS/Vue files: no HTML, no Markdown, and .d.ts stays + // out unless declarations are requested. + lintFiles, _, err := collectLintable(t, dir, false, gitfiles.SelectionAll, "src") + + if err != nil { + t.Fatalf("collect lintable: %v", err) + } + + wantLint := []string{ + filepath.Join(dir, "src", "app.ts"), + filepath.Join(dir, "src", "component.vue"), + } + + if !reflect.DeepEqual(lintFiles, wantLint) { + t.Fatalf("lintable files mismatch\nwant: %#v\n got: %#v", wantLint, lintFiles) + } +} + +func TestCollectLintableCanIncludeDeclarationFiles(t *testing.T) { + dir := testutil.InitRepo(t) + testutil.WriteFile(t, filepath.Join(dir, "src", "app.ts"), "const value = 1;\n") + testutil.WriteFile(t, filepath.Join(dir, "src", "types.d.ts"), "declare const value: string;\n") + testutil.WriteFile(t, filepath.Join(dir, "src", "index.html"), "\n") + testutil.GitAdd(t, dir, ".") + + files, _, err := collectLintable(t, dir, true, gitfiles.SelectionAll, "src") + + if err != nil { + t.Fatalf("collect lintable: %v", err) + } + + // Declarations come back when requested; HTML never does. + want := []string{ + filepath.Join(dir, "src", "app.ts"), + filepath.Join(dir, "src", "types.d.ts"), + } + + if !reflect.DeepEqual(files, want) { + t.Fatalf("lintable files mismatch\nwant: %#v\n got: %#v", want, files) + } +} + +func TestCollectIncludesUntrackedAndIgnoresIgnored(t *testing.T) { + dir := testutil.InitRepo(t) + testutil.WriteFile(t, filepath.Join(dir, ".gitignore"), "ignored.ts\n") + testutil.WriteFile(t, filepath.Join(dir, "tracked.ts"), "const value = 1;\n") + testutil.GitAdd(t, dir, ".gitignore", "tracked.ts") + testutil.WriteFile(t, filepath.Join(dir, "untracked.vue"), "\n") + testutil.WriteFile(t, filepath.Join(dir, "ignored.ts"), "const ignored = true;\n") + + files, warnings, err := collectFormattable(t, dir, false, gitfiles.SelectionAll) + + if err != nil { + t.Fatalf("collect: %v", err) + } + + if len(warnings) != 0 { + t.Fatalf("unexpected warnings: %v", warnings) + } + + want := []string{ + filepath.Join(dir, "tracked.ts"), + filepath.Join(dir, "untracked.vue"), + } + + if !reflect.DeepEqual(files, want) { + t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, files) + } +} + +func TestCollectScopesAndDeduplicatesFiles(t *testing.T) { + dir := testutil.InitRepo(t) + testutil.WriteFile(t, filepath.Join(dir, "src", "app.ts"), "const value = 1;\n") + testutil.WriteFile(t, filepath.Join(dir, "other", "app.ts"), "const value = 2;\n") + testutil.GitAdd(t, dir, ".") + + files, warnings, err := collectFormattable(t, dir, false, gitfiles.SelectionAll, + "src", filepath.Join(dir, "src", "app.ts"), "missing") + + if err != nil { + t.Fatalf("collect: %v", err) + } + + if len(warnings) != 1 { + t.Fatalf("expected one warning, got %v", warnings) + } + + want := []string{filepath.Join(dir, "src", "app.ts")} + + if !reflect.DeepEqual(files, want) { + t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, files) + } +} + +func TestCollectChangedCoversOnlyTheWorkingTreesChanges(t *testing.T) { + dir := testutil.InitRepo(t) + testutil.WriteFile(t, filepath.Join(dir, ".gitignore"), "ignored.ts\n") + testutil.WriteFile(t, filepath.Join(dir, "untouched.ts"), "const untouched = 1;\n") + testutil.WriteFile(t, filepath.Join(dir, "modified.ts"), "const modified = 1;\n") + testutil.GitAdd(t, dir, ".gitignore", "untouched.ts", "modified.ts") + testutil.GitCommit(t, dir) + + // Only these three diverge from the commit: a tracked file edited in the + // working tree, a brand new file, and an ignored one that must stay out. + testutil.WriteFile(t, filepath.Join(dir, "modified.ts"), "const modified = 2;\n") + testutil.WriteFile(t, filepath.Join(dir, "untracked.vue"), "\n") + testutil.WriteFile(t, filepath.Join(dir, "ignored.ts"), "const ignored = true;\n") + + files, warnings, err := collectFormattable(t, dir, false, gitfiles.SelectionChanged) + + if err != nil { + t.Fatalf("collect: %v", err) + } + + if len(warnings) != 0 { + t.Fatalf("unexpected warnings: %v", warnings) + } + + want := []string{ + filepath.Join(dir, "modified.ts"), + filepath.Join(dir, "untracked.vue"), + } + + if !reflect.DeepEqual(files, want) { + t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, files) + } +} + +func TestCollectChangedIncludesStagedFiles(t *testing.T) { + dir := testutil.InitRepo(t) + testutil.WriteFile(t, filepath.Join(dir, "staged.ts"), "const staged = 1;\n") + testutil.WriteFile(t, filepath.Join(dir, "removed.ts"), "const removed = 1;\n") + testutil.WriteFile(t, filepath.Join(dir, "untouched.ts"), "const untouched = 1;\n") + testutil.GitAdd(t, dir, "staged.ts", "removed.ts", "untouched.ts") + testutil.GitCommit(t, dir) + + // Fully staged: the working tree and index agree, but HEAD does not. This is + // the pre-commit-hook shape, where everything is added before the hook runs. + testutil.WriteFile(t, filepath.Join(dir, "staged.ts"), "const staged = 2;\n") + testutil.GitAdd(t, dir, "staged.ts") + + // A staged deletion leaves no file to format and must stay out. + testutil.Run(t, dir, "git", "rm", "-q", "removed.ts") + + files, warnings, err := collectFormattable(t, dir, false, gitfiles.SelectionChanged) + + if err != nil { + t.Fatalf("collect: %v", err) + } + + if len(warnings) != 0 { + t.Fatalf("unexpected warnings: %v", warnings) + } + + want := []string{filepath.Join(dir, "staged.ts")} + + if !reflect.DeepEqual(files, want) { + t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, files) + } +} + +func TestCollectChangedWorksBeforeTheFirstCommit(t *testing.T) { + dir := testutil.InitRepo(t) + testutil.WriteFile(t, filepath.Join(dir, "staged.ts"), "const staged = 1;\n") + testutil.GitAdd(t, dir, "staged.ts") + + files, warnings, err := collectFormattable(t, dir, false, gitfiles.SelectionChanged) + + if err != nil { + t.Fatalf("collect: %v", err) + } + + if len(warnings) != 0 { + t.Fatalf("unexpected warnings: %v", warnings) + } + + want := []string{filepath.Join(dir, "staged.ts")} + + if !reflect.DeepEqual(files, want) { + t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, files) + } +} + +func TestCollectAllCoversCommittedFilesThatChangedSelectionSkips(t *testing.T) { + dir := testutil.InitRepo(t) + testutil.WriteFile(t, filepath.Join(dir, "untouched.ts"), "const untouched = 1;\n") + testutil.GitAdd(t, dir, "untouched.ts") + testutil.GitCommit(t, dir) + + changed, _, err := collectFormattable(t, dir, false, gitfiles.SelectionChanged) + + if err != nil { + t.Fatalf("collect changed: %v", err) + } + + if len(changed) != 0 { + t.Fatalf("a clean working tree has no changes, got: %#v", changed) + } + + all, _, err := collectFormattable(t, dir, false, gitfiles.SelectionAll) + + if err != nil { + t.Fatalf("collect all: %v", err) + } + + want := []string{filepath.Join(dir, "untouched.ts")} + + if !reflect.DeepEqual(all, want) { + t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, all) + } +} + +func TestCollectDefaultsToAll(t *testing.T) { + dir := testutil.InitRepo(t) + testutil.WriteFile(t, filepath.Join(dir, "untouched.ts"), "const untouched = 1;\n") + testutil.GitAdd(t, dir, "untouched.ts") + testutil.GitCommit(t, dir) + + // The zero gitfiles.Selection is SelectionAll, so a Collector built with it + // must cover committed files a changed run would skip. + files, _, err := collectFormattable(t, dir, false, gitfiles.Selection(0)) + + if err != nil { + t.Fatalf("collect: %v", err) + } + + want := []string{filepath.Join(dir, "untouched.ts")} + + if !reflect.DeepEqual(files, want) { + t.Fatalf("the zero Selection must cover everything\nwant: %#v\n got: %#v", want, files) + } +} diff --git a/packages/go/driver/internal/typescript/step.go b/packages/go/driver/internal/typescript/step.go new file mode 100644 index 0000000..f827d1d --- /dev/null +++ b/packages/go/driver/internal/typescript/step.go @@ -0,0 +1,190 @@ +// Package typescript is the TS/Vue lane: it lints (oxlint) and formats (the +// oxfmt pipeline plus the project passes) TS, Vue, HTML, and Markdown files, +// contributing the lint and format steps to the pipeline. The lane's machinery +// is split across subpackages — runtime (toolchain extraction and spawning), +// proto (the wire protocol), sourcefiles/filetypes/prettierignore (file +// discovery), and embedded (the assets baked into release binaries) — while +// this package builds the pipeline steps that drive them. +package typescript + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os/exec" + "strings" + + "go.ollin.sh/fmtkit/driver/internal/gitfiles" + "go.ollin.sh/fmtkit/driver/internal/pipeline" + "go.ollin.sh/fmtkit/driver/internal/toolchain" + "go.ollin.sh/fmtkit/driver/internal/typescript/proto" + "go.ollin.sh/fmtkit/driver/internal/typescript/runtime" +) + +// Toolchain is the TS/Vue lane. +type Toolchain struct{} + +type lintStep struct { + version string + paths []string + selection gitfiles.Selection +} + +type formatStep struct { + version string + paths []string + selection gitfiles.Selection +} + +// New builds the TS toolchain. +func New() Toolchain { return Toolchain{} } + +// Name is the lane's selector, matching the --ts flag. +func (Toolchain) Name() string { return "ts" } + +// Steps returns the TS lane's ordered steps. Lint runs first so the formatting +// passes normalize whatever oxlint rewrites. +func (Toolchain) Steps(req toolchain.Request) []pipeline.Step { + return []pipeline.Step{ + LintStep(req.Version, req.Paths, req.Selection), + FormatStep(req.Version, req.Paths, req.Selection), + } +} + +// LintStep builds the step that lints TS/Vue files, applying oxlint's safe +// fixes (--fix). +func LintStep(version string, paths []string, selection gitfiles.Selection) pipeline.Step { + return lintStep{version: version, paths: paths, selection: selection} +} + +// FormatStep builds the step that runs the full TS/Vue formatting pipeline +// (oxfmt plus the project passes). +func FormatStep(version string, paths []string, selection gitfiles.Selection) pipeline.Step { + return formatStep{version: version, paths: paths, selection: selection} +} + +// Driver-owned bookkeeping lines the TS steps recognize in their captured +// output. The sidecar's own wire lines are parsed by the proto package; these +// are notices the Go driver prints around the sidecar, so they stay here. +const ( + sourcesMissingPrefix = "[sources] path not found, skipping:" + lintNothingToLintLine = "[lint] no TS/Vue files to lint." +) + +func (s lintStep) Label() string { return "Running TS/Vue lint" } + +func (s lintStep) Run(ctx context.Context, output io.Writer) pipeline.Result { + var captured bytes.Buffer + + err := invoke(s.version, io.MultiWriter(output, &captured), func(invoker runtime.Invoker, w io.Writer) error { + return invoker.RunLint(ctx, runtime.Request{Scopes: s.paths, Selection: s.selection, Fix: true, Stdout: w, Stderr: w}) + }) + + if code := exitCode(err, output); code != 0 { + return pipeline.Result{ExitCode: code} + } + + return pipeline.Result{Details: lintDetails(captured.String())} +} + +func (s formatStep) Label() string { return "Running TS/Vue formatting" } + +func (s formatStep) Run(ctx context.Context, output io.Writer) pipeline.Result { + var captured bytes.Buffer + + err := invoke(s.version, io.MultiWriter(output, &captured), func(invoker runtime.Invoker, w io.Writer) error { + return invoker.RunPipeline(ctx, runtime.Request{Scopes: s.paths, Selection: s.selection, Stdout: w, Stderr: w}) + }) + + if code := exitCode(err, output); code != 0 { + return pipeline.Result{ExitCode: code} + } + + return pipeline.Result{Details: formatDetails(captured.String())} +} + +// invoke resolves the TS toolchain and invokes it through spawn, which receives +// the constructed Invoker and the writer to stream tool output to. +func invoke(version string, output io.Writer, spawn func(runtime.Invoker, io.Writer) error) error { + assets, err := runtime.Resolve(version) + + if err != nil { + return err + } + + return spawn(runtime.NewInvoker(assets), output) +} + +// exitCode maps a TS step error to its exit code. Failures that never produced +// tool output (a missing sidecar, an unreadable working tree) surface their +// message through output so they are visible both live and in the quiet failure +// dump. +func exitCode(err error, output io.Writer) int { + if err == nil { + return 0 + } + + if exit, ok := errors.AsType[*exec.ExitError](err); ok { + return exit.ExitCode() + } + + _, _ = io.WriteString(output, err.Error()+"\n") + + return 1 +} + +// lintDetails derives the oxlint summary line. A driver "no files" notice wins; +// otherwise oxlint's own result line; otherwise a clean fallback. +func lintDetails(log string) []pipeline.Detail { + for line := range strings.SplitSeq(log, "\n") { + if strings.HasPrefix(line, lintNothingToLintLine) { + return []pipeline.Detail{{Label: "oxlint", Value: strings.TrimPrefix(lintNothingToLintLine, "[lint] ")}} + } + } + + if result := proto.ParseLintSummary(log).Result; result != "" { + return []pipeline.Detail{{Label: "oxlint", Value: result}} + } + + return []pipeline.Detail{{Label: "oxlint", Value: "no issues found"}} +} + +// formatDetails derives the TS pipeline's detail lines from the sidecar's +// progress output plus the driver's missing-source notices. +func formatDetails(log string) []pipeline.Detail { + summary := proto.ParsePipelineSummary(log) + + var details []pipeline.Detail + + if summary.BlankLines != "" { + details = append(details, pipeline.Detail{Label: "blank-lines", Value: summary.BlankLines}) + } + + missing := 0 + + for line := range strings.SplitSeq(log, "\n") { + if strings.HasPrefix(line, sourcesMissingPrefix) { + missing++ + } + } + + if missing > 0 { + details = append(details, pipeline.Detail{Label: "skipped", Value: fmt.Sprintf("%d missing tracked file(s)", missing)}) + } + + if summary.Oxfmt != "" { + details = append(details, pipeline.Detail{Label: "oxfmt", Value: summary.Oxfmt}) + } + + if summary.FluentChains != "" { + details = append(details, pipeline.Detail{Label: "fluent", Value: summary.FluentChains}) + } + + if summary.ValidateSyntax != "" { + details = append(details, pipeline.Detail{Label: "validated", Value: summary.ValidateSyntax}) + } + + return details +} diff --git a/packages/go/driver/internal/typescript/step_test.go b/packages/go/driver/internal/typescript/step_test.go new file mode 100644 index 0000000..de24053 --- /dev/null +++ b/packages/go/driver/internal/typescript/step_test.go @@ -0,0 +1,104 @@ +package typescript + +import ( + "bytes" + "errors" + "fmt" + "testing" + + "go.ollin.sh/fmtkit/driver/internal/pipeline" + "go.ollin.sh/fmtkit/driver/internal/toolchain" +) + +func detailStrings(details []pipeline.Detail) []string { + out := make([]string, 0, len(details)) + + for _, d := range details { + out = append(out, d.Label+"|"+d.Value) + } + + return out +} + +func assertDetails(t *testing.T, got []pipeline.Detail, want ...string) { + t.Helper() + + if g := fmt.Sprint(detailStrings(got)); g != fmt.Sprint(want) { + t.Fatalf("details mismatch\n--- got ---\n%s\n--- want ---\n%s", g, fmt.Sprint(want)) + } +} + +func TestFormatDetails(t *testing.T) { + log := "[blank-lines] processed 3 file(s) in /work, 0 changed\n" + + "Finished in 10ms on 3 files using 8 threads.\n" + + "[fluent-chains] processed 3 file(s) in /work, 1 changed\n" + + "[validate-syntax] checked 3 file(s).\n" + + assertDetails(t, formatDetails(log), + "blank-lines|processed 3 file(s) in /work, 0 changed", + "oxfmt|Finished in 10ms on 3 files using 8 threads.", + "fluent|processed 3 file(s) in /work, 1 changed", + "validated|checked 3 file(s).", + ) +} + +func TestFormatDetailsCountsMissing(t *testing.T) { + log := "[sources] path not found, skipping: /work/a\n" + + "[sources] path not found, skipping: /work/b\n" + + assertDetails(t, formatDetails(log), "skipped|2 missing tracked file(s)") +} + +func TestLintDetailsResult(t *testing.T) { + assertDetails(t, lintDetails("Found 0 warnings and 0 errors.\n"), "oxlint|Found 0 warnings and 0 errors.") +} + +func TestLintDetailsNoFiles(t *testing.T) { + assertDetails(t, lintDetails("[lint] no TS/Vue files to lint.\n"), "oxlint|no TS/Vue files to lint.") +} + +func TestLintDetailsFallback(t *testing.T) { + assertDetails(t, lintDetails("nothing interesting\n"), "oxlint|no issues found") +} + +func TestExitCodePlainErrorWritesToOutput(t *testing.T) { + var buf bytes.Buffer + + if code := exitCode(errors.New("boom"), &buf); code != 1 { + t.Fatalf("exitCode = %d, want 1", code) + } + + if buf.String() != "boom\n" { + t.Fatalf("exitCode output = %q, want %q", buf.String(), "boom\n") + } +} + +func TestExitCodeNil(t *testing.T) { + var buf bytes.Buffer + + if code := exitCode(nil, &buf); code != 0 { + t.Fatalf("exitCode(nil) = %d, want 0", code) + } + + if buf.Len() != 0 { + t.Fatalf("exitCode(nil) wrote %q", buf.String()) + } +} + +func TestSteps(t *testing.T) { + steps := New().Steps(toolchain.Request{Version: "dev", Paths: []string{"."}}) + + labels := make([]string, 0, len(steps)) + + for _, s := range steps { + labels = append(labels, s.Label()) + } + + if got := fmt.Sprint(labels); got != fmt.Sprint([]string{"Running TS/Vue lint", "Running TS/Vue formatting"}) { + t.Fatalf("step labels = %s, want [Running TS/Vue lint, Running TS/Vue formatting]", got) + } + + if got := New().Name(); got != "ts" { + t.Fatalf("Name = %q, want ts", got) + } +} diff --git a/packages/go/driver/package.json b/packages/go/driver/package.json index 2da09a1..057dc1d 100644 --- a/packages/go/driver/package.json +++ b/packages/go/driver/package.json @@ -2,10 +2,10 @@ "name": "driver", "private": true, "scripts": { - "build": "cd ../../.. && ./infra/task.sh build", - "check": "../infra/task.sh check", - "gofmt": "../infra/task.sh gofmt", - "test": "../infra/task.sh test", - "vet": "../infra/task.sh vet" + "build": "cd ../../.. && ./scripts/task.sh build", + "check": "../scripts/task.sh check", + "gofmt": "../scripts/task.sh gofmt", + "test": "../scripts/task.sh test", + "vet": "../scripts/task.sh vet" } } diff --git a/packages/go/driver/report/agent.go b/packages/go/driver/report/agent.go index fe00cc6..fc18e71 100644 --- a/packages/go/driver/report/agent.go +++ b/packages/go/driver/report/agent.go @@ -42,12 +42,12 @@ type agentViolation struct { Message string `json:"message"` } -// RenderAgent writes the agent-oriented JSON report representation. -func RenderAgent(w io.Writer, cwd string, report Combined) error { +// renderAgent writes the agent-oriented JSON report representation. +func (r Renderer) renderAgent(w io.Writer, report Combined) error { encoder := json.NewEncoder(w) encoder.SetIndent("", " ") - return encoder.Encode(toAgentReport(projectReport(cwd, report))) + return encoder.Encode(toAgentReport(projectReport(r.Root, report))) } func toAgentReport(report projectedReport) agentReport { diff --git a/packages/go/driver/report/json.go b/packages/go/driver/report/json.go index 20904f4..7a1c20a 100644 --- a/packages/go/driver/report/json.go +++ b/packages/go/driver/report/json.go @@ -37,9 +37,9 @@ type jsonViolation struct { Message string `json:"message"` } -// RenderJSON writes the JSON report representation. -func RenderJSON(w io.Writer, cwd string, report Combined) error { - return json.NewEncoder(w).Encode(toJSONReport(projectReport(cwd, report))) +// renderJSON writes the JSON report representation. +func (r Renderer) renderJSON(w io.Writer, report Combined) error { + return json.NewEncoder(w).Encode(toJSONReport(projectReport(r.Root, report))) } func toJSONReport(report projectedReport) jsonReport { diff --git a/packages/go/driver/report/projection.go b/packages/go/driver/report/projection.go index 7a67b7f..f839168 100644 --- a/packages/go/driver/report/projection.go +++ b/packages/go/driver/report/projection.go @@ -82,7 +82,7 @@ func projectFormatterReport(cwd string, report Combined) projectedFormatterRepor func projectVetReport(cwd string, report Combined) projectedVetReport { out := projectedVetReport{ - Status: vetStatus(report.Vet), + Status: VetStatus(report.Vet), } for _, result := range report.Vet.Errors { diff --git a/packages/go/driver/report/projection_test.go b/packages/go/driver/report/projection_test.go index 75e1c67..e79da82 100644 --- a/packages/go/driver/report/projection_test.go +++ b/packages/go/driver/report/projection_test.go @@ -52,7 +52,7 @@ func TestProjectReportNormalizesFormatterAndVetResults(t *testing.T) { func TestRenderJSONUsesProjectedReport(t *testing.T) { var out bytes.Buffer - if err := RenderJSON(&out, "/work", sampleCombinedReport()); err != nil { + if err := (Renderer{Root: "/work"}).renderJSON(&out, sampleCombinedReport()); err != nil { t.Fatalf("render json: %v", err) } @@ -66,7 +66,7 @@ func TestRenderJSONUsesProjectedReport(t *testing.T) { func TestRenderAgentUsesProjectedReport(t *testing.T) { var out bytes.Buffer - if err := RenderAgent(&out, "/work", sampleCombinedReport()); err != nil { + if err := (Renderer{Root: "/work"}).renderAgent(&out, sampleCombinedReport()); err != nil { t.Fatalf("render agent: %v", err) } diff --git a/packages/go/driver/report/render.go b/packages/go/driver/report/render.go index 4225ccc..0154528 100644 --- a/packages/go/driver/report/render.go +++ b/packages/go/driver/report/render.go @@ -9,26 +9,94 @@ import ( "go.ollin.sh/fmtkit/vet" ) +// Mode is whether the CLI is checking or rewriting files. It drives the verbs +// in the text render ("Checked"/"would apply" vs "Formatted"/"applied") and the +// exit-code policy (see Combined.ExitCode). +type Mode string + +// Format is the output representation the CLI renders. +type Format string + // Combined contains the formatter and vet reports rendered by the CLI. type Combined struct { Formatter formatterengine.Report `json:"formatter"` Vet vet.Report `json:"vet"` } +// Renderer writes a Combined report. Root is the base that file paths are made +// relative to; Mode selects the check/format verbs in the text render. +type Renderer struct { + Root string + Mode Mode +} + type jsonErrorMessage struct { File string `json:"file"` Message string `json:"message"` } +const ( + // ModeCheck reports what would change without touching files. + ModeCheck Mode = "check" + + // ModeFormat rewrites files in place. + ModeFormat Mode = "format" +) + +const ( + // FormatText is the human-readable, sectioned report. + FormatText Format = "text" + + // FormatJSON is the compact single-line JSON report. + FormatJSON Format = "json" + + // FormatAgent is the indented, agent-oriented JSON report. + FormatAgent Format = "agent" +) + +// ParseFormat resolves a --format flag value to a Format. Unknown values are +// rejected with the same error the CLI has always returned for them. +func ParseFormat(s string) (Format, error) { + switch Format(s) { + case FormatText, FormatJSON, FormatAgent: + return Format(s), nil + default: + return "", errors.New("unsupported output format") + } +} + +// ExitCode maps a combined report onto a process exit code for the given mode. +// Vet errors always fail. In check mode any non-pass formatter result fails; in +// format mode only formatter errors (not fixable violations) fail. +func (c Combined) ExitCode(m Mode) int { + if c.Vet.ErrorCount() > 0 { + return 1 + } + + if m == ModeCheck { + if c.Formatter.Result == formatterengine.ResultPass { + return 0 + } + + return 1 + } + + if c.Formatter.ErrorCount() > 0 { + return 1 + } + + return 0 +} + // Render writes the report in the requested output format. -func Render(w io.Writer, format, cwd, mode string, report Combined) error { +func (r Renderer) Render(w io.Writer, format Format, report Combined) error { switch format { - case "text": - return RenderText(w, cwd, mode, report) - case "json": - return RenderJSON(w, cwd, report) - case "agent": - return RenderAgent(w, cwd, report) + case FormatText: + return r.renderText(w, report) + case FormatJSON: + return r.renderJSON(w, report) + case FormatAgent: + return r.renderAgent(w, report) default: return errors.New("unsupported output format") } @@ -52,7 +120,10 @@ func combinedResult(report Combined) string { return string(report.Formatter.Result) } -func vetStatus(report vet.Report) string { +// VetStatus classifies a vet report as "skipped", "fail", or "pass". The Go +// lane's pipeline step reports the same classification, so both read it here +// rather than each restating the rule. +func VetStatus(report vet.Report) string { switch { case report.Skipped || report.Root == "": return "skipped" @@ -62,3 +133,23 @@ func vetStatus(report vet.Report) string { return "pass" } } + +// VetSummary is the one-line vet status sentence, or "" for a failure, whose +// per-error lines are shown instead of a summary. The text render wraps this in +// color; the pipeline step prints it as-is. +func VetSummary(report vet.Report) string { + switch VetStatus(report) { + case "skipped": + reason := "no Go module or workspace was detected" + + if report.Skipped { + reason = "the Go toolchain is not available" + } + + return "Skipped automatic go vet ./... because " + reason + "." + case "pass": + return "go vet ./... passed." + default: + return "" + } +} diff --git a/packages/go/driver/report/render_test.go b/packages/go/driver/report/render_test.go index ab509ff..feee815 100644 --- a/packages/go/driver/report/render_test.go +++ b/packages/go/driver/report/render_test.go @@ -15,10 +15,12 @@ func TestRenderDispatch(t *testing.T) { t.Cleanup(func() { color.NoColor = previous }) - for _, format := range []string{"text", "json", "agent"} { + renderer := Renderer{Root: "/work", Mode: ModeCheck} + + for _, format := range []Format{FormatText, FormatJSON, FormatAgent} { var out bytes.Buffer - if err := Render(&out, format, "/work", "check", sampleCombinedReport()); err != nil { + if err := renderer.Render(&out, format, sampleCombinedReport()); err != nil { t.Fatalf("render %s: %v", format, err) } @@ -29,13 +31,89 @@ func TestRenderDispatch(t *testing.T) { var out bytes.Buffer - err := Render(&out, "yaml", "/work", "check", sampleCombinedReport()) + err := renderer.Render(&out, Format("yaml"), sampleCombinedReport()) if err == nil || err.Error() != "unsupported output format" { t.Fatalf("expected unsupported format error, got %v", err) } } +func TestParseFormat(t *testing.T) { + for _, tc := range []struct { + in string + want Format + }{ + {"text", FormatText}, + {"json", FormatJSON}, + {"agent", FormatAgent}, + } { + got, err := ParseFormat(tc.in) + + if err != nil { + t.Fatalf("ParseFormat(%q): %v", tc.in, err) + } + + if got != tc.want { + t.Fatalf("ParseFormat(%q) = %q, want %q", tc.in, got, tc.want) + } + } + + if _, err := ParseFormat("yaml"); err == nil || err.Error() != "unsupported output format" { + t.Fatalf("expected unsupported format error, got %v", err) + } +} + +func TestExitCode(t *testing.T) { + cases := []struct { + name string + mode Mode + report Combined + want int + }{ + { + name: "vet errors fail either mode", + mode: ModeFormat, + report: Combined{Vet: vet.Report{Errors: []vet.ErrorResult{{Message: "boom"}}}}, + want: 1, + }, + { + name: "check passes on pass result", + mode: ModeCheck, + report: Combined{Formatter: formatterengine.Report{Result: "pass"}}, + want: 0, + }, + { + name: "check fails on non-pass result", + mode: ModeCheck, + report: Combined{Formatter: formatterengine.Report{Result: "fail"}}, + want: 1, + }, + { + name: "format fails on formatter errors", + mode: ModeFormat, + report: Combined{Formatter: formatterengine.Report{ + Result: "fail", + Errors: []formatterengine.ErrorResult{{Message: "walk failed"}}, + }}, + want: 1, + }, + { + name: "format succeeds after applying fixes", + mode: ModeFormat, + report: Combined{Formatter: formatterengine.Report{Result: "fixed"}}, + want: 0, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.report.ExitCode(tc.mode); got != tc.want { + t.Fatalf("ExitCode(%s) = %d, want %d", tc.mode, got, tc.want) + } + }) + } +} + func TestCombinedResult(t *testing.T) { cases := []struct { name string @@ -82,8 +160,8 @@ func TestVetStatus(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - if got := vetStatus(tc.report); got != tc.want { - t.Fatalf("vetStatus() = %q, want %q", got, tc.want) + if got := VetStatus(tc.report); got != tc.want { + t.Fatalf("VetStatus() = %q, want %q", got, tc.want) } }) } diff --git a/packages/go/driver/report/text.go b/packages/go/driver/report/text.go index 5314a6b..18b82a1 100644 --- a/packages/go/driver/report/text.go +++ b/packages/go/driver/report/text.go @@ -9,13 +9,13 @@ import ( formatterengine "go.ollin.sh/fmtkit/formatter/engine" ) -// RenderText writes the human-readable text report representation. -func RenderText(w io.Writer, cwd, mode string, report Combined) error { +// renderText writes the human-readable text report representation. +func (r Renderer) renderText(w io.Writer, report Combined) error { if _, err := color.New(color.Bold).Fprintf(w, "\nFormatter\n\n"); err != nil { return err } - if err := renderFormatterText(w, cwd, mode, report.Formatter); err != nil { + if err := renderFormatterText(w, r.Root, r.Mode, report.Formatter); err != nil { return err } @@ -23,10 +23,10 @@ func RenderText(w io.Writer, cwd, mode string, report Combined) error { return err } - return renderVetText(w, cwd, report) + return renderVetText(w, r.Root, report) } -func renderFormatterText(w io.Writer, cwd, mode string, report formatterengine.Report) error { +func renderFormatterText(w io.Writer, cwd string, mode Mode, report formatterengine.Report) error { if report.Files == 0 && len(report.Errors) == 0 { if _, err := color.New(color.FgYellow).Fprintf(w, " No Go files found.\n\n"); err != nil { return err @@ -42,7 +42,7 @@ func renderFormatterText(w io.Writer, cwd, mode string, report formatterengine.R } else { action := "Checked" - if mode == "format" { + if mode == ModeFormat { action = "Formatted" } @@ -87,7 +87,7 @@ func renderFormatterText(w io.Writer, cwd, mode string, report formatterengine.R if result.Changed { verb := "would apply" - if mode == "format" { + if mode == ModeFormat { verb = "applied" } @@ -102,19 +102,7 @@ func renderFormatterText(w io.Writer, cwd, mode string, report formatterengine.R } for _, result := range report.Errors { - rel := relativePath(cwd, result.File) - - if rel != "" && rel != "." { - if _, err := color.New(color.FgCyan, color.Bold).Fprintf(w, " %s\n", rel); err != nil { - return err - } - } else { - if _, err := color.New(color.FgCyan, color.Bold).Fprintf(w, " workspace\n"); err != nil { - return err - } - } - - if _, err := color.New(color.FgRed).Fprintf(w, " ! %s\n\n", result.Message); err != nil { + if err := renderErrorEntry(w, cwd, result.File, result.Message); err != nil { return err } } @@ -134,37 +122,44 @@ func renderFormatterText(w io.Writer, cwd, mode string, report formatterengine.R return err } -func renderVetText(w io.Writer, cwd string, report Combined) error { - switch vetStatus(report.Vet) { - case "skipped": - reason := "no Go module or workspace was detected" +// renderErrorEntry writes one error: its path relative to cwd (or "workspace" +// when the error belongs to no single file), then the message. +// +// It takes the two fields rather than a record because the formatter and vet +// carry their own identically shaped error types. Go has no field-access +// constraint, so a type parameter here would have to be handed accessor +// closures by every caller — more code than the two strings it would abstract. +func renderErrorEntry(w io.Writer, cwd string, file string, message string) error { + rel := relativePath(cwd, file) - if report.Vet.Skipped { - reason = "the Go toolchain is not available" - } + label := "workspace" + + if rel != "" && rel != "." { + label = rel + } + + if _, err := color.New(color.FgCyan, color.Bold).Fprintf(w, " %s\n", label); err != nil { + return err + } + + _, err := color.New(color.FgRed).Fprintf(w, " ! %s\n\n", message) + + return err +} - if _, err := color.New(color.FgYellow).Fprint(w, " Skipped automatic go vet ./... because "+reason+".\n\n"); err != nil { +func renderVetText(w io.Writer, cwd string, report Combined) error { + switch VetStatus(report.Vet) { + case "skipped": + if _, err := color.New(color.FgYellow).Fprint(w, " "+VetSummary(report.Vet)+"\n\n"); err != nil { return err } case "pass": - if _, err := color.New(color.FgGreen).Fprintf(w, " go vet ./... passed.\n\n"); err != nil { + if _, err := color.New(color.FgGreen).Fprint(w, " "+VetSummary(report.Vet)+"\n\n"); err != nil { return err } default: for _, result := range report.Vet.Errors { - rel := relativePath(cwd, result.File) - - if rel != "" && rel != "." { - if _, err := color.New(color.FgCyan, color.Bold).Fprintf(w, " %s\n", rel); err != nil { - return err - } - } else { - if _, err := color.New(color.FgCyan, color.Bold).Fprintf(w, " workspace\n"); err != nil { - return err - } - } - - if _, err := color.New(color.FgRed).Fprintf(w, " ! %s\n\n", result.Message); err != nil { + if err := renderErrorEntry(w, cwd, result.File, result.Message); err != nil { return err } } @@ -172,7 +167,7 @@ func renderVetText(w io.Writer, cwd string, report Combined) error { summaryColor := color.New(color.Bold) - switch vetStatus(report.Vet) { + switch VetStatus(report.Vet) { case "pass": summaryColor.Add(color.FgGreen) case "skipped": @@ -181,7 +176,7 @@ func renderVetText(w io.Writer, cwd string, report Combined) error { summaryColor.Add(color.FgRed) } - _, err := summaryColor.Fprintf(w, " Result: %s. %d error(s).\n\n", vetStatus(report.Vet), report.Vet.ErrorCount()) + _, err := summaryColor.Fprintf(w, " Result: %s. %d error(s).\n\n", VetStatus(report.Vet), report.Vet.ErrorCount()) return err } diff --git a/packages/go/driver/report/text_test.go b/packages/go/driver/report/text_test.go index e4c1b7a..84d1d6e 100644 --- a/packages/go/driver/report/text_test.go +++ b/packages/go/driver/report/text_test.go @@ -13,7 +13,7 @@ import ( // renderTextPlain renders without ANSI escapes so substring asserts are // stable. color.NoColor is global state, so these tests must not run in // parallel. -func renderTextPlain(t *testing.T, cwd, mode string, report Combined) string { +func renderTextPlain(t *testing.T, cwd string, mode Mode, report Combined) string { t.Helper() previous := color.NoColor @@ -23,7 +23,7 @@ func renderTextPlain(t *testing.T, cwd, mode string, report Combined) string { var out bytes.Buffer - if err := RenderText(&out, cwd, mode, report); err != nil { + if err := (Renderer{Root: cwd, Mode: mode}).renderText(&out, report); err != nil { t.Fatalf("render text: %v", err) } @@ -40,6 +40,78 @@ func assertContainsAll(t *testing.T, output string, wants []string) { } } +// wantTextCheck and wantTextFormat pin the complete text render of +// sampleCombinedReport (changed file + violation + per-file error + workspace +// error + vet error), byte for byte, with paths projected relative to /work. +// The JSON and agent renders are mode-independent and pinned byte-for-byte in +// projection_test.go; the text render is the only one that varies with mode +// ("Checked"/"would apply" vs "Formatted"/"applied"). +const wantTextCheck = ` +Formatter + + Checked 2 file(s). + + sample.go + [spacing] line 7: after if statement + ✓ would apply spacing, gofmt + + broken.go + ! parse error + + walk.go + ! walk failed + + Result: fail. 1 changed, 1 violation(s), 2 error(s). + +Vet + + module-a + ! automatic go vet ./... failed: +vet output + + Result: fail. 1 error(s). + +` + +const wantTextFormat = ` +Formatter + + Formatted 2 file(s). + + sample.go + [spacing] line 7: after if statement + ✓ applied spacing, gofmt + + broken.go + ! parse error + + walk.go + ! walk failed + + Result: fail. 1 changed, 1 violation(s), 2 error(s). + +Vet + + module-a + ! automatic go vet ./... failed: +vet output + + Result: fail. 1 error(s). + +` + +func TestRenderTextGoldenCheckMode(t *testing.T) { + if got := renderTextPlain(t, "/work", "check", sampleCombinedReport()); got != wantTextCheck { + t.Fatalf("check-mode text mismatch\n--- got ---\n%s\n--- want ---\n%s", got, wantTextCheck) + } +} + +func TestRenderTextGoldenFormatMode(t *testing.T) { + if got := renderTextPlain(t, "/work", "format", sampleCombinedReport()); got != wantTextFormat { + t.Fatalf("format-mode text mismatch\n--- got ---\n%s\n--- want ---\n%s", got, wantTextFormat) + } +} + func TestRenderTextCheckModeFailure(t *testing.T) { output := renderTextPlain(t, "/work", "check", sampleCombinedReport()) diff --git a/packages/go/driver/testutil/files.go b/packages/go/driver/testutil/files.go index c99e0e8..11b0081 100644 --- a/packages/go/driver/testutil/files.go +++ b/packages/go/driver/testutil/files.go @@ -27,7 +27,7 @@ func WriteGoFile(t *testing.T, path string, content string) { func WriteGoMod(t *testing.T, dir string, modulePath string) { t.Helper() - WriteFile(t, filepath.Join(dir, "go.mod"), "module "+modulePath+"\n\ngo 1.26.4\n") + WriteFile(t, filepath.Join(dir, "go.mod"), "module "+modulePath+"\n\ngo 1.26.5\n") } func WriteGoWork(t *testing.T, dir string, content string) { diff --git a/packages/go/driver/testutil/git.go b/packages/go/driver/testutil/git.go new file mode 100644 index 0000000..a83d357 --- /dev/null +++ b/packages/go/driver/testutil/git.go @@ -0,0 +1,46 @@ +package testutil + +import ( + "os/exec" + "testing" +) + +// InitRepo creates a temporary git repository with the identity committing +// requires, and returns its directory. +func InitRepo(t *testing.T) string { + t.Helper() + + dir := t.TempDir() + Run(t, dir, "git", "init", "-q") + Run(t, dir, "git", "config", "user.email", "tests@example.com") + Run(t, dir, "git", "config", "user.name", "Test Runner") + + return dir +} + +// GitAdd stages paths in the repository at dir. +func GitAdd(t *testing.T, dir string, paths ...string) { + t.Helper() + + Run(t, dir, "git", append([]string{"add"}, paths...)...) +} + +// GitCommit commits whatever is staged in the repository at dir. +func GitCommit(t *testing.T, dir string) { + t.Helper() + + Run(t, dir, "git", "commit", "-q", "-m", "fixture") +} + +// Run executes name with args in dir, failing the test with the combined +// output when it exits non-zero. +func Run(t *testing.T, dir string, name string, args ...string) { + t.Helper() + + cmd := exec.Command(name, args...) + cmd.Dir = dir + + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s %v: %v\n%s", name, args, err, out) + } +} diff --git a/packages/go/formatter/engine/diff.go b/packages/go/formatter/engine/diff.go deleted file mode 100644 index a7cffec..0000000 --- a/packages/go/formatter/engine/diff.go +++ /dev/null @@ -1,38 +0,0 @@ -package engine - -import ( - "fmt" - "strings" -) - -func generateDiff(old, new string) string { - oldLines := strings.Split(old, "\n") - newLines := strings.Split(new, "\n") - - var diff strings.Builder - - i, j := 0, 0 - - for i < len(oldLines) || j < len(newLines) { - if i < len(oldLines) && j < len(newLines) && oldLines[i] == newLines[j] { - i++ - j++ - - continue - } - - if i < len(oldLines) && (j >= len(newLines) || oldLines[i] != newLines[j]) { - fmt.Fprintf(&diff, "-%s\n", oldLines[i]) - - i++ - } - - if j < len(newLines) { - fmt.Fprintf(&diff, "+%s\n", newLines[j]) - - j++ - } - } - - return diff.String() -} diff --git a/packages/go/formatter/engine/engine.go b/packages/go/formatter/engine/engine.go index 68656fe..c45d202 100644 --- a/packages/go/formatter/engine/engine.go +++ b/packages/go/formatter/engine/engine.go @@ -106,11 +106,7 @@ func (e *Engine) run(ctx context.Context, files []string, write bool) (Report, e case sem <- struct{}{}: } - wg.Add(1) - - go func(i int, file string) { - defer wg.Done() - + wg.Go(func() { defer func() { <-sem }() if ctx.Err() != nil { @@ -120,7 +116,7 @@ func (e *Engine) run(ctx context.Context, files []string, write bool) (Report, e } results[i] = e.processFile(ctx, file, write) - }(i, file) + }) } wg.Wait() @@ -156,15 +152,7 @@ func effectiveConcurrency(configured, fileCount int) int { n = runtime.NumCPU() } - if n > fileCount { - n = fileCount - } - - if n < 1 { - n = 1 - } - - return n + return max(min(n, fileCount), 1) } func (e *Engine) processFile(ctx context.Context, path string, write bool) FileResult { @@ -214,7 +202,6 @@ func (e *Engine) processFile(ctx context.Context, path string, write bool) FileR } result.Changed = true - result.Diff = generateDiff(string(original), string(current)) if write { if err := writeFileAtomic(path, current); err != nil { diff --git a/packages/go/formatter/engine/engine_test.go b/packages/go/formatter/engine/engine_test.go index cc99358..65953b2 100644 --- a/packages/go/formatter/engine/engine_test.go +++ b/packages/go/formatter/engine/engine_test.go @@ -7,6 +7,7 @@ import ( "go/format" "os" "path/filepath" + "slices" "strings" "testing" @@ -153,7 +154,7 @@ func TestCollectGoFilesSkipsHiddenVendorAndGenerated(t *testing.T) { testutil.WriteGoFile(t, filepath.Join(root, "vendor", "skip.go"), "package sample\n") testutil.WriteGoFile(t, filepath.Join(root, ".hidden", "skip.go"), "package sample\n") testutil.WriteGoFile(t, filepath.Join(root, "generated.gen.go"), "package sample\n") - testutil.WriteFile(t, filepath.Join(root, "docker", "Dockerfile.golang"), "FROM golang:1.26.4-bookworm\n") + testutil.WriteFile(t, filepath.Join(root, "docker", "Dockerfile.golang"), "FROM golang:1.26.5-bookworm\n") files, err := engine.CollectGoFiles([]string{root}, config.Default()) @@ -198,15 +199,7 @@ func TestCollectGoFilesAppliesConfiguredExclusionsAndDeduplicates(t *testing.T) t.Fatalf("abs: %v", err) } - found := false - - for _, got := range files { - if got == abs { - found = true - - break - } - } + found := slices.Contains(files, abs) if !found { t.Fatalf("expected collected files to include %s: %#v", abs, files) @@ -339,8 +332,6 @@ func TestProcessFileReportsReadRuleAndFormatterErrors(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { report, err := engine.New(config.Default(), tt.rules, tt.formatters).CheckFiles(context.Background(), tt.files) @@ -398,7 +389,7 @@ func TestFormatFilesReportsWriteErrors(t *testing.T) { } } -func TestReportCountsAndAllErrors(t *testing.T) { +func TestReportCounts(t *testing.T) { report := engine.Report{ Errors: []engine.ErrorResult{ {Message: "workspace failed"}, @@ -425,20 +416,6 @@ func TestReportCountsAndAllErrors(t *testing.T) { if got := report.ErrorCount(); got != 2 { t.Fatalf("expected 2 errors, got %d", got) } - - errors := report.AllErrors() - - if len(errors) != 2 { - t.Fatalf("expected 2 all errors, got %#v", errors) - } - - if errors[0].Message != "workspace failed" { - t.Fatalf("unexpected workspace error: %#v", errors[0]) - } - - if errors[1].File != "b.go" || errors[1].Message != "read file: denied" { - t.Fatalf("unexpected file error: %#v", errors[1]) - } } func TestFormatIsDeterministicAcrossConcurrencyLevels(t *testing.T) { @@ -457,7 +434,7 @@ func run() { root := t.TempDir() - for i := 0; i < fileCount; i++ { + for i := range fileCount { path := filepath.Join(root, fmt.Sprintf("pkg%02d", i), "sample.go") testutil.WriteGoFile(t, path, source) } diff --git a/packages/go/formatter/engine/files.go b/packages/go/formatter/engine/files.go index 4bb40ed..e7f71ba 100644 --- a/packages/go/formatter/engine/files.go +++ b/packages/go/formatter/engine/files.go @@ -77,62 +77,32 @@ func CollectGoFiles(paths []string, cfg config.Config) ([]string, error) { return files, nil } -func filterFiles(files, selected []string) []string { - if len(files) == 0 || len(selected) == 0 { - return nil - } - - allowed := make(map[string]struct{}, len(selected)) - - for _, path := range selected { - allowed[path] = struct{}{} - } - - filtered := make([]string, 0, len(files)) - - for _, path := range files { - if _, ok := allowed[path]; ok { - filtered = append(filtered, path) - } - } - - return filtered -} - func shouldSkipDir(path, root, name string, cfg config.Config) bool { if path != root && strings.HasPrefix(name, ".") { return true } - for _, excluded := range cfg.Exclude { - if name == excluded { - return true - } - } - - return false + return slices.Contains(cfg.Exclude, name) } func isExcludedFile(path string, cfg config.Config) bool { base := filepath.Base(path) - for _, pattern := range cfg.NotName { + nameExcluded := slices.ContainsFunc(cfg.NotName, func(pattern string) bool { matched, _ := filepath.Match(pattern, base) - if matched { - return true - } + return matched + }) + + if nameExcluded { + return true } slashed := filepath.ToSlash(path) - for _, pattern := range cfg.NotPath { - if strings.Contains(slashed, pattern) { - return true - } - } - - return false + return slices.ContainsFunc(cfg.NotPath, func(pattern string) bool { + return strings.Contains(slashed, pattern) + }) } func isGoSource(path string) bool { diff --git a/packages/go/formatter/engine/files_internal_test.go b/packages/go/formatter/engine/files_internal_test.go index 889d4ee..efc79d1 100644 --- a/packages/go/formatter/engine/files_internal_test.go +++ b/packages/go/formatter/engine/files_internal_test.go @@ -9,31 +9,6 @@ import ( "go.ollin.sh/fmtkit/formatter/config" ) -func TestFilterFiles(t *testing.T) { - files := []string{"a.go", "b.go", "c.go"} - - if got := filterFiles(nil, []string{"a.go"}); got != nil { - t.Fatalf("expected nil for empty files, got %#v", got) - } - - if got := filterFiles(files, nil); got != nil { - t.Fatalf("expected nil for empty selected, got %#v", got) - } - - got := filterFiles(files, []string{"c.go", "a.go"}) - want := []string{"a.go", "c.go"} - - if len(got) != len(want) { - t.Fatalf("expected %#v, got %#v", want, got) - } - - for i := range want { - if got[i] != want[i] { - t.Fatalf("expected %#v, got %#v", want, got) - } - } -} - func TestEffectiveConcurrencyBounds(t *testing.T) { tests := []struct { name string @@ -48,8 +23,6 @@ func TestEffectiveConcurrencyBounds(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { if got := effectiveConcurrency(tt.configured, tt.fileCount); got != tt.want { t.Fatalf("expected %d, got %d", tt.want, got) diff --git a/packages/go/formatter/engine/report.go b/packages/go/formatter/engine/report.go index 2e51062..0a53c3e 100644 --- a/packages/go/formatter/engine/report.go +++ b/packages/go/formatter/engine/report.go @@ -10,7 +10,6 @@ type FileResult struct { File string `json:"file"` Applied []string `json:"applied,omitempty"` Violations []rules.Violation `json:"violations,omitempty"` - Diff string `json:"diff,omitempty"` Error string `json:"error,omitempty"` Changed bool `json:"changed,omitempty"` } @@ -61,21 +60,3 @@ func (r Report) ErrorCount() int { return total } - -// AllErrors returns every engine error, including per-file errors. -func (r Report) AllErrors() []ErrorResult { - out := append([]ErrorResult(nil), r.Errors...) - - for _, result := range r.Results { - if result.Error == "" { - continue - } - - out = append(out, ErrorResult{ - File: result.File, - Message: result.Error, - }) - } - - return out -} diff --git a/packages/go/formatter/formatter_test.go b/packages/go/formatter/formatter_test.go index cb25a4a..21f4416 100644 --- a/packages/go/formatter/formatter_test.go +++ b/packages/go/formatter/formatter_test.go @@ -86,8 +86,6 @@ func TestFormatRepairsGoEmbedDirectivePlacement(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -138,8 +136,6 @@ func TestFormatPreservesImportsBeforeAnchoredDecls(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/packages/go/formatter/package.json b/packages/go/formatter/package.json index caeed5a..c76c1f0 100644 --- a/packages/go/formatter/package.json +++ b/packages/go/formatter/package.json @@ -2,9 +2,9 @@ "name": "formatter", "private": true, "scripts": { - "check": "../infra/task.sh check", - "gofmt": "../infra/task.sh gofmt", - "test": "../infra/task.sh test", - "vet": "../infra/task.sh vet" + "check": "../scripts/task.sh check", + "gofmt": "../scripts/task.sh gofmt", + "test": "../scripts/task.sh test", + "vet": "../scripts/task.sh vet" } } diff --git a/packages/go/formatter/rules/spacing/context.go b/packages/go/formatter/rules/spacing/context.go new file mode 100644 index 0000000..8d496eb --- /dev/null +++ b/packages/go/formatter/rules/spacing/context.go @@ -0,0 +1,67 @@ +package spacing + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" +) + +// importAliases maps the identifier a file uses to the standard-library import +// path it refers to, restricted to the packages whose selector calls the +// spacing rule brackets with blank lines. +type importAliases map[string]string + +// fileContext holds the parse state a spacing pass shares: the file is parsed +// once and the derived line-start and import-alias tables are built alongside it +// so every analyzer reads the same view of the source. +type fileContext struct { + fset *token.FileSet + file *ast.File + src []byte + lineStarts []int + aliases importAliases +} + +// stdlibSpacingImports returns the standard-library import paths whose selector +// calls receive blank-line spacing, keyed to the identifier each import binds by +// default. It replaces a package-level map so the table cannot be mutated at run +// time and is rebuilt fresh for every file. +func stdlibSpacingImports() map[string]string { + return map[string]string{ + "sort": "sort", + "slices": "slices", + "math/rand": "rand", + "math/rand/v2": "rand", + } +} + +// newFileContext parses src and precomputes the shared line-start and +// import-alias tables. It returns an error when the source cannot be parsed or +// the parsed file has no backing token file. +func newFileContext(filename string, src []byte) (*fileContext, error) { + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, filename, src, parser.ParseComments) + + if err != nil { + return nil, err + } + + if fset.File(file.Pos()) == nil { + return nil, fmt.Errorf("missing token file for %s", filename) + } + + return &fileContext{ + fset: fset, + file: file, + src: src, + lineStarts: buildLineStarts(src), + aliases: buildImportAliases(file), + }, nil +} + +// lineStartOffset returns the byte offset at which the given 1-based line begins, +// using the precomputed line-start table. +func (c *fileContext) lineStartOffset(line int) int { + return lineStartOffset(c.lineStarts, line) +} diff --git a/packages/go/formatter/rules/spacing/corpus_test.go b/packages/go/formatter/rules/spacing/corpus_test.go new file mode 100644 index 0000000..9d44986 --- /dev/null +++ b/packages/go/formatter/rules/spacing/corpus_test.go @@ -0,0 +1,69 @@ +package spacing + +import ( + "flag" + "os" + "path/filepath" + "strings" + "testing" +) + +var updateCorpus = flag.Bool("update", false, "rewrite spacing corpus golden files") + +// TestSpacingCorpus runs each testdata/corpus/*.input fixture through +// New().Apply and asserts the rewritten source matches its .golden pair byte +// for byte. The corpus characterizes the current spacing behavior across +// statement gaps, selector-call setup spacing, type-declaration spacing, type +// ordering, embed-directive repair/collapse, and import aliases so later +// refactor stages cannot silently change the output. The fixtures use a +// non-.go extension so the repo's own format-all walk leaves them untouched. +// Regenerate with `go test ./formatter/rules/spacing -run TestSpacingCorpus -update`. +func TestSpacingCorpus(t *testing.T) { + inputs, err := filepath.Glob(filepath.Join("testdata", "corpus", "*.input")) + + if err != nil { + t.Fatalf("glob corpus: %v", err) + } + + if len(inputs) == 0 { + t.Fatal("no corpus fixtures found") + } + + for _, input := range inputs { + name := strings.TrimSuffix(filepath.Base(input), ".input") + + t.Run(name, func(t *testing.T) { + src, err := os.ReadFile(input) + + if err != nil { + t.Fatalf("read input: %v", err) + } + + _, formatted, err := New().Apply(input, src) + + if err != nil { + t.Fatalf("apply: %v", err) + } + + golden := strings.TrimSuffix(input, ".input") + ".golden" + + if *updateCorpus { + if err := os.WriteFile(golden, formatted, 0o644); err != nil { + t.Fatalf("update golden: %v", err) + } + + return + } + + want, err := os.ReadFile(golden) + + if err != nil { + t.Fatalf("read golden: %v", err) + } + + if string(formatted) != string(want) { + t.Fatalf("output mismatch for %s\n--- got ---\n%s\n--- want ---\n%s", name, formatted, want) + } + }) + } +} diff --git a/packages/go/formatter/rules/spacing/embeds.go b/packages/go/formatter/rules/spacing/embeds.go index d8e256f..fe82413 100644 --- a/packages/go/formatter/rules/spacing/embeds.go +++ b/packages/go/formatter/rules/spacing/embeds.go @@ -2,6 +2,7 @@ package spacing import ( "bytes" + "cmp" "go/ast" "go/parser" "go/token" @@ -11,6 +12,17 @@ import ( "go.ollin.sh/fmtkit/formatter/rules" ) +// embedDirectiveRepairer keeps go:embed directives immediately above the var +// declaration they annotate, reading the shared parse state through ctx. +type embedDirectiveRepairer struct { + ctx *fileContext +} + +// newEmbedDirectiveRepairer returns a repairer bound to the shared parse state. +func newEmbedDirectiveRepairer(ctx *fileContext) *embedDirectiveRepairer { + return &embedDirectiveRepairer{ctx: ctx} +} + func attachEmbedDirectiveDocs(file *ast.File) { for decl, group := range embedDirectiveMatches(file) { genDecl, ok := decl.(*ast.GenDecl) @@ -23,12 +35,14 @@ func attachEmbedDirectiveDocs(file *ast.File) { } } -func embedAdjacencyViolations(file *ast.File, fset *token.FileSet, filename string) []rules.Violation { +// analyze reports every go:embed directive separated from the var declaration it +// annotates, labelling the returned violations with filename. +func (e *embedDirectiveRepairer) analyze(filename string) []rules.Violation { var violations []rules.Violation - for decl, group := range embedDirectiveMatches(file) { - commentEndLine := fset.Position(group.End()).Line - declLine := fset.Position(decl.Pos()).Line + for decl, group := range embedDirectiveMatches(e.ctx.file) { + commentEndLine := e.ctx.fset.Position(group.End()).Line + declLine := e.ctx.fset.Position(decl.Pos()).Line if declLine == commentEndLine+1 { continue @@ -45,7 +59,11 @@ func embedAdjacencyViolations(file *ast.File, fset *token.FileSet, filename stri return violations } -func repairDetachedEmbedDirectives(filename string, src []byte) ([]byte, error) { +// repair re-parses src — which may already carry the type reorder — and moves +// every detached go:embed directive group back immediately above its var +// declaration. It parses its own file rather than reusing ctx because it operates +// on the transformed bytes. +func (e *embedDirectiveRepairer) repair(filename string, src []byte) ([]byte, error) { fset := token.NewFileSet() file, err := parser.ParseFile(fset, filename, src, parser.ParseComments) @@ -82,15 +100,10 @@ func repairDetachedEmbedDirectives(filename string, src []byte) ([]byte, error) lines := bytes.SplitAfter(src, []byte{'\n'}) + // Descending: moves are applied bottom-up so the line indices of the moves + // still to come stay valid. Hence b before a. slices.SortStableFunc(moves, func(a embedMove, b embedMove) int { - switch { - case a.commentStartLine > b.commentStartLine: - return -1 - case a.commentStartLine < b.commentStartLine: - return 1 - default: - return 0 - } + return cmp.Compare(b.commentStartLine, a.commentStartLine) }) for _, move := range moves { @@ -175,8 +188,14 @@ func nextTopLevelVarDeclAfter(decls []ast.Decl, pos token.Pos) (ast.Decl, bool) return nil, false } +// isEmbedDirectiveText reports whether text is a go:embed directive carrying at +// least one pattern. The directive grammar comes from go/ast, so a bare +// //go:embed, a longer name such as //go:embedded, and anything that is not a +// directive at all are rejected without restating those rules here. func isEmbedDirectiveText(text string) bool { - return hasEmbedDirectivePrefix(strings.TrimSpace(text)) + directive, ok := ast.ParseDirective(token.NoPos, strings.TrimSpace(text)) + + return ok && directive.Tool == "go" && directive.Name == "embed" && directive.Args != "" } func containsEmbedDirective(group *ast.CommentGroup) bool { @@ -184,15 +203,14 @@ func containsEmbedDirective(group *ast.CommentGroup) bool { return false } - for _, comment := range group.List { - if isEmbedDirectiveText(comment.Text) { - return true - } - } - - return false + return slices.ContainsFunc(group.List, func(comment *ast.Comment) bool { + return isEmbedDirectiveText(comment.Text) + }) } +// collapseEmbedSpacing removes a single blank line left between a go:embed +// directive and the var declaration it annotates. It works purely on bytes with +// no parse state, so it stays a free function the repairer's collapse step calls. func collapseEmbedSpacing(src []byte) []byte { lines := bytes.Split(src, []byte{'\n'}) out := make([][]byte, 0, len(lines)) @@ -223,37 +241,7 @@ func collapseEmbedSpacing(src []byte) []byte { } func isEmbedDirectiveLine(line []byte) bool { - return hasEmbedDirectiveLinePrefix(bytes.TrimSpace(line)) -} - -func hasEmbedDirectivePrefix(text string) bool { - const prefix = "//go:embed" - - if !strings.HasPrefix(text, prefix) || len(text) == len(prefix) { - return false - } - - switch text[len(prefix)] { - case ' ', '\t': - return true - default: - return false - } -} - -func hasEmbedDirectiveLinePrefix(line []byte) bool { - const prefix = "//go:embed" - - if !bytes.HasPrefix(line, []byte(prefix)) || len(line) == len(prefix) { - return false - } - - switch line[len(prefix)] { - case ' ', '\t': - return true - default: - return false - } + return isEmbedDirectiveText(string(line)) } func isVarDeclStart(line []byte) bool { diff --git a/packages/go/formatter/rules/spacing/gaps.go b/packages/go/formatter/rules/spacing/gaps.go index 0daaab7..6090f64 100644 --- a/packages/go/formatter/rules/spacing/gaps.go +++ b/packages/go/formatter/rules/spacing/gaps.go @@ -5,49 +5,118 @@ import ( "fmt" "go/ast" "go/token" + "maps" "slices" "strconv" "strings" -) -type importAliases map[string]string + "go.ollin.sh/fmtkit/formatter/rules" +) -var stdlibSpacingImports = map[string]string{ - "sort": "sort", - "slices": "slices", - "math/rand": "rand", - "math/rand/v2": "rand", +// blankLineInserter records the blank lines the spacing rule must add between +// statements and top-level declarations, then applies them to the source. It +// reads the shared parse state through ctx and accumulates the byte offsets to +// split in insertions. +type blankLineInserter struct { + ctx *fileContext + insertions map[int]struct{} } -func setupSpacingLine(list []ast.Stmt, index int, current ast.Stmt, next ast.Stmt, aliases importAliases, fset *token.FileSet) (int, bool) { - if index == 0 { - return 0, false +// newBlankLineInserter returns an inserter bound to the shared parse state. +func newBlankLineInserter(ctx *fileContext) *blankLineInserter { + return &blankLineInserter{ + ctx: ctx, + insertions: map[int]struct{}{}, } +} - receiverName, ok := selectorReceiverName(next, aliases) +// analyze walks the file's statement lists and top-level declarations, recording +// a violation and an insertion offset for every missing blank line. filename +// labels the returned violations. +func (b *blankLineInserter) analyze(filename string) []rules.Violation { + fset := b.ctx.fset + + var violations []rules.Violation + + b.inspectStmtLists(func(list []ast.Stmt) { + for i := 0; i < len(list)-1; i++ { + current := list[i] + next := list[i+1] + endLine := fset.Position(current.End()).Line + nextLine := fset.Position(next.Pos()).Line + + if currentLine, ok := b.setupSpacingLine(list, i, current, next); ok { + violations = append(violations, rules.Violation{ + Rule: "spacing", + File: filename, + Line: currentLine, + Message: "missing blank line before selector call setup", + }) + + b.insertions[b.ctx.lineStartOffset(currentLine)] = struct{}{} + } - if !ok { - return 0, false - } + if endLine == nextLine { + continue + } - assignedName, ok := assignedIdentifier(current) + if message, ok := b.statementGapRule(current, next); ok { + if nextLine < endLine+2 { + violations = append(violations, rules.Violation{ + Rule: "spacing", + File: filename, + Line: nextLine, + Message: message, + }) + + b.insertions[b.ctx.lineStartOffset(nextLine)] = struct{}{} + } + } + } + }) - if !ok || assignedName != receiverName { - return 0, false + for i := 0; i < len(b.ctx.file.Decls)-1; i++ { + current := b.ctx.file.Decls[i] + next := b.ctx.file.Decls[i+1] + + if !requiresTypeDeclSpacing(current, next) { + continue + } + + endLine := fset.Position(current.End()).Line + nextLine := fset.Position(next.Pos()).Line + + if nextLine >= endLine+2 { + continue + } + + violations = append(violations, rules.Violation{ + Rule: "spacing", + File: filename, + Line: nextLine, + Message: "missing blank line around type definition", + }) + + b.insertions[b.ctx.lineStartOffset(nextLine)] = struct{}{} } - currentLine := fset.Position(current.Pos()).Line - prevEndLine := fset.Position(list[index-1].End()).Line + return violations +} - if currentLine >= prevEndLine+2 { - return 0, false +// apply returns the source with the recorded blank-line insertions applied, or +// the source unchanged when the analysis recorded none. +func (b *blankLineInserter) apply() []byte { + if len(b.insertions) == 0 { + return b.ctx.src } - return currentLine, true + return applyInsertions(b.ctx.src, b.insertions) } -func inspectStmtLists(file *ast.File, visit func([]ast.Stmt)) { - ast.Inspect(file, func(node ast.Node) bool { +// inspectStmtLists visits every statement list in the file: block bodies, case +// clause bodies, and communication clause bodies. +func (b *blankLineInserter) inspectStmtLists(visit func([]ast.Stmt)) { + ast.Inspect(b.ctx.file, func(node ast.Node) bool { switch typed := node.(type) { case *ast.BlockStmt: visit(typed.List) @@ -61,42 +130,77 @@ func inspectStmtLists(file *ast.File, visit func([]ast.Stmt)) { }) } -func statementGapRule(current ast.Stmt, next ast.Stmt, aliases importAliases, fset *token.FileSet) (string, bool) { - if label, ok := requiresLeadingBlankLine(next, aliases); ok { +// setupSpacingLine reports the line that needs a leading blank line when the +// current statement assigns the receiver of a following spaced selector call and +// the two sit flush against the preceding statement. +func (b *blankLineInserter) setupSpacingLine(list []ast.Stmt, index int, current ast.Stmt, next ast.Stmt) (int, bool) { + if index == 0 { + return 0, false + } + + receiverName, ok := selectorReceiverName(next, b.ctx.aliases) + + if !ok { + return 0, false + } + + assignedName, ok := assignedIdentifier(current) + + if !ok || assignedName != receiverName { + return 0, false + } + + currentLine := b.ctx.fset.Position(current.Pos()).Line + prevEndLine := b.ctx.fset.Position(list[index-1].End()).Line + + if currentLine >= prevEndLine+2 { + return 0, false + } + + return currentLine, true +} + +// statementGapRule reports the message for a missing blank line between current +// and next, checking the leading rule for next before the trailing rule for +// current. +func (b *blankLineInserter) statementGapRule(current ast.Stmt, next ast.Stmt) (string, bool) { + if label, ok := b.requiresLeadingBlankLine(next); ok { return fmt.Sprintf("missing blank line before %s", label), true } - if label, ok := requiresTrailingBlankLine(current, next, aliases, fset); ok { + if label, ok := b.requiresTrailingBlankLine(current, next); ok { return fmt.Sprintf("missing blank line after %s", label), true } return "", false } -func requiresTrailingBlankLine(current ast.Stmt, next ast.Stmt, aliases importAliases, fset *token.FileSet) (string, bool) { +// requiresTrailingBlankLine reports whether current must be followed by a blank +// line, returning the label used in the violation message. +func (b *blankLineInserter) requiresTrailingBlankLine(current ast.Stmt, next ast.Stmt) (string, bool) { if isTestingHelperCall(current) { return "t.Helper call", true } - if isAnonymousFuncAssignmentStmt(current, fset) { + if isAnonymousFuncAssignmentStmt(current, b.ctx.fset) { return "anonymous function assignment", true } - if label, ok := stdlibSpacedCallLabel(current, aliases); ok { + if label, ok := stdlibSpacedCallLabel(current, b.ctx.aliases); ok { return label, true } switch current.(type) { case *ast.IfStmt, *ast.ForStmt, *ast.RangeStmt, *ast.SwitchStmt, *ast.TypeSwitchStmt, *ast.SelectStmt, *ast.DeferStmt, *ast.BranchStmt: - return statementLabel(current, aliases), true + return statementLabel(current, b.ctx.aliases), true case *ast.DeclStmt: if isTypeDeclStmt(current) { - return statementLabel(current, aliases), true + return statementLabel(current, b.ctx.aliases), true } if isVarDeclStmt(current) { if !isShortAssignStmt(next) && !isVarDeclStmt(next) { - return statementLabel(current, aliases), true + return statementLabel(current, b.ctx.aliases), true } } } @@ -104,21 +208,23 @@ func requiresTrailingBlankLine(current ast.Stmt, next ast.Stmt, aliases importAl return "", false } -func requiresLeadingBlankLine(stmt ast.Stmt, aliases importAliases) (string, bool) { +// requiresLeadingBlankLine reports whether stmt must be preceded by a blank +// line, returning the label used in the violation message. +func (b *blankLineInserter) requiresLeadingBlankLine(stmt ast.Stmt) (string, bool) { if label, ok := routeRegistryCallLabel(stmt); ok { return label, true } - if label, ok := stdlibSpacedCallLabel(stmt, aliases); ok { + if label, ok := stdlibSpacedCallLabel(stmt, b.ctx.aliases); ok { return label, true } switch stmt.(type) { case *ast.IfStmt, *ast.ForStmt, *ast.RangeStmt, *ast.SwitchStmt, *ast.TypeSwitchStmt, *ast.SelectStmt, *ast.DeferStmt, *ast.ReturnStmt, *ast.BranchStmt: - return statementLabel(stmt, aliases), true + return statementLabel(stmt, b.ctx.aliases), true case *ast.DeclStmt: if isTypeDeclStmt(stmt) || isVarDeclStmt(stmt) { - return statementLabel(stmt, aliases), true + return statementLabel(stmt, b.ctx.aliases), true } } @@ -197,6 +303,7 @@ func statementLabel(stmt ast.Stmt, aliases importAliases) string { func buildImportAliases(file *ast.File) importAliases { aliases := make(importAliases) + stdlib := stdlibSpacingImports() for _, spec := range file.Imports { path, err := strconv.Unquote(spec.Path.Value) @@ -205,7 +312,7 @@ func buildImportAliases(file *ast.File) importAliases { continue } - defaultName, ok := stdlibSpacingImports[path] + defaultName, ok := stdlib[path] if !ok { continue @@ -394,15 +501,9 @@ func isAnonymousFuncAssignmentStmt(stmt ast.Stmt, fset *token.FileSet) bool { } func hasAnonymousFuncInitializerExpr(exprs []ast.Expr, fset *token.FileSet) bool { - for _, expr := range exprs { - if !isMultiLineAnonymousFuncInitializerExpr(expr, fset) { - continue - } - - return true - } - - return false + return slices.ContainsFunc(exprs, func(expr ast.Expr) bool { + return isMultiLineAnonymousFuncInitializerExpr(expr, fset) + }) } func isMultiLineAnonymousFuncInitializerExpr(expr ast.Expr, fset *token.FileSet) bool { @@ -467,13 +568,7 @@ func lineStartOffset(starts []int, line int) int { } func applyInsertions(src []byte, insertions map[int]struct{}) []byte { - offsets := make([]int, 0, len(insertions)) - - for offset := range insertions { - offsets = append(offsets, offset) - } - - slices.Sort(offsets) + offsets := slices.Sorted(maps.Keys(insertions)) var out bytes.Buffer last := 0 diff --git a/packages/go/formatter/rules/spacing/spacing.go b/packages/go/formatter/rules/spacing/spacing.go index b24683e..29880d9 100644 --- a/packages/go/formatter/rules/spacing/spacing.go +++ b/packages/go/formatter/rules/spacing/spacing.go @@ -1,11 +1,6 @@ package spacing import ( - "fmt" - "go/ast" - "go/parser" - "go/token" - "go.ollin.sh/fmtkit/formatter/rules" ) @@ -22,106 +17,33 @@ func (Rule) Name() string { return "spacing" } +// Apply parses the source once into a shared fileContext, then runs the three +// spacing analyzers over it in a fixed order: the blank-line inserter, the +// type-order rewriter, and the embed-directive repairer. Analysis collects every +// violation up front; the rewrite phase only runs when at least one violation +// was reported, and preserves the current sequencing of insertions, type +// reordering, embed repair, and embed collapse. func (r Rule) Apply(path string, src []byte) ([]rules.Violation, []byte, error) { - return analyse(path, src) -} - -func analyse(filename string, src []byte) ([]rules.Violation, []byte, error) { - fset := token.NewFileSet() - file, err := parser.ParseFile(fset, filename, src, parser.ParseComments) + ctx, err := newFileContext(path, src) if err != nil { return nil, nil, err } - tokenFile := fset.File(file.Pos()) - - if tokenFile == nil { - return nil, nil, fmt.Errorf("missing token file for %s", filename) - } - - lineStarts := buildLineStarts(src) - insertions := map[int]struct{}{} - aliases := buildImportAliases(file) + inserter := newBlankLineInserter(ctx) + typeOrder := newTypeOrderRewriter(ctx) + embeds := newEmbedDirectiveRepairer(ctx) var violations []rules.Violation - inspectStmtLists(file, func(list []ast.Stmt) { - for i := 0; i < len(list)-1; i++ { - current := list[i] - next := list[i+1] - endLine := fset.Position(current.End()).Line - nextLine := fset.Position(next.Pos()).Line - - if currentLine, ok := setupSpacingLine(list, i, current, next, aliases, fset); ok { - violations = append(violations, rules.Violation{ - Rule: "spacing", - File: filename, - Line: currentLine, - Message: "missing blank line before selector call setup", - }) - - offset := lineStartOffset(lineStarts, currentLine) - insertions[offset] = struct{}{} - } - - if endLine == nextLine { - continue - } - - if message, ok := statementGapRule(current, next, aliases, fset); ok { - if nextLine < endLine+2 { - violations = append(violations, rules.Violation{ - Rule: "spacing", - File: filename, - Line: nextLine, - Message: message, - }) - - offset := lineStartOffset(lineStarts, nextLine) - insertions[offset] = struct{}{} - } - } - } - }) - - for i := 0; i < len(file.Decls)-1; i++ { - current := file.Decls[i] - next := file.Decls[i+1] - - if !requiresTypeDeclSpacing(current, next) { - continue - } + violations = append(violations, inserter.analyze(path)...) + violations = append(violations, typeOrder.analyze(path)...) + violations = append(violations, embeds.analyze(path)...) - endLine := fset.Position(current.End()).Line - nextLine := fset.Position(next.Pos()).Line - - if nextLine >= endLine+2 { - continue - } - - violations = append(violations, rules.Violation{ - Rule: "spacing", - File: filename, - Line: nextLine, - Message: "missing blank line around type definition", - }) - - offset := lineStartOffset(lineStarts, nextLine) - insertions[offset] = struct{}{} - } - - violations = append(violations, typeOrderViolations(file, fset, filename)...) - violations = append(violations, embedAdjacencyViolations(file, fset, filename)...) - - formatted := src - - if len(insertions) > 0 { - formatted = applyInsertions(formatted, insertions) - } + formatted := inserter.apply() if len(violations) > 0 { - reordered, changed, err := reorderTypeDecls(filename, formatted) + reordered, changed, err := typeOrder.rewrite(path, formatted) if err != nil { return nil, nil, err @@ -131,7 +53,7 @@ func analyse(filename string, src []byte) ([]rules.Violation, []byte, error) { formatted = reordered } - formatted, err = repairDetachedEmbedDirectives(filename, formatted) + formatted, err = embeds.repair(path, formatted) if err != nil { return nil, nil, err diff --git a/packages/go/formatter/rules/spacing/spacing_internal_test.go b/packages/go/formatter/rules/spacing/spacing_internal_test.go index f36965a..31e9dfd 100644 --- a/packages/go/formatter/rules/spacing/spacing_internal_test.go +++ b/packages/go/formatter/rules/spacing/spacing_internal_test.go @@ -191,51 +191,36 @@ func TestContainsEmbedDirectiveNilAndMultipleComments(t *testing.T) { } } -func TestEmbedDirectiveLinePrefixEdges(t *testing.T) { +// TestEmbedDirectiveEdges pins the directive grammar for both entry points at +// once: the string form the AST pass reads off comments, and the []byte form +// the byte-level collapse pass reads off raw lines. They share an +// implementation, so testing them apart only hid that they had to agree. +func TestEmbedDirectiveEdges(t *testing.T) { tests := []struct { line string want bool }{ {line: "//go:embed fixtures/*.txt", want: true}, {line: "//go:embed\tfixtures/*.txt", want: true}, + {line: "//go:embed fixtures/*.txt", want: true}, + {line: " //go:embed fixtures/*.txt", want: true}, {line: "//go:embed", want: false}, {line: "//go:embedded fixtures/*.txt", want: false}, {line: "//go:embed-fixtures", want: false}, + {line: "// go:embed fixtures/*.txt", want: false}, + {line: "//go:generate echo ok", want: false}, } for _, tt := range tests { t.Run(tt.line, func(t *testing.T) { - if got := hasEmbedDirectiveLinePrefix([]byte(tt.line)); got != tt.want { - t.Fatalf("expected %v, got %v", tt.want, got) + if got := isEmbedDirectiveText(tt.line); got != tt.want { + t.Fatalf("isEmbedDirectiveText(%q) = %v, want %v", tt.line, got, tt.want) } - }) - } -} - -func TestDeclOrdersEqualBranches(t *testing.T) { - orderedFile, err := parser.ParseFile(token.NewFileSet(), "sample.go", `package sample - -type config struct{} - -func run() {} -`, parser.ParseComments) - - if err != nil { - t.Fatalf("parse ordered source: %v", err) - } - - if !declOrdersEqual(orderedFile.Decls, orderedFile.Decls) { - t.Fatal("expected identical declaration order to match") - } - - reversed := []ast.Decl{orderedFile.Decls[1], orderedFile.Decls[0]} - if declOrdersEqual(orderedFile.Decls, reversed) { - t.Fatal("expected reordered declarations to differ") - } - - if declOrdersEqual(orderedFile.Decls, orderedFile.Decls[:1]) { - t.Fatal("expected declaration slices with different lengths to differ") + if got := isEmbedDirectiveLine([]byte(tt.line)); got != tt.want { + t.Fatalf("isEmbedDirectiveLine(%q) = %v, want %v", tt.line, got, tt.want) + } + }) } } diff --git a/packages/go/formatter/rules/spacing/spacing_test.go b/packages/go/formatter/rules/spacing/spacing_test.go index 2db84cc..1f226f0 100644 --- a/packages/go/formatter/rules/spacing/spacing_test.go +++ b/packages/go/formatter/rules/spacing/spacing_test.go @@ -1360,47 +1360,6 @@ func TestApplyPreservesImportsBeforeAnchoredDecls(t *testing.T) { } } -func TestIsEmbedDirectiveTextRejectsInvalidPrefixes(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - text string - want bool - }{ - { - name: "space separated directive", - text: "//go:embed foo.txt", - want: true, - }, - { - name: "tab separated directive", - text: "//go:embed\tfoo.txt", - want: true, - }, - { - name: "bare directive", - text: "//go:embed", - want: false, - }, - { - name: "embedded prefix", - text: "//go:embedded foo.txt", - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - if got := isEmbedDirectiveText(tt.text); got != tt.want { - t.Fatalf("isEmbedDirectiveText(%q) = %v, want %v", tt.text, got, tt.want) - } - }) - } -} - func TestApplyReordersTypesWithoutEmbedDirective(t *testing.T) { path := writeTempGoFile(t, `package sample diff --git a/packages/go/formatter/rules/spacing/testdata/corpus/embed_repair.golden b/packages/go/formatter/rules/spacing/testdata/corpus/embed_repair.golden new file mode 100644 index 0000000..ba892e4 --- /dev/null +++ b/packages/go/formatter/rules/spacing/testdata/corpus/embed_repair.golden @@ -0,0 +1,10 @@ +package sample + +import _ "embed" + +//go:embed data.txt +var data string + +type holder struct { + value string +} diff --git a/packages/go/formatter/rules/spacing/testdata/corpus/embed_repair.input b/packages/go/formatter/rules/spacing/testdata/corpus/embed_repair.input new file mode 100644 index 0000000..43b4de3 --- /dev/null +++ b/packages/go/formatter/rules/spacing/testdata/corpus/embed_repair.input @@ -0,0 +1,11 @@ +package sample + +import _ "embed" + +//go:embed data.txt + +var data string + +type holder struct { + value string +} diff --git a/packages/go/formatter/rules/spacing/testdata/corpus/imports_aliases.golden b/packages/go/formatter/rules/spacing/testdata/corpus/imports_aliases.golden new file mode 100644 index 0000000..7c323ba --- /dev/null +++ b/packages/go/formatter/rules/spacing/testdata/corpus/imports_aliases.golden @@ -0,0 +1,12 @@ +package sample + +import xrand "math/rand" + +func run() { + value := compute() + + xrand.Seed(1) + + next := value + _ = next +} diff --git a/packages/go/formatter/rules/spacing/testdata/corpus/imports_aliases.input b/packages/go/formatter/rules/spacing/testdata/corpus/imports_aliases.input new file mode 100644 index 0000000..c4e131b --- /dev/null +++ b/packages/go/formatter/rules/spacing/testdata/corpus/imports_aliases.input @@ -0,0 +1,10 @@ +package sample + +import xrand "math/rand" + +func run() { + value := compute() + xrand.Seed(1) + next := value + _ = next +} diff --git a/packages/go/formatter/rules/spacing/testdata/corpus/setup_spacing.golden b/packages/go/formatter/rules/spacing/testdata/corpus/setup_spacing.golden new file mode 100644 index 0000000..33b313a --- /dev/null +++ b/packages/go/formatter/rules/spacing/testdata/corpus/setup_spacing.golden @@ -0,0 +1,11 @@ +package sample + +import "math/rand" + +func run() { + seed := pick() + + rand := newRand(seed) + + rand.Intn(10) +} diff --git a/packages/go/formatter/rules/spacing/testdata/corpus/setup_spacing.input b/packages/go/formatter/rules/spacing/testdata/corpus/setup_spacing.input new file mode 100644 index 0000000..ca016d8 --- /dev/null +++ b/packages/go/formatter/rules/spacing/testdata/corpus/setup_spacing.input @@ -0,0 +1,9 @@ +package sample + +import "math/rand" + +func run() { + seed := pick() + rand := newRand(seed) + rand.Intn(10) +} diff --git a/packages/go/formatter/rules/spacing/testdata/corpus/statement_gaps.golden b/packages/go/formatter/rules/spacing/testdata/corpus/statement_gaps.golden new file mode 100644 index 0000000..2303f1b --- /dev/null +++ b/packages/go/formatter/rules/spacing/testdata/corpus/statement_gaps.golden @@ -0,0 +1,15 @@ +package sample + +func run() { + if true { + println("ok") + } + + println("next") +} + +func teardown() { + defer println("done") + + return +} diff --git a/packages/go/formatter/rules/spacing/testdata/corpus/statement_gaps.input b/packages/go/formatter/rules/spacing/testdata/corpus/statement_gaps.input new file mode 100644 index 0000000..e4de9be --- /dev/null +++ b/packages/go/formatter/rules/spacing/testdata/corpus/statement_gaps.input @@ -0,0 +1,13 @@ +package sample + +func run() { + if true { + println("ok") + } + println("next") +} + +func teardown() { + defer println("done") + return +} diff --git a/packages/go/formatter/rules/spacing/testdata/corpus/type_decl_spacing.golden b/packages/go/formatter/rules/spacing/testdata/corpus/type_decl_spacing.golden new file mode 100644 index 0000000..a06d608 --- /dev/null +++ b/packages/go/formatter/rules/spacing/testdata/corpus/type_decl_spacing.golden @@ -0,0 +1,9 @@ +package sample + +type A struct{} + +type B struct{} + +func use() (A, B) { + return A{}, B{} +} diff --git a/packages/go/formatter/rules/spacing/testdata/corpus/type_decl_spacing.input b/packages/go/formatter/rules/spacing/testdata/corpus/type_decl_spacing.input new file mode 100644 index 0000000..e8a465c --- /dev/null +++ b/packages/go/formatter/rules/spacing/testdata/corpus/type_decl_spacing.input @@ -0,0 +1,8 @@ +package sample + +type A struct{} +type B struct{} + +func use() (A, B) { + return A{}, B{} +} diff --git a/packages/go/formatter/rules/spacing/testdata/corpus/type_order.golden b/packages/go/formatter/rules/spacing/testdata/corpus/type_order.golden new file mode 100644 index 0000000..9d1be4b --- /dev/null +++ b/packages/go/formatter/rules/spacing/testdata/corpus/type_order.golden @@ -0,0 +1,10 @@ +package sample + +// Config carries settings. +type Config struct { + Name string +} + +func helper() string { + return "x" +} diff --git a/packages/go/formatter/rules/spacing/testdata/corpus/type_order.input b/packages/go/formatter/rules/spacing/testdata/corpus/type_order.input new file mode 100644 index 0000000..0b3ebdf --- /dev/null +++ b/packages/go/formatter/rules/spacing/testdata/corpus/type_order.input @@ -0,0 +1,10 @@ +package sample + +func helper() string { + return "x" +} + +// Config carries settings. +type Config struct { + Name string +} diff --git a/packages/go/formatter/rules/spacing/typeorder.go b/packages/go/formatter/rules/spacing/typeorder.go index 22e993c..b2c1095 100644 --- a/packages/go/formatter/rules/spacing/typeorder.go +++ b/packages/go/formatter/rules/spacing/typeorder.go @@ -2,6 +2,7 @@ package spacing import ( "bytes" + "cmp" "go/ast" "go/parser" "go/token" @@ -21,11 +22,24 @@ type declRegion struct { end int } -func typeOrderViolations(file *ast.File, fset *token.FileSet, filename string) []rules.Violation { +// typeOrderRewriter enforces that type declarations precede the other top-level +// declarations in a file, reading the shared parse state through ctx. +type typeOrderRewriter struct { + ctx *fileContext +} + +// newTypeOrderRewriter returns a rewriter bound to the shared parse state. +func newTypeOrderRewriter(ctx *fileContext) *typeOrderRewriter { + return &typeOrderRewriter{ctx: ctx} +} + +// analyze reports every type declaration that appears after a non-type +// declaration, labelling the returned violations with filename. +func (r *typeOrderRewriter) analyze(filename string) []rules.Violation { var violations []rules.Violation seenNonType := false - for _, block := range topLevelDeclBlocks(file) { + for _, block := range topLevelDeclBlocks(r.ctx.file) { if isImportDecl(block.decl) { continue } @@ -41,7 +55,7 @@ func typeOrderViolations(file *ast.File, fset *token.FileSet, filename string) [ violations = append(violations, rules.Violation{ Rule: "spacing", File: filename, - Line: fset.Position(block.decl.Pos()).Line, + Line: r.ctx.fset.Position(block.decl.Pos()).Line, Message: "type definitions must appear at the beginning of the file", }) } @@ -55,7 +69,11 @@ func typeOrderViolations(file *ast.File, fset *token.FileSet, filename string) [ return violations } -func reorderTypeDecls(filename string, src []byte) ([]byte, bool, error) { +// rewrite re-parses src — which may already carry the blank lines the inserter +// added — and splices every type declaration to the front of the file. It parses +// its own file rather than reusing ctx because it operates on the transformed +// bytes, and reports whether the declaration order changed. +func (r *typeOrderRewriter) rewrite(filename string, src []byte) ([]byte, bool, error) { fset := token.NewFileSet() file, err := parser.ParseFile(fset, filename, src, parser.ParseComments) @@ -67,7 +85,7 @@ func reorderTypeDecls(filename string, src []byte) ([]byte, bool, error) { desired := desiredDeclOrder(file) - if declOrdersEqual(file.Decls, desired) { + if slices.Equal(file.Decls, desired) { return src, false, nil } @@ -129,9 +147,7 @@ func declSourceRegions(decls []ast.Decl, fset *token.FileSet, src []byte) map[as if i > 0 { prevEnd := lineStartOffset(lineStarts, fset.Position(decls[i-1].End()).Line+1) - if start < prevEnd { - start = prevEnd - } + start = max(start, prevEnd) } starts[i] = start @@ -146,11 +162,7 @@ func declSourceRegions(decls []ast.Decl, fset *token.FileSet, src []byte) map[as // The last declaration ends at the line after its body, not at EOF: // trailing comments and blank lines beyond it are the file's, not the // declaration's, so they must not travel when this declaration moves. - end = lineStartOffset(lineStarts, fset.Position(decl.End()).Line+1) - - if end > len(src) { - end = len(src) - } + end = min(lineStartOffset(lineStarts, fset.Position(decl.End()).Line+1), len(src)) } regions[decl] = declRegion{start: starts[i], end: end} @@ -189,14 +201,7 @@ func topLevelDeclBlocks(file *ast.File) []declBlock { } slices.SortStableFunc(blocks, func(a declBlock, b declBlock) int { - switch { - case a.effectivePos < b.effectivePos: - return -1 - case a.effectivePos > b.effectivePos: - return 1 - default: - return 0 - } + return cmp.Compare(a.effectivePos, b.effectivePos) }) return blocks @@ -261,20 +266,6 @@ func leadingImportDeclsEnd(decls []ast.Decl) int { return importsEnd } -func declOrdersEqual(current []ast.Decl, desired []ast.Decl) bool { - if len(current) != len(desired) { - return false - } - - for i := range current { - if current[i] != desired[i] { - return false - } - } - - return true -} - func hasOutOfOrderTypeDecls(file *ast.File) bool { seenNonType := false diff --git a/packages/go/go.mod b/packages/go/go.mod index 2222d2c..c978ee6 100644 --- a/packages/go/go.mod +++ b/packages/go/go.mod @@ -1,10 +1,10 @@ module go.ollin.sh/fmtkit -go 1.26.4 +go 1.26.5 require ( github.com/fatih/color v1.19.0 - github.com/mattn/go-isatty v0.0.22 + github.com/mattn/go-isatty v0.0.24 github.com/spf13/viper v1.21.0 golang.org/x/tools v0.48.0 ) @@ -19,10 +19,9 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/mod v0.38.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect - gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect ) diff --git a/packages/go/go.sum b/packages/go/go.sum index 1a2054c..2b76d7a 100644 --- a/packages/go/go.sum +++ b/packages/go/go.sum @@ -16,8 +16,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= -github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -38,8 +38,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +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/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= @@ -50,8 +50,5 @@ golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/packages/go/infra/task.sh b/packages/go/scripts/task.sh similarity index 85% rename from packages/go/infra/task.sh rename to packages/go/scripts/task.sh index b0ae3bc..3bedb82 100755 --- a/packages/go/infra/task.sh +++ b/packages/go/scripts/task.sh @@ -3,13 +3,13 @@ set -euo pipefail # Go-toolchain tasks scoped to one package of the module. The package.json # shims in driver/, formatter/, and vet/ call this from their own directory, -# so ./... means that package's tree. Repo-wide tasks live in infra/task.sh. +# so ./... means that package's tree. Repo-wide tasks live in scripts/task.sh. # # usage: task.sh [args...] script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" -source "${script_dir}/../../../infra/lib/env.sh" +source "${script_dir}/../../../scripts/lib/env.sh" with_env() { local status diff --git a/packages/go/vet/package.json b/packages/go/vet/package.json index 917b383..b8340f2 100644 --- a/packages/go/vet/package.json +++ b/packages/go/vet/package.json @@ -2,9 +2,9 @@ "name": "vet", "private": true, "scripts": { - "check": "../infra/task.sh check", - "gofmt": "../infra/task.sh gofmt", - "test": "../infra/task.sh test", - "vet": "../infra/task.sh vet" + "check": "../scripts/task.sh check", + "gofmt": "../scripts/task.sh gofmt", + "test": "../scripts/task.sh test", + "vet": "../scripts/task.sh vet" } } diff --git a/packages/go/vet/vet.go b/packages/go/vet/vet.go index d2b90e4..064c352 100644 --- a/packages/go/vet/vet.go +++ b/packages/go/vet/vet.go @@ -41,11 +41,6 @@ type toolchain interface { // execToolchain is the exec-backed toolchain used outside tests. type execToolchain struct{} -// Default returns the default vet configuration. -func Default() Config { - return Config{Enabled: true} -} - // LookGo resolves the go executable on PATH. func (execToolchain) LookGo() (string, error) { return exec.LookPath("go") @@ -166,18 +161,21 @@ func goEnv(ctx context.Context, workRoot string, tc toolchain, keys ...string) ( out, err := tc.EnvOutput(ctx, workRoot, keys...) if err != nil { - var exitErr *exec.ExitError - - label := strings.Join(keys, " ") + return nil, wrapExitError(err, "resolve go "+strings.Join(keys, " ")) + } - if errors.As(err, &exitErr) { - return nil, fmt.Errorf("resolve go %s: %s: %w", label, strings.TrimSpace(string(exitErr.Stderr)), err) - } + return parseGoEnvValues(out, len(keys)), nil +} - return nil, fmt.Errorf("resolve go %s: %w", label, err) +// wrapExitError annotates err with what was being resolved. When the failure +// came from the child process itself, its stderr is folded in: that is where +// the go tool explains what actually went wrong. +func wrapExitError(err error, what string) error { + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { + return fmt.Errorf("%s: %s: %w", what, strings.TrimSpace(string(exitErr.Stderr)), err) } - return parseGoEnvValues(out, len(keys)), nil + return fmt.Errorf("%s: %w", what, err) } func parseGoEnvValues(out []byte, count int) []string { @@ -222,13 +220,7 @@ func discoverVetTargets(ctx context.Context, root string, tc toolchain) ([]strin out, err := tc.ListModulesOutput(ctx, root) if err != nil { - var exitErr *exec.ExitError - - if errors.As(err, &exitErr) { - return nil, fmt.Errorf("resolve go vet targets: %s: %w", strings.TrimSpace(string(exitErr.Stderr)), err) - } - - return nil, fmt.Errorf("resolve go vet targets: %w", err) + return nil, wrapExitError(err, "resolve go vet targets") } lines := strings.Split(string(out), "\n") diff --git a/packages/go/vet/vet_test.go b/packages/go/vet/vet_test.go index 12ccf57..dfb47f1 100644 --- a/packages/go/vet/vet_test.go +++ b/packages/go/vet/vet_test.go @@ -79,12 +79,6 @@ func TestParseGoEnvValuesPreservesOrderAndEmptyLines(t *testing.T) { }) } -func TestDefaultEnablesVet(t *testing.T) { - if !Default().Enabled { - t.Fatal("expected default vet config to be enabled") - } -} - func TestRunSkipsWhenDisabled(t *testing.T) { report := Run(context.Background(), t.TempDir(), Config{Enabled: false}) @@ -100,7 +94,7 @@ func TestRunSkipsWhenGoToolchainUnavailable(t *testing.T) { }, } - report := run(context.Background(), t.TempDir(), Default(), tc) + report := run(context.Background(), t.TempDir(), Config{Enabled: true}, tc) if !report.Skipped { t.Fatalf("expected skipped report, got %#v", report) @@ -118,7 +112,7 @@ func TestRunPrefersWorkspace(t *testing.T) { workspaceFile := filepath.Join(workspaceRoot, "go.work") moduleFile := filepath.Join(moduleRoot, "go.mod") - testutil.WriteFile(t, workspaceFile, "go 1.26.4\n") + testutil.WriteFile(t, workspaceFile, "go 1.26.5\n") testutil.WriteFile(t, moduleFile, "module example.com/test\n") tc := fakeToolchain{ @@ -127,7 +121,7 @@ func TestRunPrefersWorkspace(t *testing.T) { }, } - report := run(context.Background(), workRoot, Default(), tc) + report := run(context.Background(), workRoot, Config{Enabled: true}, tc) if report.Root != workspaceRoot { t.Fatalf("unexpected report: %#v", report) @@ -147,7 +141,7 @@ func TestRunFallsBackToModuleWhenWorkspaceUnset(t *testing.T) { }, } - report := run(context.Background(), workRoot, Default(), tc) + report := run(context.Background(), workRoot, Config{Enabled: true}, tc) if report.Root != moduleRoot { t.Fatalf("unexpected report: %#v", report) @@ -175,7 +169,7 @@ func run() { println("ok") } `) - testutil.WriteGoWork(t, workspaceRoot, `go 1.26.4 + testutil.WriteGoWork(t, workspaceRoot, `go 1.26.5 use ( ./module-a @@ -183,7 +177,7 @@ use ( ) `) - report := Run(context.Background(), workspaceRoot, Default()) + report := Run(context.Background(), workspaceRoot, Config{Enabled: true}) if report.ErrorCount() != 1 { t.Fatalf("expected one vet error, got %#v", report) @@ -205,7 +199,7 @@ func TestRunReportsGoEnvLookupError(t *testing.T) { }, } - report := run(context.Background(), t.TempDir(), Default(), tc) + report := run(context.Background(), t.TempDir(), Config{Enabled: true}, tc) if report.ErrorCount() != 1 { t.Fatalf("expected one error, got %#v", report) @@ -217,7 +211,7 @@ func TestRunReportsGoEnvLookupError(t *testing.T) { } func TestRunSkipsOutsideModule(t *testing.T) { - report := Run(context.Background(), t.TempDir(), Default()) + report := Run(context.Background(), t.TempDir(), Config{Enabled: true}) if report.ErrorCount() != 0 { t.Fatalf("expected empty report: %#v", report) @@ -256,7 +250,6 @@ func TestExistingGoRootFiltersInvalidCandidates(t *testing.T) { } for _, tc := range cases { - tc := tc t.Run(tc.name, func(t *testing.T) { root, ok := existingGoRoot(tc.path, tc.filename) diff --git a/packages/ts/sidecar/package.json b/packages/ts/sidecar/package.json index ee9d060..3e9d2bb 100644 --- a/packages/ts/sidecar/package.json +++ b/packages/ts/sidecar/package.json @@ -6,9 +6,9 @@ "#sidecar/*": "./src/*.ts" }, "scripts": { - "validate-syntax": "cd ../../.. && tsx packages/ts/sidecar/src/validate-syntax.ts", - "lint": "cd ../../.. && git ls-files --cached --others --exclude-standard -z | xargs -0 packages/ts/sidecar/node_modules/.bin/oxlint --fix", - "lint:check": "cd ../../.. && git ls-files --cached --others --exclude-standard -z | xargs -0 packages/ts/sidecar/node_modules/.bin/oxlint", + "validate-syntax": "cd ../../.. && tsx packages/ts/sidecar/src/cli/validate-syntax.ts", + "lint": "cd ../../.. && git ls-files --cached --others --exclude-standard -z | xargs -0 oxlint --fix", + "lint:check": "cd ../../.. && git ls-files --cached --others --exclude-standard -z | xargs -0 oxlint", "check": "pnpm lint:check", "test": "node --import tsx --test 'src/**/*.test.ts'", "test:coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-lines=90 'src/**/*.test.ts'", @@ -17,9 +17,9 @@ "devDependencies": { "@types/node": "26.1.1", "fast-check": "4.9.0", - "oxc-parser": "0.140.0", - "oxfmt": "0.59.0", - "oxlint": "1.74.0", + "oxc-parser": "0.141.0", + "oxfmt": "0.60.0", + "oxlint": "1.75.0", "tsx": "4.23.1", "typescript": "7.0.2", "zod": "4.4.3" diff --git a/packages/ts/sidecar/src/alias-specifiers.test.ts b/packages/ts/sidecar/src/alias-specifiers.test.ts index d60a1b7..9564479 100644 --- a/packages/ts/sidecar/src/alias-specifiers.test.ts +++ b/packages/ts/sidecar/src/alias-specifiers.test.ts @@ -1,14 +1,16 @@ import assert from 'node:assert/strict'; import { readdir, readFile } from 'node:fs/promises'; -import { basename, join } from 'node:path'; +import { join } from 'node:path'; import { test } from 'node:test'; -import { Ast } from '#sidecar/ast'; -import { isErr } from '#sidecar/result'; -import { Sources } from '#sidecar/sources'; -import type { Node } from '#sidecar/types'; +import { AstReader } from '#sidecar/syntax/ast-reader'; +import { isErr } from '#sidecar/kernel/result'; +import { SourceParser } from '#sidecar/syntax/source-parser'; +import type { Node } from '#sidecar/syntax/node-schema'; const sourceExtensions = new Set(['.cjs', '.js', '.jsx', '.mjs', '.ts', '.tsx']); -const exemptFiles = new Set(['sidecar.ts']); + +const ast = new AstReader(); +const parser = new SourceParser(); // Root the scan at this test's own directory so it keeps covering every source // file regardless of how the tree is nested, rather than at a single module's @@ -20,7 +22,7 @@ function isRelativeSpecifier(value: string): boolean { } function sourceValue(source: Node | undefined): string | null { - return source ? (Ast.stringValue(source) ?? null) : null; + return source ? (ast.stringValue(source) ?? null) : null; } function isSourceFile(name: string): boolean { @@ -53,17 +55,17 @@ async function listSourceFiles(dir: string): Promise { } function collectModuleSpecifiers(file: string, source: string): string[] { - const parsed = Sources.parse(file, source); + const parsed = parser.parse(file, source); const specifiers: string[] = []; if (isErr(parsed)) { return specifiers; } - Ast.visit(parsed.value.program, (node) => { + ast.visit(parsed.value.program, (node) => { if (node.type === 'ImportDeclaration' || node.type === 'ExportNamedDeclaration' || node.type === 'ExportAllDeclaration') { const specifier = sourceValue( - Ast.childNode(node, 'source'), + ast.childNode(node, 'source'), ); if (specifier) { @@ -73,7 +75,7 @@ function collectModuleSpecifiers(file: string, source: string): string[] { if (node.type === 'ImportExpression') { const specifier = sourceValue( - Ast.childNode(node, 'source'), + ast.childNode(node, 'source'), ); if (specifier) { @@ -81,10 +83,10 @@ function collectModuleSpecifiers(file: string, source: string): string[] { } } - const callee = Ast.childNode(node, 'callee'); + const callee = ast.childNode(node, 'callee'); if (node.type === 'CallExpression' && callee?.type === 'Identifier' && callee.name === 'require') { - const specifier = sourceValue(Ast.childNodes(node, 'arguments')[0]); + const specifier = sourceValue(ast.childNodes(node, 'arguments')[0]); if (specifier) { specifiers.push(specifier); @@ -101,10 +103,6 @@ test('script module specifiers use aliases instead of relative paths', async () const violations: string[] = []; for (const file of files) { - if (exemptFiles.has(basename(file))) { - continue; - } - const source = await readFile(file, 'utf8'); const specifiers = collectModuleSpecifiers(file, source); diff --git a/packages/ts/sidecar/src/ast.test.ts b/packages/ts/sidecar/src/ast.test.ts deleted file mode 100644 index 39d752e..0000000 --- a/packages/ts/sidecar/src/ast.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { Ast } from '#sidecar/ast'; -import { Node } from '#sidecar/node-schema'; -import { isErr } from '#sidecar/result'; -import { Sources } from '#sidecar/sources'; - -test('Ast traverses parsed fixtures and reads validated node fields', () => { - const source = [ - "import value from 'fixture';", - 'const answer = 42;', - 'class Example {', - "\tfield = 'ready';", - '\tmethod() {', - '\t\treturn this.field;', - '\t}', - '}', - 'switch (answer) {', - '\tcase 42:', - '\t\tanswer;', - '}', - '// note', - '', - ].join('\n'); - - const parsed = Sources.parse('fixture.ts', source); - - assert.equal(isErr(parsed), false); - - if (isErr(parsed)) { - return; - } - - const statements = Ast.childNodes(parsed.value.program, 'body'); - const importDeclaration = statements[0]; - const variableDeclaration = statements[1]; - const classDeclaration = statements[2]; - - assert.equal(Ast.childNode(parsed.value.program, 'body'), undefined); - - assert.deepEqual(Ast.childNodes(parsed.value.program, 'missing'), []); - - assert.equal(importDeclaration && Ast.stringValue(Ast.childNode(importDeclaration, 'source') ?? importDeclaration), 'fixture'); - - assert.equal(variableDeclaration && Ast.declarationKind(variableDeclaration), 'const'); - - assert.equal(variableDeclaration && Ast.isConstDeclaration(variableDeclaration), true); - - assert.equal(classDeclaration && Ast.nodeName(Ast.childNode(classDeclaration, 'id') ?? classDeclaration), 'Example'); - - assert.equal(classDeclaration && source.slice(Ast.getStart(classDeclaration), Ast.getEnd(classDeclaration)).startsWith('class Example'), true); - - const visited: string[] = []; - - Ast.visit(parsed.value.program, (node) => { - visited.push(node.type); - }); - - assert.equal(visited[0], 'Program'); - - assert.ok(visited.includes('ReturnStatement')); - - assert.ok(Ast.collectStatementLists(parsed.value.program).some((list) => list.some((node) => node.type === 'SwitchCase'))); - - assert.equal(Ast.collectClassBodies(parsed.value.program).length, 1); - - assert.equal(Ast.stringValue(parsed.value.comments[0] ?? parsed.value.program), ' note'); -}); - -test('Ast position and scalar accessors preserve their fallbacks', () => { - const ranged = Node.schema.parse({ type: 'Identifier', range: [4, 9], name: 17, kind: false }); - - assert.equal(Ast.getStart(ranged), 4); - - assert.equal(Ast.getEnd(ranged), 9); - - assert.equal(Ast.nodeName(ranged), undefined); - - assert.equal(Ast.declarationKind(ranged), undefined); - - assert.equal(Ast.getStart(Node.schema.parse({ type: 'Identifier' })), -1); -}); diff --git a/packages/ts/sidecar/src/blank-line-inserter.ts b/packages/ts/sidecar/src/blank-line-inserter.ts deleted file mode 100644 index beeea63..0000000 --- a/packages/ts/sidecar/src/blank-line-inserter.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { Ast } from '#sidecar/ast'; -import { isErr } from '#sidecar/result'; -import { Rules } from '#sidecar/rules'; -import { Sources } from '#sidecar/sources'; - -/** Computes and applies the blank lines required by formatter rules. */ -export class BlankLines { - static #countNewlines(source: string, from: number, to: number): number { - let count = 0; - - for (let i = from; i < to; i++) { - if (source.charCodeAt(i) === 10) { - count++; - } - } - - return count; - } - - /** - * Compute positions where a blank line must be inserted. - * - * @param content - The source text to inspect. - * @param virtualName - The filename used to parse the source. - * @returns Source offsets where one newline should be inserted. - */ - static computeInsertPositions(content: string, virtualName: string): number[] { - const parsed = Sources.parse(virtualName, content); - - if (isErr(parsed)) { - return []; - } - - const lists = Ast.collectStatementLists(parsed.value.program); - const positions: number[] = []; - - for (const list of lists) { - for (let i = 1; i < list.length; i++) { - const prev = list[i - 1]; - const next = list[i]; - - if (!prev || !next) { - continue; - } - - if (!Rules.needsBlankLine(prev, next)) { - continue; - } - - const prevEnd = Ast.getEnd(prev); - const nextStart = Ast.getStart(next); - - if (prevEnd < 0 || nextStart < 0 || nextStart <= prevEnd) { - continue; - } - - if (BlankLines.#countNewlines(content, prevEnd, nextStart) >= 2) { - continue; - } - - const lineStart = content.lastIndexOf('\n', nextStart - 1); - - if (lineStart < 0) { - continue; - } - - positions.push(lineStart + 1); - } - } - - return positions; - } - - /** - * Insert blank lines at precomputed source offsets. - * - * @param content - The source text to update. - * @param positions - The source offsets where one newline should be inserted. - * @returns The source with the requested blank lines inserted. - */ - static insert(content: string, positions: number[]): string { - const sorted = [...new Set(positions)].sort((a, b) => { - return b - a; - }); - - let out = content; - - for (const pos of sorted) { - out = out.slice(0, pos) + '\n' + out.slice(pos); - } - - return out; - } -} diff --git a/packages/ts/sidecar/src/blank-lines.ts b/packages/ts/sidecar/src/blank-lines.ts deleted file mode 100644 index 350be13..0000000 --- a/packages/ts/sidecar/src/blank-lines.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { pathToFileURL } from 'node:url'; -import { FormatPipeline } from '#sidecar/format-pipeline'; -import { PassCliDto } from '#sidecar/pass-cli-dto'; -import { NodeProcessRunner } from '#sidecar/process-runner'; -import { NodeSourceFiles } from '#sidecar/source-files'; - -async function main(): Promise { - const cwd = process.cwd(); - const options = PassCliDto.parse(process.argv.slice(2)); - const files = [...options.files]; - const { mode } = options; - const pipeline = new FormatPipeline({ sourceFiles: new NodeSourceFiles(), processRunner: new NodeProcessRunner() }); - - const outcomes = await pipeline.runPass('blank-lines', files, mode, (file, passMode) => { - return pipeline.formatFile(file, passMode); - }); - - let changedCount = 0; - - for (const outcome of outcomes) { - if (outcome.error?._tag === 'SourceFileUnreadable' && outcome.error.isNotFound()) { - console.warn(`[blank-lines] path not found, skipping: ${outcome.file}`); - - continue; - } - - if (outcome.error) { - console.error(outcome.error); - process.exitCode = 1; - - return; - } - - if (!outcome.changed) { - continue; - } - - changedCount++; - console.log(`[blank-lines] ${mode === 'check' ? 'would change' : 'updated'} ${outcome.file}`); - } - - if (mode === 'check' && changedCount > 0) { - console.error(`[blank-lines] ${changedCount} file(s) need blank-line edits. Run "pnpm format" to fix.`); - process.exitCode = 1; - - return; - } - - console.log(`[blank-lines] processed ${files.length} file(s) in ${cwd}, ${changedCount} ${mode === 'check' ? 'would change' : 'changed'}`); -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - main().catch((error: unknown) => { - console.error(error); - process.exitCode = 1; - }); -} diff --git a/packages/ts/sidecar/src/body-wrapper.ts b/packages/ts/sidecar/src/body-wrapper.ts deleted file mode 100644 index 28cbe80..0000000 --- a/packages/ts/sidecar/src/body-wrapper.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { Ast } from '#sidecar/ast'; -import { isErr } from '#sidecar/result'; -import { SourceText } from '#sidecar/source-text'; -import { Sources } from '#sidecar/sources'; -import type { Edit, Node } from '#sidecar/types'; - -const STATEMENT_BODY_KEYS: Record = { - DoWhileStatement: ['body'], - ForInStatement: ['body'], - ForOfStatement: ['body'], - ForStatement: ['body'], - IfStatement: ['consequent', 'alternate'], - WhileStatement: ['body'], - WithStatement: ['body'], -}; - -/** Wraps unbraced statement bodies without changing unparsable source. */ -export class BodyWrapper { - static #wrapStatementBody(source: string, owner: Node, body: Node, indentUnit: string): Edit | null { - if (body.type === 'BlockStatement') { - return null; - } - - if (body.type === 'IfStatement' && owner.type === 'IfStatement' && owner.alternate === body) { - return null; - } - - const start = Ast.getStart(body); - const end = Ast.getEnd(body); - const ownerStart = Ast.getStart(owner); - - if (start < 0 || end < 0 || ownerStart < 0) { - return null; - } - - const indent = SourceText.lineIndent(source, ownerStart); - const bodySource = source.slice(start, end); - - return { - start, - end, - replacement: `{\n${indent}${indentUnit}${bodySource}\n${indent}}`, - }; - } - - /** - * Compute edits that wrap unbraced statement bodies. - * - * @param content - The source text to inspect. - * @param virtualName - The filename used to parse the source. - * @returns Non-overlapping body-wrap edits, or none for invalid source. - */ - static computeEdits(content: string, virtualName: string): Edit[] { - const parsed = Sources.parse(virtualName, content); - - if (isErr(parsed)) { - return []; - } - - const edits: Edit[] = []; - const indentUnit = SourceText.detectIndentUnit(content); - - Ast.visit(parsed.value.program, (node) => { - const bodyKeys = STATEMENT_BODY_KEYS[node.type]; - - if (!bodyKeys) { - return; - } - - for (const key of bodyKeys) { - const body = Ast.childNode(node, key); - - if (!body) { - continue; - } - - const edit = BodyWrapper.#wrapStatementBody(content, node, body, indentUnit); - - if (edit) { - edits.push(edit); - } - } - }); - - return edits - .sort((a, b) => { - return a.start - b.start || b.end - b.start - (a.end - a.start); - }) - .filter((edit, index, sorted) => { - return !sorted.some((other, otherIndex) => { - return otherIndex < index && edit.start < other.end; - }); - }); - } -} diff --git a/packages/ts/sidecar/src/class-reorder.test.ts b/packages/ts/sidecar/src/class-reorder.test.ts deleted file mode 100644 index 18c7261..0000000 --- a/packages/ts/sidecar/src/class-reorder.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { ClassReorder } from '#sidecar/class-reorder'; -import { Edits } from '#sidecar/edits'; - -test('class members are reordered as properties, constructors, then methods', () => { - const input = ['class Example {', '\trun() {}', '\tvalue = 1;', '\tconstructor() {}', '}', ''].join('\n'); - - const edits = ClassReorder.computeEdits(input, 'fixture.ts'); - const output = Edits.apply(input, edits); - - assert.equal(edits.length, 1); - assert.equal(output, ['class Example {', '\tvalue = 1;', '\tconstructor() {}', '\trun() {}', '}', ''].join('\n')); -}); - -test('class reorder skips members with comments between them', () => { - const input = ['class Example {', '\trun() {}', '\t// Preserve this member grouping.', '\tvalue = 1;', '}', ''].join('\n'); - - assert.deepEqual(ClassReorder.computeEdits(input, 'fixture.ts'), []); -}); - -test('class reorder skips already ordered and single-member classes', () => { - assert.deepEqual(ClassReorder.computeEdits(['class Ordered {', '\tvalue = 1;', '\tconstructor() {}', '\trun() {}', '}', ''].join('\n'), 'ordered.ts'), []); - - assert.deepEqual(ClassReorder.computeEdits(['class Single {', '\trun() {}', '}', ''].join('\n'), 'single.ts'), []); -}); diff --git a/packages/ts/sidecar/src/class-reorder.ts b/packages/ts/sidecar/src/class-reorder.ts deleted file mode 100644 index 189fed8..0000000 --- a/packages/ts/sidecar/src/class-reorder.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { Ast } from '#sidecar/ast'; -import { isErr } from '#sidecar/result'; -import { Rules } from '#sidecar/rules'; -import { Sources } from '#sidecar/sources'; -import type { Edit, Node } from '#sidecar/types'; - -/** Reorders class members into the formatter's stable class shape. */ -export class ClassReorder { - static #containsComment(source: string): boolean { - return /\/\/|\/\*/.test(source); - } - - static #hasCommentsAroundMembers(source: string, body: Node, members: Node[]): boolean { - const first = members[0]; - const last = members.at(-1); - - if (!first || !last) { - return false; - } - - const bodyStart = Ast.getStart(body); - const bodyEnd = Ast.getEnd(body); - const firstStart = Ast.getStart(first); - const lastEnd = Ast.getEnd(last); - - if (ClassReorder.#containsComment(source.slice(bodyStart + 1, firstStart))) { - return true; - } - - for (let i = 0; i < members.length - 1; i++) { - const current = members[i]; - const following = members[i + 1]; - - if (current && following && ClassReorder.#containsComment(source.slice(Ast.getEnd(current), Ast.getStart(following)))) { - return true; - } - } - - return ClassReorder.#containsComment(source.slice(lastEnd, bodyEnd - 1)); - } - - static #computeClassReorderEdit(source: string, body: Node): Edit | null { - const members = Ast.childNodes(body, 'body'); - - if (members.length < 2) { - return null; - } - - const properties: Node[] = []; - const constructors: Node[] = []; - const methods: Node[] = []; - - for (const member of members) { - const kind = Rules.classifyMember(member); - - if (kind === 'property') { - properties.push(member); - } else if (kind === 'constructor') { - constructors.push(member); - } else { - methods.push(member); - } - } - - const desired = [...properties, ...constructors, ...methods]; - - if ( - desired.every((member, index) => { - return member === members[index]; - }) - ) { - return null; - } - - const bodyStart = Ast.getStart(body); - const bodyEnd = Ast.getEnd(body); - - if (bodyStart < 0 || bodyEnd < 0 || ClassReorder.#hasCommentsAroundMembers(source, body, members)) { - return null; - } - - const firstMember = members[0]; - const lastOriginal = members.at(-1); - - if (!firstMember || !lastOriginal) { - return null; - } - - const prefix = source.slice(bodyStart + 1, Ast.getStart(firstMember)); - const indent = prefix.match(/\n([ \t]*)$/)?.[1]; - - if (indent === undefined) { - return null; - } - - const memberSlices = desired.map((member) => { - return source.slice(Ast.getStart(member), Ast.getEnd(member)); - }); - - const closing = source.slice(Ast.getEnd(lastOriginal), bodyEnd - 1); - - return { - start: bodyStart + 1, - end: bodyEnd - 1, - replacement: `\n${indent}${memberSlices.join(`\n${indent}`)}${closing}`, - }; - } - - /** - * Compute class-member ordering edits. - * - * @param content - The source text to inspect. - * @param virtualName - The filename used to parse the source. - * @returns Class-member ordering edits, or none for invalid source. - */ - static computeEdits(content: string, virtualName: string): Edit[] { - const parsed = Sources.parse(virtualName, content); - - if (isErr(parsed)) { - return []; - } - - const edits: Edit[] = []; - - for (const body of Ast.collectClassBodies(parsed.value.program)) { - const edit = ClassReorder.#computeClassReorderEdit(content, body); - - if (edit) { - edits.push(edit); - } - } - - return edits; - } -} diff --git a/packages/ts/sidecar/src/blank-lines.test.ts b/packages/ts/sidecar/src/cli/blank-lines.test.ts similarity index 99% rename from packages/ts/sidecar/src/blank-lines.test.ts rename to packages/ts/sidecar/src/cli/blank-lines.test.ts index d9f40de..ad32842 100644 --- a/packages/ts/sidecar/src/blank-lines.test.ts +++ b/packages/ts/sidecar/src/cli/blank-lines.test.ts @@ -7,7 +7,7 @@ import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; const script = fileURLToPath( - import.meta.resolve('#sidecar/blank-lines'), + import.meta.resolve('#sidecar/cli/blank-lines'), ); const tsx = fileURLToPath( import.meta.resolve('tsx'), diff --git a/packages/ts/sidecar/src/cli/blank-lines.ts b/packages/ts/sidecar/src/cli/blank-lines.ts new file mode 100644 index 0000000..661671d --- /dev/null +++ b/packages/ts/sidecar/src/cli/blank-lines.ts @@ -0,0 +1,20 @@ +import { pathToFileURL } from 'node:url'; +import { CompositionRoot } from '#sidecar/cli/composition-root'; + +/** + * Run the standalone blank-lines segment formatter entrypoint. + * + * @returns Nothing after running the command and setting the process status. + */ +async function main(): Promise { + process.exitCode = await CompositionRoot.production() + .segmentPassCommand() + .run(process.argv.slice(2)); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/packages/ts/sidecar/src/cli/command.ts b/packages/ts/sidecar/src/cli/command.ts new file mode 100644 index 0000000..4a8a154 --- /dev/null +++ b/packages/ts/sidecar/src/cli/command.ts @@ -0,0 +1,10 @@ +/** A runnable sidecar CLI command that maps parsed arguments to an exit code. */ +export interface CliCommand { + /** + * Run the command over already-sliced CLI arguments. + * + * @param argv - Arguments after the executable and script path. + * @returns The process exit code; the command never calls `process.exit`. + */ + run(argv: readonly string[]): Promise; +} diff --git a/packages/ts/sidecar/src/cli/composition-root.test.ts b/packages/ts/sidecar/src/cli/composition-root.test.ts new file mode 100644 index 0000000..04c970d --- /dev/null +++ b/packages/ts/sidecar/src/cli/composition-root.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { CompositionRoot } from '#sidecar/cli/composition-root'; + +test('CompositionRoot wires formatAllCommand end-to-end through the Node adapters', async () => { + const dir = await mkdtemp( + join( + tmpdir(), + 'fmtkit-composition-root-', + ), + ); + + try { + const file = join(dir, 'app.ts'); + + await writeFile(file, 'function run() {\n\tconst x = 1;\n\tif (x) return x;\n\treturn 0;\n}\n'); + + const exitCode = await CompositionRoot.production() + .formatAllCommand() + .run(['--format-files', file, '--syntax-files', file]); + + assert.equal(exitCode, 0); + + const updated = await readFile(file, 'utf8'); + + assert.match(updated, /if \(x\) \{\n\t\treturn x;\n\t\}/); + } finally { + await rm( + dir, + { recursive: true, force: true }, + ); + } +}); + +test('CompositionRoot builds every named command', () => { + const root = CompositionRoot.production(); + + assert.equal(typeof root.formatAllCommand().run, 'function'); + assert.equal(typeof root.segmentPassCommand().run, 'function'); + assert.equal(typeof root.fluentPassCommand().run, 'function'); + assert.equal(typeof root.validateSyntaxCommand().run, 'function'); +}); diff --git a/packages/ts/sidecar/src/cli/composition-root.ts b/packages/ts/sidecar/src/cli/composition-root.ts new file mode 100644 index 0000000..af7b0d7 --- /dev/null +++ b/packages/ts/sidecar/src/cli/composition-root.ts @@ -0,0 +1,107 @@ +import { FormatAllCommand } from '#sidecar/cli/format-all-command'; +import { FormatPassCommand } from '#sidecar/cli/format-pass-command'; +import { FormatPipeline } from '#sidecar/pipeline/format-pipeline'; +import { NodeProcessRunner } from '#sidecar/io/process-runner'; +import { NodeSourceFiles } from '#sidecar/io/source-files'; +import { PassReporter, SyntaxReporter } from '#sidecar/cli/reporter'; +import { PipelineFactory } from '#sidecar/pipeline/pipeline-factory'; +import type { ProcessRunner } from '#sidecar/io/process-runner'; +import { SourceFileEditor } from '#sidecar/pipeline/source-file-editor'; +import type { SourceFiles } from '#sidecar/io/source-files'; +import { ValidateSyntaxCommand } from '#sidecar/cli/validate-syntax-command'; + +/** + * The single production wiring point for the sidecar CLI. It composes the + * pass/pipeline graph from {@link PipelineFactory} with the IO adapters, the + * shared {@link FormatPipeline}, reporters, and the CLI command classes. + */ +export class CompositionRoot { + readonly #factory: PipelineFactory; + readonly #pipeline: FormatPipeline; + + /** + * @param ports - The Node adapters the pipeline reads and runs through. + * @param ports.sourceFiles - The filesystem port for reads and writes. + * @param ports.processRunner - The process port for invoking oxfmt. + */ + private constructor(ports: { sourceFiles: SourceFiles; processRunner: ProcessRunner }) { + this.#factory = PipelineFactory.create(); + this.#pipeline = new FormatPipeline({ + editor: new SourceFileEditor({ sourceFiles: ports.sourceFiles }), + processRunner: ports.processRunner, + validator: this.#factory.syntaxValidator(ports.sourceFiles), + }); + } + + /** + * Build the production composition root over the Node filesystem and process ports. + * + * @returns A composition root wired with the default Node adapters. + */ + static production(): CompositionRoot { + return new CompositionRoot({ + sourceFiles: new NodeSourceFiles(), + processRunner: new NodeProcessRunner(), + }); + } + + /** + * Build the full-pipeline command running the ordered formatting schedule. + * + * @returns The composed {@link FormatAllCommand}. + */ + formatAllCommand(): FormatAllCommand { + return new FormatAllCommand({ + pipeline: this.#pipeline, + segmentFormatter: this.#factory.segmentFormatter(), + fluentFormatter: this.#factory.fluentFormatter(), + reporter: new PassReporter(), + syntaxReporter: new SyntaxReporter(), + targets: this.#factory.fileTargetPolicy(), + }); + } + + /** + * Build the standalone blank-lines segment-pass command. + * + * @returns The composed segment {@link FormatPassCommand}. + */ + segmentPassCommand(): FormatPassCommand { + return new FormatPassCommand({ + pipeline: this.#pipeline, + formatter: this.#factory.segmentFormatter(), + reporter: new PassReporter(), + targets: this.#factory.fileTargetPolicy(), + label: 'blank-lines', + failureNoun: 'blank-line edits', + }); + } + + /** + * Build the standalone fluent-chains pass command. + * + * @returns The composed fluent {@link FormatPassCommand}. + */ + fluentPassCommand(): FormatPassCommand { + return new FormatPassCommand({ + pipeline: this.#pipeline, + formatter: this.#factory.fluentFormatter(), + reporter: new PassReporter(), + targets: this.#factory.fileTargetPolicy(), + label: 'fluent-chains', + failureNoun: 'fluent-chain edits', + }); + } + + /** + * Build the standalone syntax-validation command. + * + * @returns The composed {@link ValidateSyntaxCommand}. + */ + validateSyntaxCommand(): ValidateSyntaxCommand { + return new ValidateSyntaxCommand({ + pipeline: this.#pipeline, + reporter: new SyntaxReporter(), + }); + } +} diff --git a/packages/ts/sidecar/src/fluent-chains.test.ts b/packages/ts/sidecar/src/cli/fluent-chains.test.ts similarity index 90% rename from packages/ts/sidecar/src/fluent-chains.test.ts rename to packages/ts/sidecar/src/cli/fluent-chains.test.ts index eb4ebf5..7280721 100644 --- a/packages/ts/sidecar/src/fluent-chains.test.ts +++ b/packages/ts/sidecar/src/cli/fluent-chains.test.ts @@ -5,10 +5,18 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it } from 'node:test'; import { fileURLToPath } from 'node:url'; -import { FluentChains } from '#sidecar/fluent-chains'; +import { PipelineFactory } from '#sidecar/pipeline/pipeline-factory'; +import { SourceDocument } from '#sidecar/syntax/source-document'; + +const fluentPipeline = PipelineFactory.create().fluentPipeline(); + +// The composed fluent → Drizzle → expanded pipeline the fluent-chains CLI runs. +function format(input: string, virtualName: string): string { + return fluentPipeline.apply(SourceDocument.of(virtualName, input)).text; +} const script = fileURLToPath( - import.meta.resolve('#sidecar/fluent-chains'), + import.meta.resolve('#sidecar/cli/fluent-chains'), ); const tsx = fileURLToPath( import.meta.resolve('tsx'), @@ -65,26 +73,26 @@ describe('fluent chain formatter', () => { '', ].join('\n'); - assert.equal(FluentChains.format(input, 'fixture.ts'), expected); + assert.equal(format(input, 'fixture.ts'), expected); }); it('is idempotent for already split chains', () => { const input = ['const routes = createRouter()', "\t.use('*', bindEnv)", "\t.get('/', getMe);", ''].join('\n'); - assert.equal(FluentChains.format(FluentChains.format(input, 'fixture.ts'), 'fixture.ts'), input); + assert.equal(format(format(input, 'fixture.ts'), 'fixture.ts'), input); }); it('uses the file indentation style for split chains', () => { const input = ['function routes() {', " return createRouter().use('*', bindEnv).get('/', getMe);", '}', ''].join('\n'); const expected = ['function routes() {', ' return createRouter()', " .use('*', bindEnv)", " .get('/', getMe);", '}', ''].join('\n'); - assert.equal(FluentChains.format(input, 'fixture.ts'), expected); + assert.equal(format(input, 'fixture.ts'), expected); }); it('splits chains with four spaces when the source is space-indented', () => { const input = ['function routes() {', " return createRouter().use('*', bindEnv).get('/', getMe);", '}', ''].join('\n'); const expected = ['function routes() {', ' return createRouter()', " .use('*', bindEnv)", " .get('/', getMe);", '}', ''].join('\n'); - const output = FluentChains.format(input, 'fixture.ts'); + const output = format(input, 'fixture.ts'); assert.equal(output, expected); assert.ok(!output.includes('\t'), 'space-indented chain splitting must not introduce tabs'); @@ -96,7 +104,7 @@ describe('fluent chain formatter', () => { // continuations land at 4 tabs, not 6. const input = ['\t\t\tconst result = builder().withA(1).withB(2).withC(3).build();', ''].join('\n'); const expected = ['\t\t\tconst result = builder()', '\t\t\t\t.withA(1)', '\t\t\t\t.withB(2)', '\t\t\t\t.withC(3)', '\t\t\t\t.build();', ''].join('\n'); - const output = FluentChains.format(input, 'fixture.ts'); + const output = format(input, 'fixture.ts'); assert.equal(output, expected); @@ -106,20 +114,20 @@ describe('fluent chain formatter', () => { it('leaves short value transform chains unchanged', () => { const input = ['const normalized = value.trim().toLowerCase();', ''].join('\n'); - assert.equal(FluentChains.format(input, 'fixture.ts'), input); + assert.equal(format(input, 'fixture.ts'), input); }); it('preserves optional chain operators', () => { const input = ["const result = makeClient()?.use(auth).get('/');", ''].join('\n'); const expected = ['const result = makeClient()', '\t?.use(auth)', "\t.get('/');", ''].join('\n'); - assert.equal(FluentChains.format(input, 'fixture.ts'), expected); + assert.equal(format(input, 'fixture.ts'), expected); }); it('skips chains with comments between links', () => { const input = ['const routes = createRouter()', '\t// attach middleware first', "\t.use('*', bindEnv).get('/', getMe);", ''].join('\n'); - assert.equal(FluentChains.format(input, 'fixture.ts'), input); + assert.equal(format(input, 'fixture.ts'), input); }); it('reaches a fixed point over an expanded multiline template literal', () => { @@ -127,12 +135,12 @@ describe('fluent chain formatter', () => { // committed byte state survived another pass. const interior = ['
', ' hello', '
']; const input = ['const Harness = defineComponent({', ' template: `', ...interior, ' `,', '});', ''].join('\n'); - const once = FluentChains.format(input, 'fixture.ts'); - const twice = FluentChains.format(once, 'fixture.ts'); + const once = format(input, 'fixture.ts'); + const twice = format(once, 'fixture.ts'); assert.equal(twice, once); - assert.equal(FluentChains.format(twice, 'fixture.ts'), once); + assert.equal(format(twice, 'fixture.ts'), once); assert.ok(once.includes(interior.join('\n')), 'the literal interior must keep its original bytes'); }); diff --git a/packages/ts/sidecar/src/cli/fluent-chains.ts b/packages/ts/sidecar/src/cli/fluent-chains.ts new file mode 100644 index 0000000..fd1bf2a --- /dev/null +++ b/packages/ts/sidecar/src/cli/fluent-chains.ts @@ -0,0 +1,20 @@ +import { pathToFileURL } from 'node:url'; +import { CompositionRoot } from '#sidecar/cli/composition-root'; + +/** + * Run the standalone fluent-chain formatter entrypoint. + * + * @returns Nothing after running the command and setting the process status. + */ +async function main(): Promise { + process.exitCode = await CompositionRoot.production() + .fluentPassCommand() + .run(process.argv.slice(2)); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/packages/ts/sidecar/src/cli/format-all-cli-dto.ts b/packages/ts/sidecar/src/cli/format-all-cli-dto.ts new file mode 100644 index 0000000..4a03139 --- /dev/null +++ b/packages/ts/sidecar/src/cli/format-all-cli-dto.ts @@ -0,0 +1,93 @@ +import { z } from 'zod'; +import { UnexpectedCliArgument } from '#sidecar/kernel/errors'; +import type { FormatMode } from '#sidecar/pipeline/format-pipeline'; +import { err, ok } from '#sidecar/kernel/result'; +import type { Result } from '#sidecar/kernel/result'; + +/** Immutable command-line options for the full formatting pipeline. */ +export class CliOptionsDto { + /** Whether the pipeline checks source or writes changes. */ + readonly mode: FormatMode; + + /** The oxfmt executable, or `null` to skip external formatting. */ + readonly oxfmtBin: string | null; + + /** The oxfmt configuration path, or `null` to use its defaults. */ + readonly oxfmtConfig: string | null; + + /** Files eligible for formatting passes. */ + readonly formatFiles: readonly string[]; + + /** Files eligible for final syntax validation. */ + readonly syntaxFiles: readonly string[]; + + static readonly #argvSchema = z.array(z.string()); + + static readonly #schema = z.object({ + mode: z.enum(['check', 'write']), + oxfmtBin: z.string().nullable(), + oxfmtConfig: z.string().nullable(), + formatFiles: z.array(z.string()), + syntaxFiles: z.array(z.string()), + }); + + private constructor(value: { mode: FormatMode; oxfmtBin: string | null; oxfmtConfig: string | null; formatFiles: string[]; syntaxFiles: string[] }) { + this.mode = value.mode; + this.oxfmtBin = value.oxfmtBin; + this.oxfmtConfig = value.oxfmtConfig; + this.formatFiles = Object.freeze(value.formatFiles); + this.syntaxFiles = Object.freeze(value.syntaxFiles); + + Object.setPrototypeOf(this, Object.prototype); + Object.freeze(this); + } + + /** + * Parse the full-pipeline command line. + * + * @param input - Arguments after the executable and script path. + * @returns Parsed options, or the unexpected argument as a typed value. + */ + static parse(input: unknown): Result { + const argv = CliOptionsDto.#argvSchema.parse(input); + + const candidate = { + mode: 'write' as FormatMode, + oxfmtBin: null as string | null, + oxfmtConfig: null as string | null, + formatFiles: [] as string[], + syntaxFiles: [] as string[], + }; + + let section: 'formatFiles' | 'syntaxFiles' | null = null; + + for (let index = 0; index < argv.length; index++) { + const argument = argv[index]; + + if (argument === undefined) { + continue; + } + + if (argument === '--check') { + candidate.mode = 'check'; + section = null; + } else if (argument === '--oxfmt-bin') { + candidate.oxfmtBin = argv[++index] ?? null; + section = null; + } else if (argument === '--oxfmt-config') { + candidate.oxfmtConfig = argv[++index] ?? null; + section = null; + } else if (argument === '--format-files') { + section = 'formatFiles'; + } else if (argument === '--syntax-files') { + section = 'syntaxFiles'; + } else if (section) { + candidate[section].push(argument); + } else { + return err(new UnexpectedCliArgument(argument)); + } + } + + return ok(new CliOptionsDto(CliOptionsDto.#schema.parse(candidate))); + } +} diff --git a/packages/ts/sidecar/src/cli/format-all-command.ts b/packages/ts/sidecar/src/cli/format-all-command.ts new file mode 100644 index 0000000..da9b69d --- /dev/null +++ b/packages/ts/sidecar/src/cli/format-all-command.ts @@ -0,0 +1,96 @@ +import { CliOptionsDto } from '#sidecar/cli/format-all-cli-dto'; +import type { CliCommand } from '#sidecar/cli/command'; +import type { FileFormatter } from '#sidecar/pipeline/file-formatter'; +import type { FileTargetPolicy } from '#sidecar/hosts/file-target-policy'; +import type { FormatPipeline } from '#sidecar/pipeline/format-pipeline'; +import { isErr } from '#sidecar/kernel/result'; +import type { PassReporter, SyntaxReporter } from '#sidecar/cli/reporter'; + +/** Runs the full formatting schedule: segment, oxfmt, fluent, segment, validate. */ +export class FormatAllCommand implements CliCommand { + readonly #pipeline: FormatPipeline; + readonly #segmentFormatter: FileFormatter; + readonly #fluentFormatter: FileFormatter; + readonly #reporter: PassReporter; + readonly #syntaxReporter: SyntaxReporter; + readonly #targets: FileTargetPolicy; + + /** + * @param dependencies - The pipeline, formatters, and reporters the schedule runs on. + * @param dependencies.pipeline - Runs passes, oxfmt, and validation over files. + * @param dependencies.segmentFormatter - The blank-lines segment formatter. + * @param dependencies.fluentFormatter - The fluent-chains formatter. + * @param dependencies.reporter - Renders formatting-pass reporting lines. + * @param dependencies.syntaxReporter - Renders syntax-validation reporting lines. + * @param dependencies.targets - Classifies the format and syntax target files. + */ + constructor(dependencies: { + pipeline: FormatPipeline; + segmentFormatter: FileFormatter; + fluentFormatter: FileFormatter; + reporter: PassReporter; + syntaxReporter: SyntaxReporter; + targets: FileTargetPolicy; + }) { + this.#pipeline = dependencies.pipeline; + this.#segmentFormatter = dependencies.segmentFormatter; + this.#fluentFormatter = dependencies.fluentFormatter; + this.#reporter = dependencies.reporter; + this.#syntaxReporter = dependencies.syntaxReporter; + this.#targets = dependencies.targets; + } + + /** + * Parse the full-pipeline command line and run the ordered formatting schedule. + * + * @param argv - Arguments after the executable and script path. + * @returns `0` when every stage succeeds, `1` at the first reported failure. + */ + async run(argv: readonly string[]): Promise { + const parsed = CliOptionsDto.parse(argv); + + if (isErr(parsed)) { + console.error(parsed.error); + + return 1; + } + + const options = parsed.value; + const formatTargets = [...new Set(options.formatFiles.filter((file) => this.#targets.isTargetFile(file)))]; + const syntaxTargets = [...new Set(options.syntaxFiles.filter((file) => this.#targets.isSyntaxTarget(file)))]; + + const blankLines = await this.#pipeline.runPass(this.#segmentFormatter, formatTargets, options.mode); + + if (!this.#reporter.reportPass('blank-lines', formatTargets, options.mode, blankLines, 'edits')) { + return 1; + } + + const oxfmt = await this.#pipeline.runOxfmt({ bin: options.oxfmtBin, config: options.oxfmtConfig, files: formatTargets, mode: options.mode }); + + if (isErr(oxfmt)) { + console.error(oxfmt.error); + + return 1; + } + + const fluentChains = await this.#pipeline.runPass(this.#fluentFormatter, formatTargets, options.mode); + + if (!this.#reporter.reportPass('fluent-chains', formatTargets, options.mode, fluentChains, 'edits')) { + return 1; + } + + // Fluent and expanded calls create blank-line obligations the first pass + // cannot see, so the second pass makes one invocation reach a fixed point. + const finalBlankLines = await this.#pipeline.runPass(this.#segmentFormatter, formatTargets, options.mode); + + if (!this.#reporter.reportPass('blank-lines', formatTargets, options.mode, finalBlankLines, 'edits')) { + return 1; + } + + if (!this.#syntaxReporter.report(syntaxTargets, await this.#pipeline.validate(syntaxTargets))) { + return 1; + } + + return 0; + } +} diff --git a/packages/ts/sidecar/src/format-all.test.ts b/packages/ts/sidecar/src/cli/format-all.test.ts similarity index 83% rename from packages/ts/sidecar/src/format-all.test.ts rename to packages/ts/sidecar/src/cli/format-all.test.ts index 7d8bef2..880c4b4 100644 --- a/packages/ts/sidecar/src/format-all.test.ts +++ b/packages/ts/sidecar/src/cli/format-all.test.ts @@ -1,20 +1,31 @@ import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; +import { availableParallelism, tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { test } from 'node:test'; import { promisify } from 'node:util'; -import { SourceFileUnreadable } from '#sidecar/errors'; -import { CliOptionsDto } from '#sidecar/format-all'; -import { FormatPipeline } from '#sidecar/format-pipeline'; -import { NodeProcessRunner } from '#sidecar/process-runner'; -import { err, isErr, ok } from '#sidecar/result'; -import { NodeSourceFiles } from '#sidecar/source-files'; +import { CliOptionsDto } from '#sidecar/cli/format-all-cli-dto'; +import { FormatPipeline } from '#sidecar/pipeline/format-pipeline'; +import { mapPool } from '#sidecar/kernel/concurrency'; +import { NodeProcessRunner } from '#sidecar/io/process-runner'; +import { isErr } from '#sidecar/kernel/result'; +import { PipelineFactory } from '#sidecar/pipeline/pipeline-factory'; +import { SourceFileEditor } from '#sidecar/pipeline/source-file-editor'; +import { NodeSourceFiles } from '#sidecar/io/source-files'; const execFileAsync = promisify(execFile); const formatAllScript = resolve(import.meta.dirname, 'format-all.ts'); -const pipeline = new FormatPipeline({ sourceFiles: new NodeSourceFiles(), processRunner: new NodeProcessRunner() }); +const factory = PipelineFactory.create(); +const sourceFiles = new NodeSourceFiles(); + +const pipeline = new FormatPipeline({ + editor: new SourceFileEditor({ sourceFiles }), + processRunner: new NodeProcessRunner(), + validator: factory.syntaxValidator(sourceFiles), +}); + +const segmentFormatter = factory.segmentFormatter(); test('parseArgs splits flags and file sections', () => { const options = CliOptionsDto.parse(['--check', '--oxfmt-bin', '/bin/oxfmt', '--oxfmt-config', '/etc/oxfmtrc.json', '--format-files', 'a.ts', 'b.vue', '--syntax-files', 'a.ts', 'types.d.ts']); @@ -59,23 +70,25 @@ test('mapPool processes every item, preserves order, and honors the limit', asyn return index; }); - const outcomes = await pipeline.runPass('test', items.map(String), 'check', async (item) => { - active++; - peak = Math.max(peak, active); + const outcomes = await mapPool( + items, + availableParallelism(), + async (item) => { + active++; + peak = Math.max(peak, active); - await new Promise((resolvePromise) => { - return setTimeout(resolvePromise, 1); - }); + await new Promise((resolvePromise) => { + return setTimeout(resolvePromise, 1); + }); - active--; + active--; - return ok(Number(item) % 2 === 0); - }); + return item % 2 === 0; + }, + ); assert.deepEqual( - outcomes.map((outcome) => { - return outcome.changed; - }), + outcomes, items.map((item) => { return item % 2 === 0; }), @@ -85,21 +98,35 @@ test('mapPool processes every item, preserves order, and honors the limit', asyn }); test('runPass processes every file and skips missing ones', async () => { - const seen: string[] = []; + const dir = await mkdtemp( + join( + tmpdir(), + 'fmtkit-sidecar-runpass-', + ), + ); - const outcomes = await pipeline.runPass('blank-lines', ['a.ts', 'missing.ts', 'b.ts'], 'write', async (file) => { - if (file === 'missing.ts') { - return err(new SourceFileUnreadable(file, { code: 'ENOENT' })); - } + try { + const first = join(dir, 'a.ts'); + const missing = join(dir, 'missing.ts'); + const last = join(dir, 'b.ts'); - seen.push(file); + await writeFile(first, 'const value = 1;\n'); - return ok(true); - }); + await writeFile(last, 'const value = 1;\n'); - assert.deepEqual(seen.sort(), ['a.ts', 'b.ts']); + const outcomes = await pipeline.runPass(segmentFormatter, [first, missing, last], 'write'); - assert.equal(outcomes[1]?.error?._tag === 'SourceFileUnreadable' && outcomes[1].error.isNotFound(), true); + assert.equal(outcomes[0]?.error, null); + + assert.equal(outcomes[2]?.error, null); + + assert.equal(outcomes[1]?.error?._tag === 'SourceFileUnreadable' && outcomes[1].error.isNotFound(), true); + } finally { + await rm( + dir, + { recursive: true, force: true }, + ); + } }); test('runOxfmt resolves without spawning when no binary or no files are given', async () => { diff --git a/packages/ts/sidecar/src/cli/format-all.ts b/packages/ts/sidecar/src/cli/format-all.ts new file mode 100644 index 0000000..1ae8296 --- /dev/null +++ b/packages/ts/sidecar/src/cli/format-all.ts @@ -0,0 +1,20 @@ +import { pathToFileURL } from 'node:url'; +import { CompositionRoot } from '#sidecar/cli/composition-root'; + +/** + * Run the full formatting CLI and map its exit code to the process status. + * + * @returns Nothing after running the command and setting the process status. + */ +export async function main(): Promise { + process.exitCode = await CompositionRoot.production() + .formatAllCommand() + .run(process.argv.slice(2)); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/packages/ts/sidecar/src/cli/format-pass-command.ts b/packages/ts/sidecar/src/cli/format-pass-command.ts new file mode 100644 index 0000000..02f9c6c --- /dev/null +++ b/packages/ts/sidecar/src/cli/format-pass-command.ts @@ -0,0 +1,49 @@ +import type { CliCommand } from '#sidecar/cli/command'; +import type { FileFormatter } from '#sidecar/pipeline/file-formatter'; +import type { FileTargetPolicy } from '#sidecar/hosts/file-target-policy'; +import type { FormatPipeline } from '#sidecar/pipeline/format-pipeline'; +import { PassCliDto } from '#sidecar/cli/pass-cli-dto'; +import type { PassReporter } from '#sidecar/cli/reporter'; + +/** Runs a single standalone formatting pass over the CLI's target files. */ +export class FormatPassCommand implements CliCommand { + readonly #pipeline: FormatPipeline; + readonly #formatter: FileFormatter; + readonly #reporter: PassReporter; + readonly #targets: FileTargetPolicy; + readonly #label: string; + readonly #failureNoun: string; + + /** + * @param dependencies - The pipeline, formatter, reporter, and labels for the pass. + * @param dependencies.pipeline - Applies the formatter across files concurrently. + * @param dependencies.formatter - The file formatter whose pipeline drives the pass. + * @param dependencies.reporter - Renders per-file and summary reporting lines. + * @param dependencies.targets - Classifies the target files parsed from the command line. + * @param dependencies.label - The reporting label the pass emits. + * @param dependencies.failureNoun - The change description used in check-mode guidance. + */ + constructor(dependencies: { pipeline: FormatPipeline; formatter: FileFormatter; reporter: PassReporter; targets: FileTargetPolicy; label: string; failureNoun: string }) { + this.#pipeline = dependencies.pipeline; + this.#formatter = dependencies.formatter; + this.#reporter = dependencies.reporter; + this.#targets = dependencies.targets; + this.#label = dependencies.label; + this.#failureNoun = dependencies.failureNoun; + } + + /** + * Parse the pass command line, run the pass, and report its outcomes. + * + * @param argv - Arguments after the executable and script path. + * @returns `0` when the pass succeeds, `1` when it reports a failure. + */ + async run(argv: readonly string[]): Promise { + const options = PassCliDto.parse(argv, this.#targets); + const files = [...options.files]; + + const outcomes = await this.#pipeline.runPass(this.#formatter, files, options.mode); + + return this.#reporter.reportPass(this.#label, files, options.mode, outcomes, this.#failureNoun) ? 0 : 1; + } +} diff --git a/packages/ts/sidecar/src/pass-cli-dto.ts b/packages/ts/sidecar/src/cli/pass-cli-dto.ts similarity index 79% rename from packages/ts/sidecar/src/pass-cli-dto.ts rename to packages/ts/sidecar/src/cli/pass-cli-dto.ts index 2b18c82..7544b2e 100644 --- a/packages/ts/sidecar/src/pass-cli-dto.ts +++ b/packages/ts/sidecar/src/cli/pass-cli-dto.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { FileTargets } from '#sidecar/file-targets'; +import type { FileTargetPolicy } from '#sidecar/hosts/file-target-policy'; /** Immutable command-line options shared by standalone formatting passes. */ export class PassCliDto { @@ -28,15 +28,16 @@ export class PassCliDto { * Parse a standalone formatting pass command line. * * @param input - Arguments after the executable and script path. + * @param targets - The policy that classifies eligible target files. * @returns Immutable formatting pass options. */ - static parse(input: unknown): PassCliDto { + static parse(input: unknown, targets: FileTargetPolicy): PassCliDto { const argv = PassCliDto.#argvSchema.parse(input); const candidate = { mode: argv.includes('--check') ? ('check' as const) : ('write' as const), files: argv.filter((argument) => { - return argument !== '--check' && FileTargets.isTargetFile(argument); + return argument !== '--check' && targets.isTargetFile(argument); }), }; diff --git a/packages/ts/sidecar/src/cli/reporter.ts b/packages/ts/sidecar/src/cli/reporter.ts new file mode 100644 index 0000000..4ba717a --- /dev/null +++ b/packages/ts/sidecar/src/cli/reporter.ts @@ -0,0 +1,110 @@ +import type { OxcErrorDto } from '#sidecar/kernel/errors'; +import type { FormatMode, PassOutcome, ValidationFailure } from '#sidecar/pipeline/format-pipeline'; + +/** Reports formatting-pass values to the console without coupling passes to it. */ +export class PassReporter { + /** + * Report one formatting pass and decide whether execution may continue. + * + * @param label - The formatting pass label. + * @param files - The source paths requested for the pass. + * @param mode - Whether the pass checked or wrote source. + * @param outcomes - The ordered outcomes produced by the pass. + * @param failureNoun - The change description used in check-mode guidance. + * @returns `true` when no outcome or pending change makes the pass fail. + */ + reportPass(label: string, files: readonly string[], mode: FormatMode, outcomes: PassOutcome[], failureNoun: string): boolean { + let changedCount = 0; + + for (const outcome of outcomes) { + if (outcome.error?._tag === 'SourceFileUnreadable' && outcome.error.isNotFound()) { + console.warn(`[${label}] path not found, skipping: ${outcome.file}`); + + continue; + } + + if (outcome.error) { + console.error(outcome.error); + + return false; + } + + if (outcome.changed) { + changedCount++; + console.log(`[${label}] ${mode === 'check' ? 'would change' : 'updated'} ${outcome.file}`); + } + } + + if (mode === 'check' && changedCount > 0) { + console.error(`[${label}] ${changedCount} file(s) need ${failureNoun}. Run "pnpm format" to fix.`); + + return false; + } + + console.log(`[${label}] processed ${files.length} file(s) in ${process.cwd()}, ${changedCount} ${mode === 'check' ? 'would change' : 'changed'}`); + + return true; + } +} + +/** Reports syntax-validation values to the console without coupling validation to it. */ +export class SyntaxReporter { + /** + * Format one parser diagnostic for console output. + * + * @param file - The source path associated with the diagnostic. + * @param error - The parser diagnostic to render. + * @returns A source-framed message, plain message, or stable fallback. + */ + format(file: string, error: OxcErrorDto): string { + if (error.codeframe && error.codeframe.length > 0) { + return `[validate-syntax] ${file}\n${error.codeframe.trimEnd()}`; + } + + if (error.message && error.message.length > 0) { + return `[validate-syntax] ${file}: ${error.message}`; + } + + return `[validate-syntax] ${file}: syntax validation failed`; + } + + /** + * Report syntax-validation failures and decide whether execution succeeded. + * + * @param files - The source paths requested for validation. + * @param failures - The ordered read and parse failures. + * @returns `true` when no reportable validation failure remains. + */ + report(files: readonly string[], failures: ValidationFailure[]): boolean { + const diagnostics: string[] = []; + + for (const failure of failures) { + if (failure.error._tag === 'SourceFileUnreadable') { + if (failure.error.isNotFound()) { + console.warn(`[validate-syntax] path not found, skipping: ${failure.file}`); + + continue; + } + + console.error(failure.error); + + return false; + } + + for (const error of failure.error.errors) { + diagnostics.push(this.format(failure.file, error)); + } + } + + if (diagnostics.length > 0) { + console.error(diagnostics.join('\n')); + console.error(`[validate-syntax] ${diagnostics.length} syntax error(s) found after formatting.`); + + return false; + } + + console.log(`[validate-syntax] checked ${files.length} file(s) in ${process.cwd()}`); + + return true; + } +} diff --git a/packages/ts/sidecar/src/cli/syntax-cli-dto.ts b/packages/ts/sidecar/src/cli/syntax-cli-dto.ts new file mode 100644 index 0000000..cff3b6c --- /dev/null +++ b/packages/ts/sidecar/src/cli/syntax-cli-dto.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +/** Immutable command-line options for standalone syntax validation. */ +export class SyntaxCliDto { + /** TypeScript and Vue files eligible for syntax validation. */ + readonly files: readonly string[]; + + static readonly #argvSchema = z.array(z.string()); + + static readonly #schema = z.object({ + files: z.array(z.string()), + }); + + private constructor(value: { files: string[] }) { + this.files = Object.freeze(value.files); + + Object.setPrototypeOf(this, Object.prototype); + Object.freeze(this); + } + + /** + * Parse the standalone syntax-validation command line. + * + * @param input - Arguments after the executable and script path. + * @returns Immutable syntax-validation options. + */ + static parse(input: unknown): SyntaxCliDto { + const argv = SyntaxCliDto.#argvSchema.parse(input); + + const files = argv.filter((file) => { + return file.endsWith('.ts') || file.endsWith('.vue'); + }); + + return new SyntaxCliDto(SyntaxCliDto.#schema.parse({ files })); + } +} diff --git a/packages/ts/sidecar/src/cli/validate-syntax-command.ts b/packages/ts/sidecar/src/cli/validate-syntax-command.ts new file mode 100644 index 0000000..687174b --- /dev/null +++ b/packages/ts/sidecar/src/cli/validate-syntax-command.ts @@ -0,0 +1,35 @@ +import type { CliCommand } from '#sidecar/cli/command'; +import type { FormatPipeline } from '#sidecar/pipeline/format-pipeline'; +import { SyntaxCliDto } from '#sidecar/cli/syntax-cli-dto'; +import type { SyntaxReporter } from '#sidecar/cli/reporter'; + +/** Runs standalone syntax validation over the CLI's target files. */ +export class ValidateSyntaxCommand implements CliCommand { + readonly #pipeline: FormatPipeline; + readonly #reporter: SyntaxReporter; + + /** + * @param dependencies - The pipeline and reporter used to validate and report. + * @param dependencies.pipeline - Validates files and host embedded blocks. + * @param dependencies.reporter - Renders parser diagnostics and summary lines. + */ + constructor(dependencies: { pipeline: FormatPipeline; reporter: SyntaxReporter }) { + this.#pipeline = dependencies.pipeline; + this.#reporter = dependencies.reporter; + } + + /** + * Parse the validation command line, validate, and report the failures. + * + * @param argv - Arguments after the executable and script path. + * @returns `0` when validation succeeds, `1` when it reports a failure. + */ + async run(argv: readonly string[]): Promise { + const options = SyntaxCliDto.parse(argv); + const files = [...options.files]; + + const failures = await this.#pipeline.validate(files); + + return this.#reporter.report(files, failures) ? 0 : 1; + } +} diff --git a/packages/ts/sidecar/src/validate-syntax.test.ts b/packages/ts/sidecar/src/cli/validate-syntax.test.ts similarity index 98% rename from packages/ts/sidecar/src/validate-syntax.test.ts rename to packages/ts/sidecar/src/cli/validate-syntax.test.ts index 3649203..af1aec1 100644 --- a/packages/ts/sidecar/src/validate-syntax.test.ts +++ b/packages/ts/sidecar/src/cli/validate-syntax.test.ts @@ -7,7 +7,7 @@ import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; const script = fileURLToPath( - import.meta.resolve('#sidecar/validate-syntax'), + import.meta.resolve('#sidecar/cli/validate-syntax'), ); const tsx = fileURLToPath( import.meta.resolve('tsx'), diff --git a/packages/ts/sidecar/src/cli/validate-syntax.ts b/packages/ts/sidecar/src/cli/validate-syntax.ts new file mode 100644 index 0000000..57efd2a --- /dev/null +++ b/packages/ts/sidecar/src/cli/validate-syntax.ts @@ -0,0 +1,20 @@ +import { pathToFileURL } from 'node:url'; +import { CompositionRoot } from '#sidecar/cli/composition-root'; + +/** + * Run the standalone syntax-validation entrypoint. + * + * @returns Nothing after running the command and setting the process status. + */ +async function main(): Promise { + process.exitCode = await CompositionRoot.production() + .validateSyntaxCommand() + .run(process.argv.slice(2)); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/packages/ts/sidecar/src/declaration-reorder.ts b/packages/ts/sidecar/src/declaration-reorder.ts deleted file mode 100644 index 765154e..0000000 --- a/packages/ts/sidecar/src/declaration-reorder.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { Ast } from '#sidecar/ast'; -import { Node } from '#sidecar/node-schema'; -import { isErr } from '#sidecar/result'; -import { SourceText } from '#sidecar/source-text'; -import { Sources } from '#sidecar/sources'; -import type { Edit } from '#sidecar/types'; - -/** Reorders declarations only where the transformation is side-effect safe. */ -export class DeclarationReorder { - static #isMultiline(source: string, node: Node): boolean { - const start = Ast.getStart(node); - const end = Ast.getEnd(node); - - return start >= 0 && end >= 0 && source.slice(start, end).includes('\n'); - } - - static #nodeSource(source: string, node: Node): string { - const start = Ast.getStart(node); - const end = Ast.getEnd(node); - - return `${SourceText.lineIndent(source, start)}${source.slice(start, end)}`; - } - - static #isSideEffectSafeExpression(node: Node | undefined): boolean { - if (!node) { - return true; - } - - switch (node.type) { - case 'ArrowFunctionExpression': - - case 'FunctionExpression': - - case 'Identifier': - - case 'Literal': - return true; - - case 'ArrayExpression': { - const elements = node.elements; - - return ( - Array.isArray(elements) && - elements.every((element) => { - if (element === null) { - return true; - } - - return element instanceof Node && DeclarationReorder.#isSideEffectSafeExpression(element); - }) - ); - } - - case 'ObjectExpression': { - const properties = node.properties; - - return ( - Array.isArray(properties) && - properties.every((property) => { - if (!(property instanceof Node)) { - return false; - } - - if (property.type === 'SpreadElement') { - return DeclarationReorder.#isSideEffectSafeExpression(Ast.childNode(property, 'argument')); - } - - if (property.type !== 'ObjectProperty' && property.type !== 'Property') { - return false; - } - - const computed = Boolean(property.computed); - const key = Ast.childNode(property, 'key'); - const value = Ast.childNode(property, 'value'); - - return (!computed || DeclarationReorder.#isSideEffectSafeExpression(key)) && DeclarationReorder.#isSideEffectSafeExpression(value); - }) - ); - } - - case 'TemplateLiteral': { - const expressions = node.expressions; - - return ( - Array.isArray(expressions) && - expressions.every((expression) => { - return expression instanceof Node && DeclarationReorder.#isSideEffectSafeExpression(expression); - }) - ); - } - - default: - return false; - } - } - - static #isSafeConstDeclaration(node: Node): boolean { - if (!Ast.isConstDeclaration(node)) { - return false; - } - - return ( - Array.isArray(node.declarations) && - Ast.childNodes(node, 'declarations').every((declaration) => { - const id = Ast.childNode(declaration, 'id'); - - return id?.type === 'Identifier' && DeclarationReorder.#isSideEffectSafeExpression(Ast.childNode(declaration, 'init')); - }) - ); - } - - static #declaredNames(nodes: Node[]): Set { - const names = new Set(); - - for (const node of nodes) { - for (const declaration of Ast.childNodes(node, 'declarations')) { - const id = Ast.childNode(declaration, 'id'); - const name = id ? Ast.nodeName(id) : undefined; - - if (id?.type === 'Identifier' && name !== undefined) { - names.add(name); - } - } - } - - return names; - } - - static #usesAnyIdentifier(node: Node, names: Set): boolean { - let found = false; - - Ast.visit(node, (child) => { - if (found || child.type !== 'Identifier') { - return; - } - - const name = Ast.nodeName(child); - - if (name !== undefined && names.has(name)) { - found = true; - } - }); - - return found; - } - - static #canReorderConstGroup(source: string, group: Node[]): boolean { - if ( - !group.every((node) => { - return DeclarationReorder.#isSafeConstDeclaration(node); - }) - ) { - return false; - } - - for (let i = 0; i < group.length; i++) { - const node = group[i]; - - if (!node || !DeclarationReorder.#isMultiline(source, node)) { - continue; - } - - const names = DeclarationReorder.#declaredNames([node]); - - if ( - group.slice(i + 1).some((node) => { - return !DeclarationReorder.#isMultiline(source, node) && DeclarationReorder.#usesAnyIdentifier(node, names); - }) - ) { - return false; - } - } - - return true; - } - - static #splitGroups(list: Node[], predicate: (node: Node) => boolean): Node[][] { - const groups: Node[][] = []; - - let current: Node[] = []; - - for (const node of list) { - if (!predicate(node)) { - if (current.length > 1) { - groups.push(current); - } - - current = []; - continue; - } - - current.push(node); - } - - if (current.length > 1) { - groups.push(current); - } - - return groups; - } - - static #groupEdit(source: string, group: Node[], canReorder: boolean): Edit | null { - const singleLine = group.filter((node) => { - return !DeclarationReorder.#isMultiline(source, node); - }); - const multiline = group.filter((node) => { - return DeclarationReorder.#isMultiline(source, node); - }); - - if (singleLine.length === 0 || multiline.length === 0) { - return null; - } - - const desired = canReorder ? [...singleLine, ...multiline] : group; - - const replacement = desired - .map((node, index) => { - const previous = desired[index - 1]; - const separator = previous && (DeclarationReorder.#isMultiline(source, previous) || DeclarationReorder.#isMultiline(source, node)) ? '\n\n' : index > 0 ? '\n' : ''; - - return `${separator}${DeclarationReorder.#nodeSource(source, node)}`; - }) - .join(''); - - const first = group[0]; - const last = group.at(-1); - - if (!first || !last) { - return null; - } - - const firstStart = Ast.getStart(first); - const lastEnd = Ast.getEnd(last); - - if (firstStart < 0 || lastEnd < 0) { - return null; - } - - const start = SourceText.lineStart(source, firstStart); - const current = source.slice(start, lastEnd); - - if (current === replacement) { - return null; - } - - const alreadyOrdered = desired.every((node, index) => { - return node === group[index]; - }); - - if (!canReorder && !alreadyOrdered) { - return null; - } - - return { - start, - end: lastEnd, - replacement, - }; - } - /** - * Compute declaration-ordering edits. - * - * @param content - The source text to inspect. - * @param virtualName - The filename used to parse the source. - * @returns Safe declaration-ordering edits, or none for invalid source. - */ - static computeEdits(content: string, virtualName: string): Edit[] { - const parsed = Sources.parse(virtualName, content); - - if (isErr(parsed)) { - return []; - } - - const lists = Ast.collectStatementLists(parsed.value.program); - const edits: Edit[] = []; - - for (const list of lists) { - const importGroups = DeclarationReorder.#splitGroups(list, (node) => { - return node.type === 'ImportDeclaration'; - }); - - const constGroups = DeclarationReorder.#splitGroups(list, Ast.isConstDeclaration); - - for (const group of importGroups) { - const edit = DeclarationReorder.#groupEdit(content, group, true); - - if (edit) { - edits.push(edit); - } - } - - for (const group of constGroups) { - const edit = DeclarationReorder.#groupEdit(content, group, DeclarationReorder.#canReorderConstGroup(content, group)); - - if (edit) { - edits.push(edit); - } - } - } - - return edits; - } -} diff --git a/packages/ts/sidecar/src/drizzle-queries.ts b/packages/ts/sidecar/src/drizzle-queries.ts deleted file mode 100644 index 27a6ae3..0000000 --- a/packages/ts/sidecar/src/drizzle-queries.ts +++ /dev/null @@ -1,650 +0,0 @@ -import { Ast } from '#sidecar/ast'; -import { Edits } from '#sidecar/edits'; -import { FileTargets } from '#sidecar/file-targets'; -import { Node } from '#sidecar/node-schema'; -import { isErr } from '#sidecar/result'; -import { SourceText } from '#sidecar/source-text'; -import { Sources } from '#sidecar/sources'; -import type { Edit } from '#sidecar/types'; - -type DrizzleImports = { - locals: Map; - namespaces: Set; -}; - -// Detection: identify Drizzle imports, receivers, calls, and structural arguments. - -const DRIZZLE_MODULE = 'drizzle-orm'; -const DRIZZLE_RECEIVERS = new Set(['db', 'tx']); - -const DRIZZLE_CHAIN_METHODS = new Set([ - '$count', - '$dynamic', - '$with', - 'as', - 'crossJoin', - 'delete', - 'except', - 'from', - 'fullJoin', - 'groupBy', - 'having', - 'innerJoin', - 'insert', - 'intersect', - 'leftJoin', - 'limit', - 'offset', - 'onConflictDoNothing', - 'onConflictDoUpdate', - 'orderBy', - 'prepare', - 'returning', - 'rightJoin', - 'select', - 'set', - 'union', - 'unionAll', - 'update', - 'values', - 'where', - 'with', -]); - -const DRIZZLE_FORMAT_METHODS = new Set([ - '$count', - 'as', - 'crossJoin', - 'except', - 'findFirst', - 'findMany', - 'fullJoin', - 'groupBy', - 'having', - 'innerJoin', - 'intersect', - 'leftJoin', - 'onConflictDoNothing', - 'onConflictDoUpdate', - 'orderBy', - 'returning', - 'rightJoin', - 'set', - 'union', - 'unionAll', - 'values', - 'where', -]); - -const DRIZZLE_HELPERS = new Set([ - 'and', - 'arrayContained', - 'arrayContains', - 'arrayOverlaps', - 'asc', - 'between', - 'desc', - 'eq', - 'exists', - 'gt', - 'gte', - 'ilike', - 'inArray', - 'isNotNull', - 'isNull', - 'like', - 'lt', - 'lte', - 'ne', - 'not', - 'notBetween', - 'notExists', - 'notIlike', - 'notInArray', - 'notLike', - 'or', - 'sql', -]); - -const MULTILINE_HELPERS = new Set(['and', 'or', 'not', 'exists', 'notExists']); -const SET_OPERATION_HELPERS = new Set(['except', 'intersect', 'union', 'unionAll']); -const DRIZZLE_OBJECT_KEYS = new Set(['columns', 'extras', 'limit', 'offset', 'onUpdate', 'orderBy', 'set', 'target', 'targetWhere', 'where', 'with']); - -/** Formats recognised Drizzle query structures without touching unrelated calls. */ -export class DrizzleQueries { - static #localName(node: Node | undefined): string | null { - return node?.type === 'Identifier' ? (Ast.nodeName(node) ?? null) : null; - } - - static #literalValue(node: Node | undefined): string | null { - if (node?.type !== 'Literal') { - return null; - } - - return Ast.stringValue(node) ?? null; - } - - static #propertyName(member: Node | undefined): string | null { - if (member?.type !== 'MemberExpression' || member.computed) { - return null; - } - - return DrizzleQueries.#localName(Ast.childNode(member, 'property')); - } - - static #calleeName(callee: Node | undefined, imports: DrizzleImports): string | null { - if (!callee) { - return null; - } - - if (callee.type === 'Identifier') { - const name = DrizzleQueries.#localName(callee); - - return name ? (imports.locals.get(name) ?? null) : null; - } - - if (callee.type === 'MemberExpression' && !callee.computed) { - const object = Ast.childNode(callee, 'object'); - - const property = DrizzleQueries.#localName(Ast.childNode(callee, 'property')); - - const objectName = DrizzleQueries.#localName(object); - - if (objectName && property && imports.namespaces.has(objectName)) { - return property; - } - } - - return null; - } - - static #collectDrizzleImports(program: Node): DrizzleImports { - const imports: DrizzleImports = { locals: new Map(), namespaces: new Set() }; - const body = Ast.childNodes(program, 'body'); - - for (const statement of body) { - if (statement.type !== 'ImportDeclaration') { - continue; - } - - const source = DrizzleQueries.#literalValue(Ast.childNode(statement, 'source')); - - if (!source?.startsWith(DRIZZLE_MODULE)) { - continue; - } - - for (const specifier of Ast.childNodes(statement, 'specifiers')) { - if (specifier.type === 'ImportSpecifier') { - const imported = DrizzleQueries.#localName(Ast.childNode(specifier, 'imported')); - const local = DrizzleQueries.#localName(Ast.childNode(specifier, 'local')); - - if (imported && local) { - imports.locals.set(local, imported); - } - } - - if (specifier.type === 'ImportNamespaceSpecifier') { - const local = DrizzleQueries.#localName(Ast.childNode(specifier, 'local')); - - if (local) { - imports.namespaces.add(local); - } - } - } - } - - return imports; - } - - static #chainHasQueryMember(node: Node | undefined): boolean { - const current = SourceText.unwrapChainExpression(node); - - if (!current) { - return false; - } - - if (current.type === 'MemberExpression') { - if (DrizzleQueries.#propertyName(current) === 'query') { - return true; - } - - return DrizzleQueries.#chainHasQueryMember(Ast.childNode(current, 'object')); - } - - if (current.type === 'CallExpression') { - return DrizzleQueries.#chainHasQueryMember(Ast.childNode(current, 'callee')); - } - - return false; - } - - static #isDrizzleReceiver(node: Node | undefined, imports: DrizzleImports): boolean { - const current = SourceText.unwrapChainExpression(node); - - if (!current) { - return false; - } - - if (current.type === 'Identifier') { - const name = DrizzleQueries.#localName(current); - - return Boolean(name && DRIZZLE_RECEIVERS.has(name)); - } - - if (current.type === 'MemberExpression') { - const object = Ast.childNode(current, 'object'); - const property = DrizzleQueries.#propertyName(current); - - if (property === 'query') { - return DrizzleQueries.#isDrizzleReceiver(object, imports); - } - - return DrizzleQueries.#isDrizzleReceiver(object, imports); - } - - if (current.type === 'CallExpression') { - const callee = SourceText.unwrapChainExpression(Ast.childNode(current, 'callee')); - - if (callee?.type === 'Identifier') { - const imported = DrizzleQueries.#calleeName(callee, imports); - - return Boolean(imported && SET_OPERATION_HELPERS.has(imported)); - } - - if (callee?.type === 'MemberExpression') { - const method = DrizzleQueries.#propertyName(callee); - - if (method && DRIZZLE_CHAIN_METHODS.has(method)) { - return DrizzleQueries.#isDrizzleReceiver(Ast.childNode(callee, 'object'), imports); - } - - return DrizzleQueries.#isDrizzleReceiver(Ast.childNode(callee, 'object'), imports); - } - } - - return false; - } - - static #methodName(call: Node): string | null { - const callee = SourceText.unwrapChainExpression(Ast.childNode(call, 'callee')); - - return callee?.type === 'MemberExpression' ? DrizzleQueries.#propertyName(callee) : null; - } - - static #isDrizzleMethodCall(call: Node, imports: DrizzleImports): boolean { - const callee = SourceText.unwrapChainExpression(Ast.childNode(call, 'callee')); - - if (callee?.type !== 'MemberExpression') { - return false; - } - - const name = DrizzleQueries.#propertyName(callee); - - if (!name || !DRIZZLE_FORMAT_METHODS.has(name)) { - return false; - } - - return DrizzleQueries.#isDrizzleReceiver(Ast.childNode(callee, 'object'), imports); - } - - static #isRelationalQueryCall(call: Node, imports: DrizzleImports): boolean { - const name = DrizzleQueries.#methodName(call); - - const callee = SourceText.unwrapChainExpression(Ast.childNode(call, 'callee')); - - if ((name !== 'findMany' && name !== 'findFirst') || callee?.type !== 'MemberExpression') { - return false; - } - - const object = Ast.childNode(callee, 'object'); - - return DrizzleQueries.#chainHasQueryMember(object) && DrizzleQueries.#isDrizzleReceiver(object, imports); - } - - static #isImportedHelperCall(call: Node, imports: DrizzleImports): boolean { - const callee = SourceText.unwrapChainExpression(Ast.childNode(call, 'callee')); - - const name = DrizzleQueries.#calleeName(callee, imports); - - return Boolean(name && DRIZZLE_HELPERS.has(name)); - } - - static #isSetOperationCall(call: Node, imports: DrizzleImports): boolean { - const callee = SourceText.unwrapChainExpression(Ast.childNode(call, 'callee')); - - const name = DrizzleQueries.#calleeName(callee, imports); - - return Boolean(name && SET_OPERATION_HELPERS.has(name)); - } - - static #callDisplayName(source: string, call: Node, imports: DrizzleImports): string { - const callee = SourceText.unwrapChainExpression(Ast.childNode(call, 'callee')); - - if (callee?.type === 'Identifier') { - return SourceText.sourceOf(source, callee); - } - - if (callee?.type === 'MemberExpression') { - const name = DrizzleQueries.#calleeName(callee, imports); - - if (name) { - return SourceText.sourceOf(source, callee); - } - } - - return callee ? SourceText.sourceOf(source, callee) : ''; - } - - static #callParens(source: string, call: Node): { open: number; close: number } | null { - return SourceText.callParens(source, call, SourceText.unwrapChainExpression(Ast.childNode(call, 'callee'))); - } - - static #shouldFormatObjectExpression(node: Node): boolean { - const properties = Ast.childNodes(node, 'properties'); - - if (properties.length > 1) { - return true; - } - - return properties.some((property) => { - if (property.type !== 'Property') { - return true; - } - - const key = DrizzleQueries.#localName(Ast.childNode(property, 'key')); - - const value = Ast.childNode(property, 'value'); - - if (!value) { - return false; - } - - if (key && DRIZZLE_OBJECT_KEYS.has(key) && (value.type === 'ObjectExpression' || value.type === 'ArrayExpression' || value.type === 'CallExpression')) { - return true; - } - - return value.type === 'ObjectExpression' || value.type === 'ArrayExpression'; - }); - } - - static #shouldFormatArrayExpression(node: Node): boolean { - const elements = Array.isArray(node.elements) ? node.elements : []; - - return elements.length > 1 || elements.some((element) => element instanceof Node && (element.type === 'ObjectExpression' || element.type === 'CallExpression')); - } - - static #isComplexArgument(node: Node, imports: DrizzleImports): boolean { - if (node.type === 'ObjectExpression') { - return DrizzleQueries.#shouldFormatObjectExpression(node); - } - - if (node.type === 'ArrayExpression') { - return DrizzleQueries.#shouldFormatArrayExpression(node); - } - - if (node.type === 'CallExpression') { - return DrizzleQueries.#isImportedHelperCall(node, imports) || DrizzleQueries.#isSetOperationCall(node, imports) || DrizzleQueries.#isDrizzleMethodCall(node, imports); - } - - return false; - } - - static #isStructuralArgument(node: Node, imports: DrizzleImports): boolean { - if (node.type === 'ObjectExpression' || node.type === 'ArrayExpression') { - return true; - } - - return node.type === 'CallExpression' && (DrizzleQueries.#isSetOperationCall(node, imports) || DrizzleQueries.#isDrizzleMethodCall(node, imports)); - } - - static #shouldFormatMethodArguments(call: Node, imports: DrizzleImports): boolean { - const args = Ast.childNodes(call, 'arguments'); - - if (args.length === 0) { - return false; - } - - if (DrizzleQueries.#isRelationalQueryCall(call, imports)) { - return args.some((arg) => arg.type === 'ObjectExpression' && DrizzleQueries.#shouldFormatObjectExpression(arg)); - } - - const name = DrizzleQueries.#methodName(call); - - if (!name) { - return false; - } - - if (['where', 'having', '$count'].includes(name)) { - return args.some((arg) => DrizzleQueries.#isComplexArgument(arg, imports)); - } - - if (['leftJoin', 'rightJoin', 'innerJoin', 'fullJoin', 'crossJoin'].includes(name)) { - return args.length > 1 && args.some((arg, index) => index > 0 && DrizzleQueries.#isComplexArgument(arg, imports)); - } - - if (['onConflictDoNothing', 'onConflictDoUpdate', 'returning', 'set', 'values'].includes(name)) { - return args.some((arg) => DrizzleQueries.#isComplexArgument(arg, imports)); - } - - if (['as', 'except', 'groupBy', 'intersect', 'orderBy', 'union', 'unionAll'].includes(name)) { - return args.length > 1 || args.some((arg) => DrizzleQueries.#isStructuralArgument(arg, imports)); - } - - return args.length > 1 && args.some((arg) => DrizzleQueries.#isComplexArgument(arg, imports)); - } - - // Emission: render recognised structures and produce non-overlapping edits. - - static #formatArrayExpression(source: string, node: Node, imports: DrizzleImports, comments: readonly Node[], indent: string, indentUnit: string): string { - if (SourceText.hasCommentBetween(comments, Ast.getStart(node), Ast.getEnd(node))) { - return SourceText.sourceOf(source, node); - } - - const elements = Array.isArray(node.elements) ? node.elements : []; - - if (elements.length === 0) { - return '[]'; - } - - const nextIndent = `${indent}${indentUnit}`; - - const formatted = elements.map((element) => { - return element instanceof Node ? DrizzleQueries.#formatNode(source, element, imports, comments, nextIndent, indentUnit) : ''; - }); - - return `[\n${nextIndent}${formatted.join(`,\n${nextIndent}`)},\n${indent}]`; - } - - static #formatObjectExpression(source: string, node: Node, imports: DrizzleImports, comments: readonly Node[], indent: string, indentUnit: string): string { - if (SourceText.hasCommentBetween(comments, Ast.getStart(node), Ast.getEnd(node))) { - return SourceText.sourceOf(source, node); - } - - const properties = Ast.childNodes(node, 'properties'); - - if (properties.length === 0) { - return '{}'; - } - - const nextIndent = `${indent}${indentUnit}`; - - const formatted = properties.map((property) => { - if (property.type !== 'Property') { - return SourceText.sourceOf(source, property); - } - - const key = Ast.childNode(property, 'key'); - const value = Ast.childNode(property, 'value'); - - if (!key || !value || property.computed || property.method) { - return SourceText.sourceOf(source, property); - } - - if (property.shorthand) { - return SourceText.sourceOf(source, property); - } - - return `${SourceText.sourceOf(source, key)}: ${DrizzleQueries.#formatNode(source, value, imports, comments, nextIndent, indentUnit)}`; - }); - - return `{\n${nextIndent}${formatted.join(`,\n${nextIndent}`)},\n${indent}}`; - } - - static #formatHelperCall(source: string, call: Node, imports: DrizzleImports, comments: readonly Node[], indent: string, indentUnit: string): string { - if (SourceText.hasCommentBetween(comments, Ast.getStart(call), Ast.getEnd(call))) { - return SourceText.sourceOf(source, call); - } - - const importedName = DrizzleQueries.#calleeName(SourceText.unwrapChainExpression(Ast.childNode(call, 'callee')), imports); - - const args = Ast.childNodes(call, 'arguments'); - - if (!importedName || !MULTILINE_HELPERS.has(importedName) || args.length === 0) { - return SourceText.sourceOf(source, call); - } - - const nextIndent = `${indent}${indentUnit}`; - const formatted = args.map((arg) => DrizzleQueries.#formatNode(source, arg, imports, comments, nextIndent, indentUnit)); - - return `${DrizzleQueries.#callDisplayName(source, call, imports)}(\n${nextIndent}${formatted.join(`,\n${nextIndent}`)},\n${indent})`; - } - - static #formatSetOperationCall(source: string, call: Node, imports: DrizzleImports, comments: readonly Node[], indent: string, indentUnit: string): string { - if (SourceText.hasCommentBetween(comments, Ast.getStart(call), Ast.getEnd(call))) { - return SourceText.sourceOf(source, call); - } - - const args = Ast.childNodes(call, 'arguments'); - - if (args.length < 2) { - return SourceText.sourceOf(source, call); - } - - const nextIndent = `${indent}${indentUnit}`; - const formatted = args.map((arg) => DrizzleQueries.#formatNode(source, arg, imports, comments, nextIndent, indentUnit)); - - return `${DrizzleQueries.#callDisplayName(source, call, imports)}(\n${nextIndent}${formatted.join(`,\n${nextIndent}`)},\n${indent})`; - } - - static #formatNode(source: string, node: Node, imports: DrizzleImports, comments: readonly Node[], indent: string, indentUnit: string): string { - if (node.type === 'ObjectExpression' && DrizzleQueries.#shouldFormatObjectExpression(node)) { - return DrizzleQueries.#formatObjectExpression(source, node, imports, comments, indent, indentUnit); - } - - if (node.type === 'ArrayExpression' && DrizzleQueries.#shouldFormatArrayExpression(node)) { - return DrizzleQueries.#formatArrayExpression(source, node, imports, comments, indent, indentUnit); - } - - if (node.type === 'CallExpression') { - if (DrizzleQueries.#isSetOperationCall(node, imports)) { - return DrizzleQueries.#formatSetOperationCall(source, node, imports, comments, indent, indentUnit); - } - - if (DrizzleQueries.#isImportedHelperCall(node, imports)) { - return DrizzleQueries.#formatHelperCall(source, node, imports, comments, indent, indentUnit); - } - } - - return SourceText.sourceOf(source, node); - } - - static #formatCallArguments(source: string, call: Node, imports: DrizzleImports, comments: readonly Node[], indentUnit: string): Edit | null { - const parens = DrizzleQueries.#callParens(source, call); - const args = Ast.childNodes(call, 'arguments'); - - if (!parens || args.length === 0) { - return null; - } - - if (SourceText.hasCommentBetween(comments, parens.open, parens.close)) { - return null; - } - - const callee = SourceText.unwrapChainExpression(Ast.childNode(call, 'callee')); - - const property = callee ? Ast.childNode(callee, 'property') : undefined; - const indentPos = callee?.type === 'MemberExpression' && property ? Ast.getStart(property) : Ast.getStart(call); - const indent = SourceText.lineIndent(source, indentPos); - const argIndent = `${indent}${indentUnit}`; - const formatted = args.map((arg) => DrizzleQueries.#formatNode(source, arg, imports, comments, argIndent, indentUnit)); - const replacement = `(\n${argIndent}${formatted.join(`,\n${argIndent}`)},\n${indent})`; - - if (source.slice(parens.open, parens.close + 1) === replacement) { - return null; - } - - return { - start: parens.open, - end: parens.close + 1, - replacement, - }; - } - - /** - * Compute edits for recognised Drizzle query structures. - * - * @param content - The source text to inspect. - * @param virtualName - The filename used to parse the source. - * @returns Non-overlapping query-formatting edits. - */ - static computeEdits(content: string, virtualName: string): Edit[] { - if (FileTargets.isDeclarationFile(virtualName)) { - return []; - } - - const parsed = Sources.parse(virtualName, content); - - if (isErr(parsed)) { - return []; - } - - const comments = parsed.value.comments; - const imports = DrizzleQueries.#collectDrizzleImports(parsed.value.program); - - if (imports.locals.size === 0 && imports.namespaces.size === 0) { - return []; - } - - const edits: Edit[] = []; - const indentUnit = SourceText.detectIndentUnit(content); - - Ast.visit(parsed.value.program, (node) => { - if (node.type !== 'CallExpression') { - return; - } - - if (DrizzleQueries.#isDrizzleMethodCall(node, imports) || DrizzleQueries.#isRelationalQueryCall(node, imports) || DrizzleQueries.#isSetOperationCall(node, imports)) { - const args = Ast.childNodes(node, 'arguments'); - - if (DrizzleQueries.#isSetOperationCall(node, imports) && args.length > 0 && args.length < 2) { - return; - } - - if (!DrizzleQueries.#isSetOperationCall(node, imports) && !DrizzleQueries.#shouldFormatMethodArguments(node, imports)) { - return; - } - - const edit = DrizzleQueries.#formatCallArguments(content, node, imports, comments, indentUnit); - - if (edit) { - edits.push(edit); - } - } - }); - - return Edits.nonOverlapping(edits); - } - - /** - * Format recognised Drizzle query structures. - * - * @param content - The source text to format. - * @param virtualName - The filename used to parse the source. - * @returns The formatted source, or the original source when no edits apply. - */ - static format(content: string, virtualName: string): string { - const edits = DrizzleQueries.computeEdits(content, virtualName); - - return edits.length > 0 ? Edits.apply(content, edits) : content; - } -} diff --git a/packages/ts/sidecar/src/expanded-calls.ts b/packages/ts/sidecar/src/expanded-calls.ts deleted file mode 100644 index 44f91f5..0000000 --- a/packages/ts/sidecar/src/expanded-calls.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { Ast } from '#sidecar/ast'; -import { Edits } from '#sidecar/edits'; -import { FileTargets } from '#sidecar/file-targets'; -import { Node } from '#sidecar/node-schema'; -import { isErr } from '#sidecar/result'; -import { SourceText } from '#sidecar/source-text'; -import type { CallParens } from '#sidecar/source-text'; -import { Sources } from '#sidecar/sources'; -import { TemplateSpans } from '#sidecar/template-spans'; -import type { Edit } from '#sidecar/types'; - -const FUNCTION_TYPES = new Set(['ArrowFunctionExpression', 'FunctionDeclaration', 'FunctionExpression']); - -/** Expands structurally complex call arguments into stable multiline layouts. */ -export class ExpandedCalls { - static #unwrapExpression(node: Node | undefined): Node | undefined { - let current = node; - - while ( - current && - (current.type === 'ChainExpression' || - current.type === 'ParenthesizedExpression' || - current.type === 'TSAsExpression' || - current.type === 'TSSatisfiesExpression' || - current.type === 'TSNonNullExpression' || - current.type === 'TSTypeAssertion') - ) { - current = Ast.childNode(current, 'expression'); - } - - return current; - } - - static #calleeParens(source: string, call: Node): CallParens | null { - return SourceText.callParens(source, call, ExpandedCalls.#unwrapExpression(Ast.childNode(call, 'callee'))); - } - - static #callArguments(call: Node): Node[] { - return Ast.childNodes(call, 'arguments'); - } - - static #isMethodCall(call: Node): boolean { - const callee = ExpandedCalls.#unwrapExpression(Ast.childNode(call, 'callee')); - - return callee?.type === 'MemberExpression'; - } - - static #isComplexArgument(node: Node): boolean { - const current = ExpandedCalls.#unwrapExpression(node); - - return current?.type === 'CallExpression' || current?.type === 'ObjectExpression' || current?.type === 'ArrayExpression'; - } - - static #shouldExpandCall(call: Node): boolean { - const args = ExpandedCalls.#callArguments(call); - - return !ExpandedCalls.#isMethodCall(call) && args.length > 0 && args.some(ExpandedCalls.#isComplexArgument); - } - - static #collectParents(node: Node, parents: WeakMap): void { - for (const value of Object.values(node)) { - if (Array.isArray(value)) { - for (const child of value) { - if (child instanceof Node) { - parents.set(child, node); - ExpandedCalls.#collectParents(child, parents); - } - } - } else if (value instanceof Node) { - parents.set(value, node); - ExpandedCalls.#collectParents(value, parents); - } - } - } - - static #isInsideCallArgument(node: Node, call: Node): boolean { - const start = Ast.getStart(node); - const end = Ast.getEnd(node); - const args = ExpandedCalls.#callArguments(call); - - return args.some((arg) => { - return Ast.getStart(arg) <= start && end <= Ast.getEnd(arg); - }); - } - - static #nearestCallAncestor(node: Node, parents: WeakMap): Node | null { - let current = parents.get(node); - - while (current) { - if (FUNCTION_TYPES.has(current.type)) { - return null; - } - - if (current.type === 'CallExpression') { - return current; - } - - current = parents.get(current); - } - - return null; - } - - static #isNestedInsideUnexpandedCallArgument(node: Node, parents: WeakMap): boolean { - const ancestor = ExpandedCalls.#nearestCallAncestor(node, parents); - - if (!ancestor || !ExpandedCalls.#isInsideCallArgument(node, ancestor)) { - return false; - } - - return !ExpandedCalls.#shouldExpandCall(ancestor); - } - - static #canUseTrailingComma(arg: Node | undefined): boolean { - return arg?.type !== 'SpreadElement'; - } - - static #rebaseLine(line: string, lineStart: number, from: string, to: string, spans: TemplateSpans): string { - // A template literal's leading whitespace is string content, not - // indentation: moving it would rewrite the value, and since oxfmt hugs the - // expanded call back onto one line before the next run re-expands it, every - // run would shift the literal one level further right. - if (spans.contains(lineStart)) { - return line; - } - - if (line.trim() === '') { - return ''; - } - - return line.startsWith(from) ? `${to}${line.slice(from.length)}` : line; - } - - /** - * Re-indent lifted source so its continuation lines match where it now sits. - * - * A node's text is copied out of the call site verbatim, so its second and - * later lines are still indented relative to the line the node was written on. - * Expanding the call moves the node one or more levels deeper (`to`), and - * without rebasing those lines they keep the shallower depth and the block - * reads inside-out. Reading the origin off the node's own line, rather than off - * the call being expanded, is what makes a second run a no-op: text already - * sitting at its target depth is left alone. Only the first line is skipped - * outright — the caller places it. - */ - static #rebaseIndent(source: string, node: Node, to: string, spans: TemplateSpans): string { - const start = Ast.getStart(node); - const text = SourceText.sourceOf(source, node); - const from = SourceText.lineIndent(source, start); - - if (from === to || !text.includes('\n')) { - return text; - } - - const rebased: string[] = []; - - let lineStart = start; - - for (const [index, line] of text.split('\n').entries()) { - rebased.push(index === 0 ? line : ExpandedCalls.#rebaseLine(line, lineStart, from, to, spans)); - lineStart += line.length + 1; - } - - return rebased.join('\n'); - } - - static #formatCallParens(source: string, call: Node, comments: readonly Node[], indent: string, indentUnit: string, spans: TemplateSpans): string | null { - const parens = ExpandedCalls.#calleeParens(source, call); - const args = ExpandedCalls.#callArguments(call); - - if (!parens || args.length === 0 || SourceText.hasCommentBetween(comments, parens.open, parens.close)) { - return null; - } - - if (!ExpandedCalls.#shouldExpandCall(call)) { - return null; - } - - const argIndent = `${indent}${indentUnit}`; - - const formattedArgs = args.map((arg) => { - return ExpandedCalls.#formatNode(source, arg, comments, argIndent, indentUnit, spans); - }); - - const separator = `,\n${argIndent}`; - const trailingComma = ExpandedCalls.#canUseTrailingComma(args.at(-1)) ? ',' : ''; - - return `(\n${argIndent}${formattedArgs.join(separator)}${trailingComma}\n${indent})`; - } - - static #formatCall(source: string, call: Node, comments: readonly Node[], indent: string, indentUnit: string, spans: TemplateSpans): string { - const parens = ExpandedCalls.#calleeParens(source, call); - const formattedParens = ExpandedCalls.#formatCallParens(source, call, comments, indent, indentUnit, spans); - - if (!parens || formattedParens === null) { - return ExpandedCalls.#rebaseIndent(source, call, indent, spans); - } - - return `${source.slice(Ast.getStart(call), parens.open)}${formattedParens}`; - } - - // indent is where node will sit once expanded; the depth its text came from is - // read back off the node's own line, because nothing has moved in the source - // yet however deep the recursion goes. - static #formatNode(source: string, node: Node, comments: readonly Node[], indent: string, indentUnit: string, spans: TemplateSpans): string { - if (node.type !== 'CallExpression') { - return ExpandedCalls.#rebaseIndent(source, node, indent, spans); - } - - if (!ExpandedCalls.#shouldExpandCall(node)) { - return ExpandedCalls.#rebaseIndent(source, node, indent, spans); - } - - return ExpandedCalls.#formatCall(source, node, comments, indent, indentUnit, spans); - } - /** - * Compute edits for calls whose arguments require a multiline layout. - * - * @param content - The source text to inspect. - * @param virtualName - The filename used to parse the source. - * @returns Non-overlapping expanded-call edits. - */ - static computeEdits(content: string, virtualName: string): Edit[] { - if (FileTargets.isDeclarationFile(virtualName)) { - return []; - } - - const parsed = Sources.parse(virtualName, content); - - if (isErr(parsed)) { - return []; - } - - const comments = parsed.value.comments; - const parents = new WeakMap(); - const edits: Edit[] = []; - const indentUnit = SourceText.detectIndentUnit(content); - const spans = TemplateSpans.collect(parsed.value.program); - - ExpandedCalls.#collectParents(parsed.value.program, parents); - - Ast.visit(parsed.value.program, (node) => { - if (node.type !== 'CallExpression') { - return; - } - - if (!ExpandedCalls.#shouldExpandCall(node)) { - return; - } - - if (ExpandedCalls.#isNestedInsideUnexpandedCallArgument(node, parents)) { - return; - } - - const parens = ExpandedCalls.#calleeParens(content, node); - - if (!parens || SourceText.hasCommentBetween(comments, parens.open, parens.close)) { - return; - } - - const indent = SourceText.lineIndent(content, Ast.getStart(node)); - - const replacement = ExpandedCalls.#formatCallParens(content, node, comments, indent, indentUnit, spans); - const current = content.slice(parens.open, parens.close + 1); - - if (replacement === null || replacement === current) { - return; - } - - edits.push({ - start: parens.open, - end: parens.close + 1, - replacement, - }); - }); - - return Edits.nonOverlapping(edits); - } - - /** - * Format calls whose arguments require a multiline layout. - * - * @param content - The source text to format. - * @param virtualName - The filename used to parse the source. - * @returns The formatted source, or the original source when no edits apply. - */ - static format(content: string, virtualName: string): string { - const edits = ExpandedCalls.computeEdits(content, virtualName); - - return edits.length > 0 ? Edits.apply(content, edits) : content; - } -} diff --git a/packages/ts/sidecar/src/file-targets.test.ts b/packages/ts/sidecar/src/file-targets.test.ts deleted file mode 100644 index 2a93dcf..0000000 --- a/packages/ts/sidecar/src/file-targets.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { FileTargets } from '#sidecar/file-targets'; - -test('isTargetFile accepts ts and host documents but not declarations', () => { - assert.equal(FileTargets.isTargetFile('app.ts'), true); - - assert.equal(FileTargets.isTargetFile('widget.vue'), true); - - assert.equal(FileTargets.isTargetFile('page.html'), true); - - assert.equal(FileTargets.isTargetFile('page.htm'), true); - - assert.equal(FileTargets.isTargetFile('notes.md'), true); - - assert.equal(FileTargets.isTargetFile('notes.markdown'), true); - - assert.equal(FileTargets.isTargetFile('types.d.ts'), false); - - assert.equal(FileTargets.isTargetFile('data.json'), false); -}); - -test('isSyntaxTarget accepts every ts file plus host documents', () => { - assert.equal(FileTargets.isSyntaxTarget('app.ts'), true); - - assert.equal(FileTargets.isSyntaxTarget('types.d.ts'), true); - - assert.equal(FileTargets.isSyntaxTarget('widget.vue'), true); - - assert.equal(FileTargets.isSyntaxTarget('page.html'), true); - - assert.equal(FileTargets.isSyntaxTarget('notes.md'), true); - - assert.equal(FileTargets.isSyntaxTarget('data.json'), false); -}); - -test('isDeclarationFile only matches .d.ts', () => { - assert.equal(FileTargets.isDeclarationFile('types.d.ts'), true); - - assert.equal(FileTargets.isDeclarationFile('app.ts'), false); -}); diff --git a/packages/ts/sidecar/src/file-targets.ts b/packages/ts/sidecar/src/file-targets.ts deleted file mode 100644 index 6931744..0000000 --- a/packages/ts/sidecar/src/file-targets.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { EmbeddedBlocks } from '#sidecar/embedded-blocks'; - -/** Classifies paths accepted by sidecar formatting passes. */ -export class FileTargets { - /** - * Report whether a virtual filename denotes a TypeScript declaration file. - * - * @param virtualName - The filename to classify. - * @returns `true` when the filename ends in `.d.ts`. - */ - static isDeclarationFile(virtualName: string): boolean { - return virtualName.endsWith('.d.ts'); - } - - /** - * Report whether a path denotes a supported non-declaration source file. - * - * @param path - The source path to classify. - * @returns `true` for host documents and non-declaration TypeScript files. - */ - static isTargetFile(path: string): boolean { - return (path.endsWith('.ts') && !path.endsWith('.d.ts')) || EmbeddedBlocks.isHost(path); - } - - /** - * Report whether a path is eligible for final syntax validation. - * - * @param path - The source path to classify. - * @returns `true` for host documents and every TypeScript file. - */ - static isSyntaxTarget(path: string): boolean { - return path.endsWith('.ts') || EmbeddedBlocks.isHost(path); - } -} diff --git a/packages/ts/sidecar/src/files.test.ts b/packages/ts/sidecar/src/files.test.ts deleted file mode 100644 index 670a473..0000000 --- a/packages/ts/sidecar/src/files.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import assert from 'node:assert/strict'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { test } from 'node:test'; -import { Files } from '#sidecar/files'; -import { FormatPipeline } from '#sidecar/format-pipeline'; -import { NodeProcessRunner } from '#sidecar/process-runner'; -import { isErr } from '#sidecar/result'; -import { NodeSourceFiles } from '#sidecar/source-files'; - -const pipeline = new FormatPipeline({ sourceFiles: new NodeSourceFiles(), processRunner: new NodeProcessRunner() }); - -async function processFile(file: string, mode: 'check' | 'write'): Promise { - const outcome = await pipeline.formatFile(file, mode); - - assert.equal(isErr(outcome), false); - - return isErr(outcome) ? false : outcome.value; -} - -async function withTempDir(fn: (dir: string) => Promise): Promise { - const dir = await mkdtemp( - join( - tmpdir(), - 'fmtkit-sidecar-files-', - ), - ); - - try { - await fn(dir); - } finally { - await rm( - dir, - { recursive: true, force: true }, - ); - } -} - -test('dirExists reports existing directories and missing paths', async () => { - await withTempDir(async (dir) => { - assert.equal(await Files.dirExists(dir), true); - - assert.equal(await Files.dirExists(join(dir, 'missing')), false); - }); -}); - -test('listSourceFiles returns TypeScript and Vue files only', async () => { - await withTempDir(async (dir) => { - await writeFile( - join(dir, 'component.vue'), - '\n', - ); - - await writeFile( - join(dir, 'source.ts'), - 'const value = 1;\n', - ); - - await writeFile( - join(dir, 'notes.md'), - '# Notes\n', - ); - - const files = (await Files.listSourceFiles(dir)).map((file) => { - return file.slice(dir.length + 1); - }); - - assert.deepEqual(files.sort(), ['component.vue', 'source.ts']); - }); -}); - -test('processFile reports check changes without writing TypeScript files', async () => { - await withTempDir(async (dir) => { - const file = join(dir, 'source.ts'); - const original = ['function run() {', '\tconst value = 1;', '\tif (value) return value;', '}', ''].join('\n'); - - await writeFile(file, original); - - assert.equal(await processFile(file, 'check'), true); - - assert.equal(await readFile(file, 'utf8'), original); - }); -}); - -test('processFile leaves non-JS/TS Vue script blocks untouched', async () => { - await withTempDir(async (dir) => { - const file = join(dir, 'component.vue'); - const yamlBlock = [''].join('\n'); - const tsBlock = [''].join('\n'); - - await writeFile(file, `${yamlBlock}\n${tsBlock}\n`); - - assert.equal(await processFile(file, 'write'), true); - - const updated = await readFile(file, 'utf8'); - - assert.ok(updated.startsWith(yamlBlock), 'yaml script block must not be reformatted'); - - assert.match(updated, /if \(value\) \{\n\tconsole\.log\(value\);\n\}/); - }); -}); - -test('processFile rewrites Vue script blocks and reports unchanged files', async () => { - await withTempDir(async (dir) => { - const file = join(dir, 'component.vue'); - - await writeFile( - file, - ['', ''].join('\n'), - ); - - assert.equal(await processFile(file, 'write'), true); - - const updated = await readFile(file, 'utf8'); - - assert.match(updated, /if \(value\) \{\n\tconsole\.log\(value\);\n\}/); - - assert.equal(await processFile(file, 'check'), false); - }); -}); diff --git a/packages/ts/sidecar/src/files.ts b/packages/ts/sidecar/src/files.ts deleted file mode 100644 index c678efe..0000000 --- a/packages/ts/sidecar/src/files.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { readdir, stat } from 'node:fs/promises'; -import { resolve } from 'node:path'; - -/** Reads source-file inventories from the local filesystem. */ -export class Files { - /** - * Report whether a path exists as a directory. - * - * @param directory - The path to inspect. - * @returns `true` when the path identifies an existing directory. - */ - static async dirExists(directory: string): Promise { - try { - return (await stat(directory)).isDirectory(); - } catch { - return false; - } - } - - /** - * List TypeScript and Vue files below a directory recursively. - * - * @param directory - The directory tree to scan. - * @returns Absolute paths to the discovered TypeScript and Vue files. - */ - static async listSourceFiles(directory: string): Promise { - const entries = await readdir( - directory, - { recursive: true, withFileTypes: true }, - ); - - const files: string[] = []; - - for (const entry of entries) { - if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.vue'))) { - files.push(resolve(entry.parentPath, entry.name)); - } - } - - return files; - } -} diff --git a/packages/ts/sidecar/src/fluent-chains.ts b/packages/ts/sidecar/src/fluent-chains.ts deleted file mode 100644 index c58a3fe..0000000 --- a/packages/ts/sidecar/src/fluent-chains.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { pathToFileURL } from 'node:url'; -import { Ast } from '#sidecar/ast'; -import { DrizzleQueries } from '#sidecar/drizzle-queries'; -import { Edits } from '#sidecar/edits'; -import { EmbeddedBlocks } from '#sidecar/embedded-blocks'; -import { ExpandedCalls } from '#sidecar/expanded-calls'; -import { PassCliDto } from '#sidecar/pass-cli-dto'; -import { isErr, ok } from '#sidecar/result'; -import type { Result } from '#sidecar/result'; -import type { SourceFileError, SourceFiles } from '#sidecar/source-files'; -import { SourceText } from '#sidecar/source-text'; -import { Sources } from '#sidecar/sources'; -import type { Edit, Node } from '#sidecar/types'; - -const cwd = process.cwd(); - -type ChainLink = { - start: number; - end: number; - operator: '.' | '?.'; -}; - -type FluentChain = { - base: Node; - links: ChainLink[]; -}; - -/** Formats fluent chains and the structured calls composed with them. */ -export class FluentChains { - static #memberCallLink(source: string, member: Node, object: Node, comments: readonly Node[]): ChainLink | null { - if (member.computed) { - return null; - } - - const property = Ast.childNode(member, 'property'); - - if (!property || (property.type !== 'Identifier' && property.type !== 'PrivateIdentifier')) { - return null; - } - - const objectEnd = Ast.getEnd(object); - const propertyStart = Ast.getStart(property); - - if (objectEnd < 0 || propertyStart < 0 || propertyStart <= objectEnd) { - return null; - } - - if (SourceText.hasCommentBetween(comments, objectEnd, propertyStart)) { - return null; - } - - const separator = source.slice(objectEnd, propertyStart); - - if (separator.includes('//') || separator.includes('/*')) { - return null; - } - - const operator = separator.replace(/[ \t\r\n]/g, ''); - - if (operator !== '.' && operator !== '?.') { - return null; - } - - return { - start: objectEnd, - end: propertyStart, - operator, - }; - } - - static #collectFluentChain(source: string, outer: Node, comments: readonly Node[]): FluentChain | null { - let call: Node = outer; - - const links: ChainLink[] = []; - - while (call.type === 'CallExpression') { - const callee = SourceText.unwrapChainExpression(Ast.childNode(call, 'callee')); - - if (callee?.type !== 'MemberExpression') { - break; - } - - const object = SourceText.unwrapChainExpression(Ast.childNode(callee, 'object')); - - if (object?.type !== 'CallExpression') { - break; - } - - const link = FluentChains.#memberCallLink(source, callee, object, comments); - - if (!link) { - return null; - } - - links.push(link); - call = object; - } - - if (links.length < 2) { - return null; - } - - return { - base: call, - links, - }; - } - /** - * Compute edits that split fluent-chain links across lines. - * - * @param content - The source text to inspect. - * @param virtualName - The filename used to parse the source. - * @returns Fluent-chain edits, or none for invalid source. - */ - static computeEdits(content: string, virtualName: string): Edit[] { - const parsed = Sources.parse(virtualName, content); - - if (isErr(parsed)) { - return []; - } - - const comments = parsed.value.comments; - const edits = new Map(); - const indentStep = SourceText.detectIndentUnit(content); - - Ast.visit(parsed.value.program, (node) => { - if (node.type !== 'CallExpression') { - return; - } - - const chain = FluentChains.#collectFluentChain(content, node, comments); - - if (!chain) { - return; - } - - const baseStart = Ast.getStart(chain.base); - - if (baseStart < 0) { - return; - } - - const indent = `${SourceText.lineIndent(content, baseStart)}${indentStep}`; - - for (const link of chain.links) { - const replacement = `\n${indent}${link.operator}`; - - if (content.slice(link.start, link.end) === replacement) { - continue; - } - - edits.set(`${link.start}:${link.end}`, { - start: link.start, - end: link.end, - replacement, - }); - } - }); - - return [...edits.values()].sort((a, b) => { - return a.start - b.start; - }); - } - - /** - * Apply fluent-chain, Drizzle-query, and expanded-call formatting. - * - * @param content - The source text to format. - * @param virtualName - The filename used to parse the source. - * @returns The formatted source text. - */ - static format(content: string, virtualName: string): string { - const edits = FluentChains.computeEdits(content, virtualName); - - const fluentFormatted = edits.length > 0 ? Edits.apply(content, edits) : content; - const drizzleFormatted = DrizzleQueries.format(fluentFormatted, virtualName); - - return ExpandedCalls.format(drizzleFormatted, virtualName); - } - - /** - * Format one TypeScript or host file through an injected filesystem port. - * - * @param file - The source file to format. - * @param mode - Whether to report changes or atomically write them. - * @param sourceFiles - The filesystem port used for reads and writes. - * @returns Whether the file changes, or the typed filesystem failure. - */ - static async formatFile(file: string, mode: 'check' | 'write', sourceFiles: SourceFiles): Promise> { - const read = await sourceFiles.readText(file); - - if (isErr(read)) { - return read; - } - - const original = read.value; - - const updated = EmbeddedBlocks.isHost(file) - ? EmbeddedBlocks.rewrite(file, original, (blockContent, virtualName) => { - return FluentChains.format(blockContent, virtualName); - }) - : FluentChains.format(original, file); - - if (updated === original) { - return ok(false); - } - - if (mode === 'write') { - const written = await sourceFiles.writeTextAtomic(file, updated); - - if (isErr(written)) { - return written; - } - } - - return ok(true); - } - - /** - * Run the standalone fluent-chain formatter entrypoint. - * - * @returns Nothing after reporting outcomes and setting the process status. - */ - static async main(): Promise { - const options = PassCliDto.parse(process.argv.slice(2)); - const files = [...options.files]; - const { mode } = options; - - const { NodeProcessRunner } = await import('#sidecar/process-runner'); - - const { NodeSourceFiles } = await import('#sidecar/source-files'); - - const { FormatPipeline } = await import('#sidecar/format-pipeline'); - - const pipeline = new FormatPipeline({ sourceFiles: new NodeSourceFiles(), processRunner: new NodeProcessRunner() }); - - const outcomes = await pipeline.runPass('fluent-chains', files, mode, (file, passMode) => { - return pipeline.formatFluentFile(file, passMode); - }); - - const changedCount = outcomes.filter((outcome) => { - if (outcome.error?._tag === 'SourceFileUnreadable' && outcome.error.isNotFound()) { - console.warn(`[fluent-chains] path not found, skipping: ${outcome.file}`); - } else if (outcome.error) { - throw outcome.error; - } else if (outcome.changed) { - console.log(`[fluent-chains] ${mode === 'check' ? 'would change' : 'updated'} ${outcome.file}`); - } - - return outcome.changed; - }).length; - - if (mode === 'check' && changedCount > 0) { - console.error(`[fluent-chains] ${changedCount} file(s) need fluent-chain edits. Run "pnpm format" to fix.`); - process.exit(1); - } - - console.log(`[fluent-chains] processed ${files.length} file(s) in ${cwd}, ${changedCount} ${mode === 'check' ? 'would change' : 'changed'}`); - } -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - FluentChains.main().catch((err: unknown) => { - console.error(err); - process.exit(1); - }); -} diff --git a/packages/ts/sidecar/src/format-all.ts b/packages/ts/sidecar/src/format-all.ts deleted file mode 100644 index d72cd04..0000000 --- a/packages/ts/sidecar/src/format-all.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { pathToFileURL } from 'node:url'; -import { z } from 'zod'; -import { UnexpectedCliArgument } from '#sidecar/errors'; -import type { OxcErrorDto } from '#sidecar/errors'; -import { FileTargets } from '#sidecar/file-targets'; -import { FormatPipeline } from '#sidecar/format-pipeline'; -import type { FormatMode, PassOutcome, ValidationFailure } from '#sidecar/format-pipeline'; -import { NodeProcessRunner } from '#sidecar/process-runner'; -import { err, isErr, ok } from '#sidecar/result'; -import type { Result } from '#sidecar/result'; -import { NodeSourceFiles } from '#sidecar/source-files'; - -/** Immutable command-line options for the full formatting pipeline. */ -export class CliOptionsDto { - /** Whether the pipeline checks source or writes changes. */ - readonly mode: FormatMode; - - /** The oxfmt executable, or `null` to skip external formatting. */ - readonly oxfmtBin: string | null; - - /** The oxfmt configuration path, or `null` to use its defaults. */ - readonly oxfmtConfig: string | null; - - /** Files eligible for formatting passes. */ - readonly formatFiles: readonly string[]; - - /** Files eligible for final syntax validation. */ - readonly syntaxFiles: readonly string[]; - - static readonly #argvSchema = z.array(z.string()); - - static readonly #schema = z.object({ - mode: z.enum(['check', 'write']), - oxfmtBin: z.string().nullable(), - oxfmtConfig: z.string().nullable(), - formatFiles: z.array(z.string()), - syntaxFiles: z.array(z.string()), - }); - - private constructor(value: { mode: FormatMode; oxfmtBin: string | null; oxfmtConfig: string | null; formatFiles: string[]; syntaxFiles: string[] }) { - this.mode = value.mode; - this.oxfmtBin = value.oxfmtBin; - this.oxfmtConfig = value.oxfmtConfig; - this.formatFiles = Object.freeze(value.formatFiles); - this.syntaxFiles = Object.freeze(value.syntaxFiles); - - Object.setPrototypeOf(this, Object.prototype); - Object.freeze(this); - } - - /** - * Parse the full-pipeline command line. - * - * @param input - Arguments after the executable and script path. - * @returns Parsed options, or the unexpected argument as a typed value. - */ - static parse(input: unknown): Result { - const argv = CliOptionsDto.#argvSchema.parse(input); - - const candidate = { - mode: 'write' as FormatMode, - oxfmtBin: null as string | null, - oxfmtConfig: null as string | null, - formatFiles: [] as string[], - syntaxFiles: [] as string[], - }; - - let section: 'formatFiles' | 'syntaxFiles' | null = null; - - for (let index = 0; index < argv.length; index++) { - const argument = argv[index]; - - if (argument === undefined) { - continue; - } - - if (argument === '--check') { - candidate.mode = 'check'; - section = null; - } else if (argument === '--oxfmt-bin') { - candidate.oxfmtBin = argv[++index] ?? null; - section = null; - } else if (argument === '--oxfmt-config') { - candidate.oxfmtConfig = argv[++index] ?? null; - section = null; - } else if (argument === '--format-files') { - section = 'formatFiles'; - } else if (argument === '--syntax-files') { - section = 'syntaxFiles'; - } else if (section) { - candidate[section].push(argument); - } else { - return err(new UnexpectedCliArgument(argument)); - } - } - - return ok(new CliOptionsDto(CliOptionsDto.#schema.parse(candidate))); - } -} - -/** Reports pipeline values without coupling formatting passes to the console. */ -class FormatAllReporter { - /** - * Report one formatting pass and decide whether execution may continue. - * - * @param label - The formatting pass label. - * @param files - The source paths requested for the pass. - * @param mode - Whether the pass checked or wrote source. - * @param outcomes - The ordered outcomes produced by the pass. - * @param failureNoun - The change description used in check-mode guidance. - * @returns `true` when no outcome or pending change makes the pass fail. - */ - static reportPass(label: string, files: readonly string[], mode: FormatMode, outcomes: PassOutcome[], failureNoun: string): boolean { - let changedCount = 0; - - for (const outcome of outcomes) { - if (outcome.error?._tag === 'SourceFileUnreadable' && outcome.error.isNotFound()) { - console.warn(`[${label}] path not found, skipping: ${outcome.file}`); - continue; - } - - if (outcome.error) { - console.error(outcome.error); - - return false; - } - - if (outcome.changed) { - changedCount++; - console.log(`[${label}] ${mode === 'check' ? 'would change' : 'updated'} ${outcome.file}`); - } - } - - if (mode === 'check' && changedCount > 0) { - console.error(`[${label}] ${changedCount} file(s) need ${failureNoun}. Run "pnpm format" to fix.`); - - return false; - } - - console.log(`[${label}] processed ${files.length} file(s) in ${process.cwd()}, ${changedCount} ${mode === 'check' ? 'would change' : 'changed'}`); - - return true; - } - - /** - * Format one parser diagnostic for console output. - * - * @param file - The source path associated with the diagnostic. - * @param error - The parser diagnostic to render. - * @returns A source-framed message, plain message, or stable fallback. - */ - static formatError(file: string, error: OxcErrorDto): string { - if (error.codeframe && error.codeframe.length > 0) { - return `[validate-syntax] ${file}\n${error.codeframe.trimEnd()}`; - } - - if (error.message && error.message.length > 0) { - return `[validate-syntax] ${file}: ${error.message}`; - } - - return `[validate-syntax] ${file}: syntax validation failed`; - } - - /** - * Report syntax-validation failures and decide whether execution succeeded. - * - * @param files - The source paths requested for validation. - * @param failures - The ordered read and parse failures. - * @returns `true` when no reportable validation failure remains. - */ - static reportValidation(files: readonly string[], failures: ValidationFailure[]): boolean { - const diagnostics: string[] = []; - - for (const failure of failures) { - if (failure.error._tag === 'SourceFileUnreadable') { - if (failure.error.isNotFound()) { - console.warn(`[validate-syntax] path not found, skipping: ${failure.file}`); - continue; - } - - console.error(failure.error); - - return false; - } - - for (const error of failure.error.errors) { - diagnostics.push(FormatAllReporter.formatError(failure.file, error)); - } - } - - if (diagnostics.length > 0) { - console.error(diagnostics.join('\n')); - console.error(`[validate-syntax] ${diagnostics.length} syntax error(s) found after formatting.`); - - return false; - } - - console.log(`[validate-syntax] checked ${files.length} file(s) in ${process.cwd()}`); - - return true; - } -} - -/** - * Run the full formatting CLI and map outcome values to console output and status. - * - * @returns Nothing after reporting outcomes and setting the process status. - */ -export async function main(): Promise { - const parsed = CliOptionsDto.parse(process.argv.slice(2)); - - if (isErr(parsed)) { - console.error(parsed.error); - process.exitCode = 1; - - return; - } - - const options = parsed.value; - const formatTargets = [...new Set(options.formatFiles.filter(FileTargets.isTargetFile))]; - const syntaxTargets = [...new Set(options.syntaxFiles.filter(FileTargets.isSyntaxTarget))]; - const pipeline = new FormatPipeline({ sourceFiles: new NodeSourceFiles(), processRunner: new NodeProcessRunner() }); - - const blankLines = await pipeline.runPass('blank-lines', formatTargets, options.mode, (file, mode) => pipeline.formatFile(file, mode)); - - if (!FormatAllReporter.reportPass('blank-lines', formatTargets, options.mode, blankLines, 'edits')) { - process.exitCode = 1; - - return; - } - - const oxfmt = await pipeline.runOxfmt({ bin: options.oxfmtBin, config: options.oxfmtConfig, files: formatTargets, mode: options.mode }); - - if (isErr(oxfmt)) { - console.error(oxfmt.error); - process.exitCode = 1; - - return; - } - - const fluentChains = await pipeline.runPass('fluent-chains', formatTargets, options.mode, (file, mode) => pipeline.formatFluentFile(file, mode)); - - if (!FormatAllReporter.reportPass('fluent-chains', formatTargets, options.mode, fluentChains, 'edits')) { - process.exitCode = 1; - - return; - } - - // Fluent and expanded calls create blank-line obligations the first pass - // cannot see, so the second pass makes one invocation reach a fixed point. - const finalBlankLines = await pipeline.runPass('blank-lines', formatTargets, options.mode, (file, mode) => pipeline.formatFile(file, mode)); - - if (!FormatAllReporter.reportPass('blank-lines', formatTargets, options.mode, finalBlankLines, 'edits')) { - process.exitCode = 1; - - return; - } - - if (!FormatAllReporter.reportValidation(syntaxTargets, await pipeline.validate(syntaxTargets))) { - process.exitCode = 1; - } -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - main().catch((error: unknown) => { - console.error(error); - process.exitCode = 1; - }); -} diff --git a/packages/ts/sidecar/src/format-pipeline.ts b/packages/ts/sidecar/src/format-pipeline.ts deleted file mode 100644 index 36a4d79..0000000 --- a/packages/ts/sidecar/src/format-pipeline.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { availableParallelism } from 'node:os'; -import { EmbeddedBlocks } from '#sidecar/embedded-blocks'; -import type { OxfmtRunFailed, SourceFileUnreadable, SourceUnparsable } from '#sidecar/errors'; -import { FluentChains } from '#sidecar/fluent-chains'; -import type { ProcessRunner } from '#sidecar/process-runner'; -import { isErr, ok } from '#sidecar/result'; -import type { Result } from '#sidecar/result'; -import { Segment } from '#sidecar/segment'; -import type { SourceFileError, SourceFiles } from '#sidecar/source-files'; -import { Sources } from '#sidecar/sources'; - -const OXFMT_CHUNK_SIZE = 100; - -/** Whether a pipeline pass checks source or writes its computed changes. */ -export type FormatMode = 'check' | 'write'; - -/** The result of processing one file in a formatting pass. */ -export type PassOutcome = { - /** The formatting pass that produced the outcome. */ - readonly label: string; - - /** The requested source path. */ - readonly file: string; - - /** Whether the pass would change or did change the source. */ - readonly changed: boolean; - - /** The typed filesystem failure, or `null` when processing completed. */ - readonly error: SourceFileError | null; -}; - -/** The options needed to invoke oxfmt over one pipeline stage. */ -export type OxfmtOptions = { - /** The executable to invoke, or `null` to skip the stage. */ - readonly bin: string | null; - - /** The oxfmt configuration path, or `null` to use defaults. */ - readonly config: string | null; - - /** The source paths passed to oxfmt. */ - readonly files: string[]; - - /** Whether oxfmt checks source or writes changes. */ - readonly mode: FormatMode; -}; - -/** A source file that could not be read or parsed during validation. */ -export type ValidationFailure = { - /** The original source path reported to the user. */ - readonly file: string; - - /** The carried read or parse failure. */ - readonly error: SourceFileUnreadable | SourceUnparsable; -}; - -type ProcessOne = (file: string, mode: FormatMode) => Promise>; - -/** Coordinates formatting and validation through narrow filesystem and process ports. */ -export class FormatPipeline { - readonly #sourceFiles: SourceFiles; - readonly #processRunner: ProcessRunner; - - static async #mapPool(items: T[], limit: number, operation: (item: T) => Promise): Promise { - const results = new Array(items.length); - - let nextIndex = 0; - - const worker = async (): Promise => { - while (true) { - const index = nextIndex++; - - if (index >= items.length) { - return; - } - - const item = items[index]; - - if (item !== undefined) { - results[index] = await operation(item); - } - } - }; - - const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker); - - await Promise.all(workers); - - return results; - } - - static #scriptPrefix(content: string, scriptStart: number): string { - return content.slice(0, scriptStart).replace(/[^\r\n]/g, ' '); - } - - /** - * @param dependencies - The filesystem and process ports used by the pipeline. - * @param dependencies.sourceFiles - Reads and atomically writes source files. - * @param dependencies.processRunner - Invokes oxfmt with inherited standard streams. - */ - constructor(dependencies: { sourceFiles: SourceFiles; processRunner: ProcessRunner }) { - this.#sourceFiles = dependencies.sourceFiles; - this.#processRunner = dependencies.processRunner; - } - - /** - * Apply the blank-line formatting pass to one TypeScript or host file. - * - * @param path - The source file to format. - * @param mode - Whether to check or atomically write changes. - * @returns Whether the file changes, or the typed filesystem failure. - */ - async formatFile(path: string, mode: FormatMode): Promise> { - const read = await this.#sourceFiles.readText(path); - - if (isErr(read)) { - return read; - } - - const original = read.value; - - const updated = EmbeddedBlocks.isHost(path) - ? EmbeddedBlocks.rewrite(path, original, (blockContent, virtualName) => { - return Segment.process(blockContent, virtualName); - }) - : Segment.process(original, path); - - return this.#writeChanged(path, original, updated, mode); - } - - /** - * Apply fluent-chain formatting to one TypeScript or host file. - * - * @param path - The source file to format. - * @param mode - Whether to check or atomically write changes. - * @returns Whether the file changes, or the typed filesystem failure. - */ - formatFluentFile(path: string, mode: FormatMode): Promise> { - return FluentChains.formatFile(path, mode, this.#sourceFiles); - } - - /** - * Process files concurrently while preserving outcome order. - * - * @param label - The pass label associated with the outcomes. - * @param files - The source paths to process. - * @param mode - Whether the pass checks or writes changes. - * @param processOne - The operation applied to each source path. - * @returns One effect-free reporting outcome per input path. - */ - async runPass(label: string, files: string[], mode: FormatMode, processOne: ProcessOne): Promise { - return FormatPipeline.#mapPool(files, availableParallelism(), async (file): Promise => { - const outcome = await processOne(file, mode); - - if (isErr(outcome)) { - return { label, file, changed: false, error: outcome.error }; - } - - return { label, file, changed: outcome.value, error: null }; - }); - } - - /** - * Run oxfmt sequentially over bounded file chunks. - * - * @param options - The executable, configuration, files, and format mode. - * @returns Nothing, or the first typed oxfmt failure. - */ - async runOxfmt(options: OxfmtOptions): Promise> { - if (!options.bin || options.files.length === 0) { - return ok(undefined); - } - - const args = options.config ? ['--config', options.config] : []; - - args.push(options.mode === 'check' ? '--check' : '--write', '--no-error-on-unmatched-pattern'); - - for (let i = 0; i < options.files.length; i += OXFMT_CHUNK_SIZE) { - const outcome = await this.#processRunner.run(options.bin, [...args, ...options.files.slice(i, i + OXFMT_CHUNK_SIZE)]); - - if (isErr(outcome)) { - return outcome; - } - } - - return ok(undefined); - } - - /** - * Validate TypeScript files and JavaScript-compatible embedded host blocks. - * - * @param files - The source paths to validate. - * @returns Carried read and parse failures in deterministic input order. - */ - async validate(files: string[]): Promise { - const failures = await FormatPipeline.#mapPool(files, availableParallelism(), async (file): Promise => { - const read = await this.#sourceFiles.readText(file); - - if (isErr(read)) { - return [{ file, error: read.error }]; - } - - if (!EmbeddedBlocks.isHost(file)) { - const parsed = Sources.parse(file, read.value); - - return isErr(parsed) ? [{ file, error: parsed.error }] : []; - } - - const hostFailures: ValidationFailure[] = []; - - for (const block of EmbeddedBlocks.extract(file, read.value)) { - const virtualContent = FormatPipeline.#scriptPrefix(read.value, block.start) + block.content; - const parsed = Sources.parse(`${file}.script.${block.extension}`, virtualContent); - - if (isErr(parsed) && EmbeddedBlocks.hardValidated(file)) { - hostFailures.push({ file, error: parsed.error }); - } - } - - return hostFailures; - }); - - return failures.flat(); - } - - async #writeChanged(path: string, original: string, updated: string, mode: FormatMode): Promise> { - if (updated === original) { - return ok(false); - } - - if (mode === 'write') { - const written = await this.#sourceFiles.writeTextAtomic(path, updated); - - if (isErr(written)) { - return written; - } - } - - return ok(true); - } -} diff --git a/packages/ts/sidecar/src/embedded-blocks.test.ts b/packages/ts/sidecar/src/hosts/embedded-block-splitter.test.ts similarity index 51% rename from packages/ts/sidecar/src/embedded-blocks.test.ts rename to packages/ts/sidecar/src/hosts/embedded-block-splitter.test.ts index 6814422..d3c5f81 100644 --- a/packages/ts/sidecar/src/embedded-blocks.test.ts +++ b/packages/ts/sidecar/src/hosts/embedded-block-splitter.test.ts @@ -1,32 +1,36 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { EmbeddedBlocks } from '#sidecar/embedded-blocks'; +import { EmbeddedBlockSplitter } from '#sidecar/hosts/embedded-block-splitter'; +import { MarkdownFences } from '#sidecar/hosts/markdown-fences'; +import { VueScript } from '#sidecar/hosts/vue-script'; -test('EmbeddedBlocks.isHost accepts every host extension and rejects others', () => { +const splitter = new EmbeddedBlockSplitter({ vueScript: new VueScript(), markdownFences: new MarkdownFences() }); + +test('EmbeddedBlockSplitter.isHost accepts every host extension and rejects others', () => { for (const path of ['a.vue', 'b.html', 'c.htm', 'd.md', 'e.markdown']) { - assert.equal(EmbeddedBlocks.isHost(path), true, path); + assert.equal(splitter.isHost(path), true, path); } for (const path of ['a.ts', 'b.tsx', 'c.json', 'd.css']) { - assert.equal(EmbeddedBlocks.isHost(path), false, path); + assert.equal(splitter.isHost(path), false, path); } }); -test('EmbeddedBlocks.hardValidated is true for markup and false for markdown', () => { - assert.equal(EmbeddedBlocks.hardValidated('a.vue'), true); +test('EmbeddedBlockSplitter.hardValidated is true for markup and false for markdown', () => { + assert.equal(splitter.hardValidated('a.vue'), true); - assert.equal(EmbeddedBlocks.hardValidated('b.html'), true); + assert.equal(splitter.hardValidated('b.html'), true); - assert.equal(EmbeddedBlocks.hardValidated('c.htm'), true); + assert.equal(splitter.hardValidated('c.htm'), true); - assert.equal(EmbeddedBlocks.hardValidated('d.md'), false); + assert.equal(splitter.hardValidated('d.md'), false); - assert.equal(EmbeddedBlocks.hardValidated('e.markdown'), false); + assert.equal(splitter.hardValidated('e.markdown'), false); }); -test('EmbeddedBlocks.extract reads JS/TS script blocks from Vue and HTML', () => { +test('EmbeddedBlockSplitter.extract reads JS/TS script blocks from Vue and HTML', () => { const vue = '\n\n'; - const vueBlocks = EmbeddedBlocks.extract('component.vue', vue); + const vueBlocks = splitter.extract('component.vue', vue); assert.equal(vueBlocks.length, 1); @@ -37,7 +41,7 @@ test('EmbeddedBlocks.extract reads JS/TS script blocks from Vue and HTML', () => assert.equal(vue.slice(vueBlocks[0]?.start, (vueBlocks[0]?.start ?? 0) + (vueBlocks[0]?.content.length ?? 0)), vueBlocks[0]?.content); const html = '\n\n\n\n\n'; - const htmlBlocks = EmbeddedBlocks.extract('page.html', html); + const htmlBlocks = splitter.extract('page.html', html); assert.equal(htmlBlocks.length, 1); @@ -46,9 +50,9 @@ test('EmbeddedBlocks.extract reads JS/TS script blocks from Vue and HTML', () => assert.equal(htmlBlocks[0]?.content, '\nconst x = 1;\n'); }); -test('EmbeddedBlocks.extract reads JS/TS fences from Markdown and skips others', () => { +test('EmbeddedBlockSplitter.extract reads JS/TS fences from Markdown and skips others', () => { const markdown = ['```bash', 'echo hi', '```', '', '```tsx', 'const n = 1;', '```', ''].join('\n'); - const blocks = EmbeddedBlocks.extract('notes.md', markdown); + const blocks = splitter.extract('notes.md', markdown); assert.equal(blocks.length, 1); @@ -59,15 +63,15 @@ test('EmbeddedBlocks.extract reads JS/TS fences from Markdown and skips others', assert.equal(markdown.slice(blocks[0]?.start, (blocks[0]?.start ?? 0) + (blocks[0]?.content.length ?? 0)), blocks[0]?.content); }); -test('EmbeddedBlocks.extract returns nothing for non-host paths', () => { - assert.deepEqual(EmbeddedBlocks.extract('app.ts', 'const x = 1;\n'), []); +test('EmbeddedBlockSplitter.extract returns nothing for non-host paths', () => { + assert.deepEqual(splitter.extract('app.ts', 'const x = 1;\n'), []); }); -test('EmbeddedBlocks.rewrite applies the transform per block and preserves surrounding bytes', () => { +test('EmbeddedBlockSplitter.rewrite applies the transform per block and preserves surrounding bytes', () => { const markdown = ['# Title', '', '```ts', 'const a = 1;', '```', '', '```ts', 'const b = 2;', '```', ''].join('\n'); const seen: string[] = []; - const rewritten = EmbeddedBlocks.rewrite('notes.md', markdown, (blockContent, virtualName) => { + const rewritten = splitter.rewrite('notes.md', markdown, (blockContent, virtualName) => { seen.push(virtualName); return blockContent.toUpperCase(); @@ -84,11 +88,11 @@ test('EmbeddedBlocks.rewrite applies the transform per block and preserves surro assert.ok(rewritten.includes('```')); }); -test('EmbeddedBlocks.rewrite leaves content unchanged when the transform is identity', () => { +test('EmbeddedBlockSplitter.rewrite leaves content unchanged when the transform is identity', () => { const html = '\n'; assert.equal( - EmbeddedBlocks.rewrite('page.html', html, (blockContent) => { + splitter.rewrite('page.html', html, (blockContent) => { return blockContent; }), html, diff --git a/packages/ts/sidecar/src/embedded-blocks.ts b/packages/ts/sidecar/src/hosts/embedded-block-splitter.ts similarity index 61% rename from packages/ts/sidecar/src/embedded-blocks.ts rename to packages/ts/sidecar/src/hosts/embedded-block-splitter.ts index 6137a23..2988406 100644 --- a/packages/ts/sidecar/src/embedded-blocks.ts +++ b/packages/ts/sidecar/src/hosts/embedded-block-splitter.ts @@ -1,5 +1,5 @@ -import { MarkdownFences } from '#sidecar/markdown-fences'; -import { VueScript } from '#sidecar/vue-script'; +import type { MarkdownFences } from '#sidecar/hosts/markdown-fences'; +import type { VueScript } from '#sidecar/hosts/vue-script'; /** A JavaScript-capable block embedded in a host document. */ export type EmbeddedBlock = { @@ -17,19 +17,18 @@ export type EmbeddedBlock = { export type EmbeddedTransform = (blockContent: string, virtualName: string) => string; /** Extracts and rewrites embedded JavaScript blocks across every host format. */ -export class EmbeddedBlocks { - static #isMarkup(path: string): boolean { - return path.endsWith('.vue') || path.endsWith('.html') || path.endsWith('.htm'); - } - - static #isMarkdown(path: string): boolean { - return path.endsWith('.md') || path.endsWith('.markdown'); - } +export class EmbeddedBlockSplitter { + readonly #vueScript: VueScript; + readonly #markdownFences: MarkdownFences; - static #markupExtension(openTag: string): 'ts' | 'tsx' { - const lang = VueScript.attribute(openTag, 'lang') ?? ''; - - return lang === 'tsx' || lang === 'jsx' ? 'tsx' : 'ts'; + /** + * @param scanners - The per-format scanners the splitter delegates extraction to. + * @param scanners.vueScript - Reads script blocks from Vue and HTML host markup. + * @param scanners.markdownFences - Reads fenced code blocks from Markdown hosts. + */ + constructor(scanners: { vueScript: VueScript; markdownFences: MarkdownFences }) { + this.#vueScript = scanners.vueScript; + this.#markdownFences = scanners.markdownFences; } /** @@ -38,8 +37,8 @@ export class EmbeddedBlocks { * @param path - The source path to classify. * @returns `true` for Vue, HTML, and Markdown host documents. */ - static isHost(path: string): boolean { - return EmbeddedBlocks.#isMarkup(path) || EmbeddedBlocks.#isMarkdown(path); + isHost(path: string): boolean { + return this.#isMarkup(path) || this.#isMarkdown(path); } /** @@ -48,8 +47,8 @@ export class EmbeddedBlocks { * @param path - The source path to classify. * @returns `true` for Vue and HTML; `false` for best-effort Markdown fences. */ - static hardValidated(path: string): boolean { - return EmbeddedBlocks.#isMarkup(path); + hardValidated(path: string): boolean { + return this.#isMarkup(path); } /** @@ -59,27 +58,29 @@ export class EmbeddedBlocks { * @param content - The complete host source text. * @returns The embedded blocks in source order, with parser extensions. */ - static extract(path: string, content: string): EmbeddedBlock[] { - if (EmbeddedBlocks.#isMarkdown(path)) { - return MarkdownFences.extractBlocks(content) + extract(path: string, content: string): EmbeddedBlock[] { + if (this.#isMarkdown(path)) { + return this.#markdownFences + .extractBlocks(content) .filter((block) => { - return MarkdownFences.isJavaScriptOrTypeScript(block.lang); + return this.#markdownFences.isJavaScriptOrTypeScript(block.lang); }) .map((block) => { - return { content: block.content, start: block.start, extension: MarkdownFences.scriptExtension(block.lang) }; + return { content: block.content, start: block.start, extension: this.#markdownFences.scriptExtension(block.lang) }; }); } - if (!EmbeddedBlocks.#isMarkup(path)) { + if (!this.#isMarkup(path)) { return []; } - return VueScript.extractBlocks(content) + return this.#vueScript + .extractBlocks(content) .filter((block) => { - return VueScript.isJavaScriptOrTypeScript(block.openTag); + return this.#vueScript.isJavaScriptOrTypeScript(block.openTag); }) .map((block) => { - return { content: block.content, start: block.start, extension: EmbeddedBlocks.#markupExtension(block.openTag) }; + return { content: block.content, start: block.start, extension: this.#markupExtension(block.openTag) }; }); } @@ -94,10 +95,10 @@ export class EmbeddedBlocks { * @param transform - The rewrite applied to each block's content. * @returns The host source with every changed block spliced back in place. */ - static rewrite(path: string, content: string, transform: EmbeddedTransform): string { + rewrite(path: string, content: string, transform: EmbeddedTransform): string { let updated = content; - const blocks = EmbeddedBlocks.extract(path, content); + const blocks = this.extract(path, content); for (const block of [...blocks].reverse()) { const rewritten = transform(block.content, `${path}.script.${block.extension}`); @@ -111,4 +112,18 @@ export class EmbeddedBlocks { return updated; } + + #isMarkup(path: string): boolean { + return path.endsWith('.vue') || path.endsWith('.html') || path.endsWith('.htm'); + } + + #isMarkdown(path: string): boolean { + return path.endsWith('.md') || path.endsWith('.markdown'); + } + + #markupExtension(openTag: string): 'ts' | 'tsx' { + const lang = this.#vueScript.attribute(openTag, 'lang') ?? ''; + + return lang === 'tsx' || lang === 'jsx' ? 'tsx' : 'ts'; + } } diff --git a/packages/ts/sidecar/src/hosts/file-target-policy.test.ts b/packages/ts/sidecar/src/hosts/file-target-policy.test.ts new file mode 100644 index 0000000..a3f2f9a --- /dev/null +++ b/packages/ts/sidecar/src/hosts/file-target-policy.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { EmbeddedBlockSplitter } from '#sidecar/hosts/embedded-block-splitter'; +import { FileTargetPolicy } from '#sidecar/hosts/file-target-policy'; +import { MarkdownFences } from '#sidecar/hosts/markdown-fences'; +import { VueScript } from '#sidecar/hosts/vue-script'; + +const targets = new FileTargetPolicy({ + embeddedBlocks: new EmbeddedBlockSplitter({ vueScript: new VueScript(), markdownFences: new MarkdownFences() }), +}); + +test('isTargetFile accepts ts and host documents but not declarations', () => { + assert.equal(targets.isTargetFile('app.ts'), true); + + assert.equal(targets.isTargetFile('widget.vue'), true); + + assert.equal(targets.isTargetFile('page.html'), true); + + assert.equal(targets.isTargetFile('page.htm'), true); + + assert.equal(targets.isTargetFile('notes.md'), true); + + assert.equal(targets.isTargetFile('notes.markdown'), true); + + assert.equal(targets.isTargetFile('types.d.ts'), false); + + assert.equal(targets.isTargetFile('data.json'), false); +}); + +test('isSyntaxTarget accepts every ts file plus host documents', () => { + assert.equal(targets.isSyntaxTarget('app.ts'), true); + + assert.equal(targets.isSyntaxTarget('types.d.ts'), true); + + assert.equal(targets.isSyntaxTarget('widget.vue'), true); + + assert.equal(targets.isSyntaxTarget('page.html'), true); + + assert.equal(targets.isSyntaxTarget('notes.md'), true); + + assert.equal(targets.isSyntaxTarget('data.json'), false); +}); + +test('isDeclarationFile only matches .d.ts', () => { + assert.equal(targets.isDeclarationFile('types.d.ts'), true); + + assert.equal(targets.isDeclarationFile('app.ts'), false); +}); diff --git a/packages/ts/sidecar/src/hosts/file-target-policy.ts b/packages/ts/sidecar/src/hosts/file-target-policy.ts new file mode 100644 index 0000000..a0ac54b --- /dev/null +++ b/packages/ts/sidecar/src/hosts/file-target-policy.ts @@ -0,0 +1,44 @@ +import type { EmbeddedBlockSplitter } from '#sidecar/hosts/embedded-block-splitter'; + +/** Classifies paths accepted by sidecar formatting passes. */ +export class FileTargetPolicy { + readonly #embeddedBlocks: EmbeddedBlockSplitter; + + /** + * @param dependencies - The collaborators the policy classifies through. + * @param dependencies.embeddedBlocks - Recognises host documents that embed JavaScript. + */ + constructor(dependencies: { embeddedBlocks: EmbeddedBlockSplitter }) { + this.#embeddedBlocks = dependencies.embeddedBlocks; + } + + /** + * Report whether a virtual filename denotes a TypeScript declaration file. + * + * @param virtualName - The filename to classify. + * @returns `true` when the filename ends in `.d.ts`. + */ + isDeclarationFile(virtualName: string): boolean { + return virtualName.endsWith('.d.ts'); + } + + /** + * Report whether a path denotes a supported non-declaration source file. + * + * @param path - The source path to classify. + * @returns `true` for host documents and non-declaration TypeScript files. + */ + isTargetFile(path: string): boolean { + return (path.endsWith('.ts') && !path.endsWith('.d.ts')) || this.#embeddedBlocks.isHost(path); + } + + /** + * Report whether a path is eligible for final syntax validation. + * + * @param path - The source path to classify. + * @returns `true` for host documents and every TypeScript file. + */ + isSyntaxTarget(path: string): boolean { + return path.endsWith('.ts') || this.#embeddedBlocks.isHost(path); + } +} diff --git a/packages/ts/sidecar/src/markdown-fences.property.test.ts b/packages/ts/sidecar/src/hosts/markdown-fences.property.test.ts similarity index 88% rename from packages/ts/sidecar/src/markdown-fences.property.test.ts rename to packages/ts/sidecar/src/hosts/markdown-fences.property.test.ts index 51a2546..55c0268 100644 --- a/packages/ts/sidecar/src/markdown-fences.property.test.ts +++ b/packages/ts/sidecar/src/hosts/markdown-fences.property.test.ts @@ -1,7 +1,9 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import fc from 'fast-check'; -import { MarkdownFences } from '#sidecar/markdown-fences'; +import { MarkdownFences } from '#sidecar/hosts/markdown-fences'; + +const markdownFences = new MarkdownFences(); type ExpectedBlock = { readonly lang: string; @@ -39,10 +41,10 @@ const documentArbitrary = fc.array(fc.record({ fence: fenceArbitrary, lang: lang return { document, expected }; }); -test('MarkdownFences.extractBlocks preserves generated content offsets and language detection', () => { +test('markdownFences.extractBlocks preserves generated content offsets and language detection', () => { fc.assert( fc.property(documentArbitrary, ({ document, expected }) => { - const extracted = MarkdownFences.extractBlocks(document); + const extracted = markdownFences.extractBlocks(document); assert.equal(extracted.length, expected.length); diff --git a/packages/ts/sidecar/src/markdown-fences.test.ts b/packages/ts/sidecar/src/hosts/markdown-fences.test.ts similarity index 60% rename from packages/ts/sidecar/src/markdown-fences.test.ts rename to packages/ts/sidecar/src/hosts/markdown-fences.test.ts index 1a14111..d0bbf6f 100644 --- a/packages/ts/sidecar/src/markdown-fences.test.ts +++ b/packages/ts/sidecar/src/hosts/markdown-fences.test.ts @@ -1,10 +1,12 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { MarkdownFences } from '#sidecar/markdown-fences'; +import { MarkdownFences } from '#sidecar/hosts/markdown-fences'; -test('MarkdownFences.extractBlocks returns each fenced block with its offset', () => { +const markdownFences = new MarkdownFences(); + +test('markdownFences.extractBlocks returns each fenced block with its offset', () => { const content = ['# Title', '', '```ts', 'const n = 1;', '```', '', 'prose', '', '~~~js', 'const m = 2;', '~~~', ''].join('\n'); - const blocks = MarkdownFences.extractBlocks(content); + const blocks = markdownFences.extractBlocks(content); assert.equal(blocks.length, 2); @@ -27,9 +29,9 @@ test('MarkdownFences.extractBlocks returns each fenced block with its offset', ( assert.equal(content.slice(second.start, second.start + second.content.length), second.content); }); -test('MarkdownFences.extractBlocks reads the first info-string token as the language', () => { +test('markdownFences.extractBlocks reads the first info-string token as the language', () => { const content = ['```tsx title="Example.tsx" {1,3}', 'const x = 1;', '```', ''].join('\n'); - const blocks = MarkdownFences.extractBlocks(content); + const blocks = markdownFences.extractBlocks(content); assert.equal(blocks.length, 1); @@ -38,9 +40,9 @@ test('MarkdownFences.extractBlocks reads the first info-string token as the lang assert.equal(blocks[0]?.content, 'const x = 1;\n'); }); -test('MarkdownFences.extractBlocks handles indented fences and preserves body bytes', () => { +test('markdownFences.extractBlocks handles indented fences and preserves body bytes', () => { const content = ['- item', '', ' ```ts', ' const x = 1;', ' ```', ''].join('\n'); - const blocks = MarkdownFences.extractBlocks(content); + const blocks = markdownFences.extractBlocks(content); assert.equal(blocks.length, 1); @@ -51,39 +53,39 @@ test('MarkdownFences.extractBlocks handles indented fences and preserves body by assert.equal(content.slice(start, start + (blocks[0]?.content.length ?? 0)), blocks[0]?.content); }); -test('MarkdownFences.extractBlocks requires the closing fence to be at least as long', () => { +test('markdownFences.extractBlocks requires the closing fence to be at least as long', () => { const content = ['````ts', 'const inner = "```";', '````', ''].join('\n'); - const blocks = MarkdownFences.extractBlocks(content); + const blocks = markdownFences.extractBlocks(content); assert.equal(blocks.length, 1); assert.equal(blocks[0]?.content, 'const inner = "```";\n'); }); -test('MarkdownFences.extractBlocks ignores an unterminated fence', () => { +test('markdownFences.extractBlocks ignores an unterminated fence', () => { const content = ['```ts', 'const x = 1;', 'const y = 2;', ''].join('\n'); - assert.deepEqual(MarkdownFences.extractBlocks(content), []); + assert.deepEqual(markdownFences.extractBlocks(content), []); }); -test('MarkdownFences.extractBlocks does not treat four-space indented code as a fence', () => { +test('markdownFences.extractBlocks does not treat four-space indented code as a fence', () => { const content = [' ```ts', ' const x = 1;', ' ```', ''].join('\n'); - assert.deepEqual(MarkdownFences.extractBlocks(content), []); + assert.deepEqual(markdownFences.extractBlocks(content), []); }); -test('MarkdownFences.extractBlocks yields an empty body for an immediately closed fence', () => { +test('markdownFences.extractBlocks yields an empty body for an immediately closed fence', () => { const content = ['```ts', '```', ''].join('\n'); - const blocks = MarkdownFences.extractBlocks(content); + const blocks = markdownFences.extractBlocks(content); assert.equal(blocks.length, 1); assert.equal(blocks[0]?.content, ''); }); -test('MarkdownFences.extractBlocks tolerates carriage returns', () => { +test('markdownFences.extractBlocks tolerates carriage returns', () => { const content = ['```ts', 'const x = 1;', '```', ''].join('\r\n'); - const blocks = MarkdownFences.extractBlocks(content); + const blocks = markdownFences.extractBlocks(content); assert.equal(blocks.length, 1); @@ -94,24 +96,24 @@ test('MarkdownFences.extractBlocks tolerates carriage returns', () => { assert.equal(content.slice(start, start + (blocks[0]?.content.length ?? 0)), blocks[0]?.content); }); -test('MarkdownFences.isJavaScriptOrTypeScript accepts JS/TS langs case-insensitively', () => { +test('markdownFences.isJavaScriptOrTypeScript accepts JS/TS langs case-insensitively', () => { for (const lang of ['ts', 'TS', 'tsx', 'js', 'JSX', 'typescript', 'javascript', 'mjs', 'cjs', 'mts', 'cts']) { - assert.equal(MarkdownFences.isJavaScriptOrTypeScript(lang), true, lang); + assert.equal(markdownFences.isJavaScriptOrTypeScript(lang), true, lang); } for (const lang of ['json', 'bash', 'sh', 'yaml', 'html', '']) { - assert.equal(MarkdownFences.isJavaScriptOrTypeScript(lang), false, lang); + assert.equal(markdownFences.isJavaScriptOrTypeScript(lang), false, lang); } }); -test('MarkdownFences.scriptExtension maps JSX flavours to tsx', () => { - assert.equal(MarkdownFences.scriptExtension('tsx'), 'tsx'); +test('markdownFences.scriptExtension maps JSX flavours to tsx', () => { + assert.equal(markdownFences.scriptExtension('tsx'), 'tsx'); - assert.equal(MarkdownFences.scriptExtension('JSX'), 'tsx'); + assert.equal(markdownFences.scriptExtension('JSX'), 'tsx'); - assert.equal(MarkdownFences.scriptExtension('ts'), 'ts'); + assert.equal(markdownFences.scriptExtension('ts'), 'ts'); - assert.equal(MarkdownFences.scriptExtension('js'), 'ts'); + assert.equal(markdownFences.scriptExtension('js'), 'ts'); - assert.equal(MarkdownFences.scriptExtension('typescript'), 'ts'); + assert.equal(markdownFences.scriptExtension('typescript'), 'ts'); }); diff --git a/packages/ts/sidecar/src/markdown-fences.ts b/packages/ts/sidecar/src/hosts/markdown-fences.ts similarity index 79% rename from packages/ts/sidecar/src/markdown-fences.ts rename to packages/ts/sidecar/src/hosts/markdown-fences.ts index 5098672..4acd9fc 100644 --- a/packages/ts/sidecar/src/markdown-fences.ts +++ b/packages/ts/sidecar/src/hosts/markdown-fences.ts @@ -27,7 +27,7 @@ const JAVASCRIPT_LANGS = ['ts', 'tsx', 'js', 'jsx', 'typescript', 'javascript', /** Inspects fenced code blocks embedded in CommonMark documents. */ export class MarkdownFences { - static #scanLines(content: string): ScannedLine[] { + #scanLines(content: string): ScannedLine[] { const lines: ScannedLine[] = []; let position = 0; @@ -36,25 +36,25 @@ export class MarkdownFences { const newline = content.indexOf('\n', position); if (newline === -1) { - lines.push({ start: position, end: content.length, text: MarkdownFences.#stripCarriageReturn(content.slice(position)) }); + lines.push({ start: position, end: content.length, text: this.#stripCarriageReturn(content.slice(position)) }); return lines; } - lines.push({ start: position, end: newline + 1, text: MarkdownFences.#stripCarriageReturn(content.slice(position, newline)) }); + lines.push({ start: position, end: newline + 1, text: this.#stripCarriageReturn(content.slice(position, newline)) }); position = newline + 1; } } - static #stripCarriageReturn(text: string): string { + #stripCarriageReturn(text: string): string { return text.endsWith('\r') ? text.slice(0, -1) : text; } - static #infoLanguage(info: string): string { + #infoLanguage(info: string): string { return info.trim().split(/\s+/)[0] ?? ''; } - static #findClose(lines: ScannedLine[], from: number, fenceChar: string, minLength: number): number { + #findClose(lines: ScannedLine[], from: number, fenceChar: string, minLength: number): number { const pattern = new RegExp(`^ {0,3}${fenceChar}{${minLength},}[ \\t]*$`); for (let index = from; index < lines.length; index++) { @@ -79,9 +79,9 @@ export class MarkdownFences { * @param content - The complete Markdown source text. * @returns The embedded fence blocks in source order. */ - static extractBlocks(content: string): MarkdownFenceBlock[] { + extractBlocks(content: string): MarkdownFenceBlock[] { const blocks: MarkdownFenceBlock[] = []; - const lines = MarkdownFences.#scanLines(content); + const lines = this.#scanLines(content); let index = 0; @@ -105,7 +105,7 @@ export class MarkdownFences { continue; } - const closeIndex = MarkdownFences.#findClose(lines, index + 1, fenceChar, fence.length); + const closeIndex = this.#findClose(lines, index + 1, fenceChar, fence.length); if (closeIndex === -1) { break; @@ -115,7 +115,7 @@ export class MarkdownFences { const bodyEnd = lines[closeIndex]?.start ?? content.length; blocks.push({ - lang: MarkdownFences.#infoLanguage(info), + lang: this.#infoLanguage(info), content: content.slice(bodyStart, bodyEnd), start: bodyStart, }); @@ -132,7 +132,7 @@ export class MarkdownFences { * @param lang - The first token of the fence info string. * @returns `true` for JavaScript and TypeScript language identifiers. */ - static isJavaScriptOrTypeScript(lang: string): boolean { + isJavaScriptOrTypeScript(lang: string): boolean { return JAVASCRIPT_LANGS.includes(lang.toLowerCase()); } @@ -142,7 +142,7 @@ export class MarkdownFences { * @param lang - The first token of the fence info string. * @returns `tsx` for JSX-flavoured languages, otherwise `ts`. */ - static scriptExtension(lang: string): 'ts' | 'tsx' { + scriptExtension(lang: string): 'ts' | 'tsx' { const normalized = lang.toLowerCase(); return normalized === 'tsx' || normalized === 'jsx' ? 'tsx' : 'ts'; diff --git a/packages/ts/sidecar/src/vue-script.property.test.ts b/packages/ts/sidecar/src/hosts/vue-script.property.test.ts similarity index 91% rename from packages/ts/sidecar/src/vue-script.property.test.ts rename to packages/ts/sidecar/src/hosts/vue-script.property.test.ts index 68ad1dc..1360a56 100644 --- a/packages/ts/sidecar/src/vue-script.property.test.ts +++ b/packages/ts/sidecar/src/hosts/vue-script.property.test.ts @@ -1,7 +1,9 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import fc from 'fast-check'; -import { VueScript } from '#sidecar/vue-script'; +import { VueScript } from '#sidecar/hosts/vue-script'; + +const vueScript = new VueScript(); type GeneratedBlock = { readonly markup: string; @@ -82,10 +84,10 @@ const documentArbitrary = fc }; }); -test('VueScript.extractBlocks preserves generated content offsets and language detection', () => { +test('vueScript.extractBlocks preserves generated content offsets and language detection', () => { fc.assert( fc.property(documentArbitrary, ({ document, scripts }) => { - const extracted = VueScript.extractBlocks(document); + const extracted = vueScript.extractBlocks(document); assert.equal(extracted.length, scripts.length); @@ -99,9 +101,9 @@ test('VueScript.extractBlocks preserves generated content offsets and language d assert.equal(block?.content, generated?.content); - assert.equal(VueScript.attribute(block?.openTag ?? '', 'lang'), generated?.lang); + assert.equal(vueScript.attribute(block?.openTag ?? '', 'lang'), generated?.lang); - assert.equal(VueScript.isJavaScriptOrTypeScript(block?.openTag ?? ''), generated?.javaScriptOrTypeScript); + assert.equal(vueScript.isJavaScriptOrTypeScript(block?.openTag ?? ''), generated?.javaScriptOrTypeScript); } }), { numRuns: 100 }, diff --git a/packages/ts/sidecar/src/hosts/vue-script.test.ts b/packages/ts/sidecar/src/hosts/vue-script.test.ts new file mode 100644 index 0000000..dfeb12d --- /dev/null +++ b/packages/ts/sidecar/src/hosts/vue-script.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { VueScript } from '#sidecar/hosts/vue-script'; + +const vueScript = new VueScript(); + +test('vueScript.extractBlocks returns every script block with its offset', () => { + const content = '\n\n'; + const blocks = vueScript.extractBlocks(content); + + assert.equal(blocks.length, 2); + + const [first, second] = blocks; + + assert.ok(first); + + assert.ok(second); + + assert.equal(first.openTag, '\n\n'; - const blocks = VueScript.extractBlocks(content); - - assert.equal(blocks.length, 2); - - const [first, second] = blocks; - - assert.ok(first); - - assert.ok(second); - - assert.equal(first.openTag, '