diff --git a/.vscode/launch.json b/.vscode/launch.json index 6cd956a..f1450c2 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -13,6 +13,18 @@ "args": [ "--extensionDevelopmentPath=${workspaceFolder}/packages/vscode", "--disable-extensions", + // An isolated, auto-created profile, so personal user settings + // (formatters, format-on-save, keybindings) cannot leak into the + // playground. `--user-data-dir` would be the stronger isolation, but + // the extension-host debugger strips it; `--profile` is the supported + // mechanism for debug launches. + "--profile=rstack-playground", + // A fresh profile would otherwise prompt for workspace trust and greet + // with the welcome tour on every first launch. + "--disable-workspace-trust", + "--skip-welcome", + "--skip-release-notes", + "--disable-updates", "${workspaceFolder}/packages/vscode/${input:playgroundTarget}" ], "outFiles": ["${workspaceFolder}/packages/vscode/dist/**/*.js"], diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 9825b23..2f9b86c 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -23,7 +23,11 @@ "background": { "activeOnStart": true, "beginsPattern": "build started\\.\\.\\.", - "endsPattern": "built in" + // Not "built in" — that line fires once per target, and the tiny + // worker bundle finishes seconds before the extension bundle, which + // released the F5 launch against a half-written dist/. This line is + // printed once, after every target completed. + "endsPattern": "build completed" } }, "presentation": { diff --git a/README.md b/README.md index 6797c8f..994f084 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ The extension takes its configuration from five sources. The tool-native configs | `rslint.config.*` | **Supported.** Diagnostics, quick fixes and the language server, all resolved from the `@rslint/core` installed in your project. | | `rstest.config.*` | **Supported.** Test discovery, run and debug, watch mode, coverage and snapshot updates in the Test Explorer. | | `define.test()` in `rstack.config.*` | **Supported.** Tests run through the same config shim `rs test` uses, so the editor and the CLI resolve the config identically. | -| `define.fmt()` in `rstack.config.*` | **Planned.** Detected and reported in the status bar; formatting itself arrives next, first over `rs fmt --stdin-filepath` and later over an `rs fmt` language server. | +| `define.fmt()` in `rstack.config.*` | **Supported.** Document formatting through the project-local `rs fmt --stdin-filepath`, resolving the config the same way the CLI does; an `rs fmt` language server is the longer-term path. | | `define.lint()` in `rstack.config.*` | **Planned.** Linting a project configured only through `rstack.config.*` needs upstream changes in Rslint and rstack-cli before the editor can evaluate it correctly. `rs lint` on the command line is unaffected. | ## License diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index dc72c6e..7cc491e 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -31,7 +31,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten - The lint × `rstack.config.*` bridge was built and deliberately removed: a partial editor-side bridge gave wrong results, and a correct one needs upstream work first. `TODO(rstack-bridge)` markers carry the plan. Do not reintroduce a partial bridge. - The test × `rstack.config.*` bridge stays thin on purpose: it points the upstream machinery at rstack's shipped shim and lets the shim interpret the config inside the worker, same as the CLI. Never re-implement rstack config semantics in the extension. -- The fmt stack is a stub on purpose. The MVP will spawn `rs fmt --stdin-filepath` with cwd = the config directory (forced by rs fmt's cwd-only config resolution); the endgame is an upstream LSP, so do not add a warm-process middle tier or "fix" the stub into an error state. +- The fmt stack is a spawn-per-request `rs fmt --stdin-filepath` MVP. Its cwd is the governing config directory because rs fmt resolves config from cwd only, and formatting errors are log-only by design. The endgame is an upstream LSP, so do not add a warm-process middle tier. - `projectModules.ts` has no cache-invalidation hook and restart must not grow one. Node's ESM registry is keyed by resolved URL and process-lifetime, so clearing the local memo hands back the identical module object (verified); a `?epoch=` query does reload the entry but relative specifiers inside it do not inherit the query, yielding a fresh entry over stale dependencies. In-place reinstalls under an unchanged path need a window reload — say so, don't fake it. - The VSIX is platform-targeted for exactly one reason: the test stack's AST collection loads a native parser binding. Do not add another native dependency — it multiplies the release matrix. diff --git a/packages/vscode/README.md b/packages/vscode/README.md index 300c25a..e7b5c56 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -1,6 +1,6 @@ # Rstack for VS Code -One extension for the whole [Rstack](https://rstack.rs) toolchain: [Rslint](https://github.com/web-infra-dev/rslint) linting, [Rstest](https://github.com/web-infra-dev/rstest) testing, and [rstack-cli](https://github.com/rstackjs/rstack-cli) support (coming soon). It replaces the standalone `rstack.rslint` and `rstack.rstest` extensions. +One extension for the whole [Rstack](https://rstack.rs) toolchain: [Rslint](https://github.com/web-infra-dev/rslint) linting, [Rstest](https://github.com/web-infra-dev/rstest) testing, and [rstack-cli](https://github.com/rstackjs/rstack-cli) support. It replaces the standalone `rstack.rslint` and `rstack.rstest` extensions. ## Installation @@ -13,7 +13,7 @@ The extension ships no tool binaries: `@rslint/core`, `@rstest/core` and `rstack - **Linting (Rslint)** — diagnostics, quick fixes and auto-fix on save via Rslint's language server. - **Testing (Rstest)** — a Test Explorer tree built from your test files: run or debug individual tests, suites or files; the tree stays in sync as files change; failed tests show up as editor diagnostics. -- **rstack-cli** — detected today, integration lands in upcoming releases, starting with formatting. +- **rstack-cli** — document formatting through the project-local `rs fmt` CLI. - **One status bar item** — a single `Rstack` entry shows which tools are active in the current workspace and why. ## Detection @@ -38,7 +38,7 @@ The project-resolved packages are checked against a support matrix at runtime; a | -------------- | --------- | | `@rslint/core` | `>=0.7.2` | | `@rstest/core` | `>=0.6.0` | -| `rstack` | `>=0.3.2` | +| `rstack` | `>=0.3.5` | ## Auto-fix on save (Rslint) @@ -94,8 +94,9 @@ All settings live under the unified `rstack.*` namespace. There are no `rslint.* | `rstack.rstest.debuggerAddress` | — | Debugger address. | | `rstack.rstest.terminalShellPath` | — | Shell used by **Run in Terminal**. | | `rstack.rstest.terminalShellArgs` | `[]` | Shell args for **Run in Terminal**. | -| `rstack.fmt.enable` | `true` | Enable/disable the formatter integration (upcoming). | -| `rstack.fmt.suggestDefaultFormatter` | `true` | Offer to set `editor.defaultFormatter` once rstack-cli is detected. | +| `rstack.fmt.enable` | `true` | Enable/disable the formatter integration. | + +To use `rs fmt` as the formatter for supported documents, opt in through your VS Code settings: `"editor.defaultFormatter": "rstack.rstack"`. The extension never changes `editor.defaultFormatter` itself. ## Migrating from the standalone extensions diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 65532df..272c5fb 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -334,14 +334,7 @@ "type": "boolean", "default": true, "scope": "window", - "markdownDescription": "Enable the `rs fmt` document formatter for detected workspace folders. Requires `#rstack.enable#`. This is a window-level kill switch; which folders are formatted is decided by detection. Phase 2 — the formatter is not registered yet." - }, - "rstack.fmt.suggestDefaultFormatter": { - "order": 1, - "type": "boolean", - "default": true, - "scope": "resource", - "markdownDescription": "Offer a one-time prompt to set Rstack as the workspace `editor.defaultFormatter` when `rs fmt` is detected. The extension never writes `editor.defaultFormatter` without confirmation." + "markdownDescription": "Enable the `rs fmt` document formatter for detected workspace folders. Requires `#rstack.enable#`. This is a window-level kill switch; which folders are formatted is decided by detection." } } } diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index b6bed7f..739688f 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -40,7 +40,7 @@ type Gate = */ class ExtensionShell { readonly #channels = new Channels(); - readonly #statusBar = new StatusBar(); + readonly #statusBar = new StatusBar(this.#channels.shell); readonly #detection: DetectionService; readonly #controllers = new Map(); readonly #subscriptions: vscode.Disposable[] = []; diff --git a/packages/vscode/src/shared/vendored/loadRstackConfig.ts b/packages/vscode/src/shared/vendored/loadRstackConfig.ts index a52a706..d17d95f 100644 --- a/packages/vscode/src/shared/vendored/loadRstackConfig.ts +++ b/packages/vscode/src/shared/vendored/loadRstackConfig.ts @@ -6,9 +6,9 @@ // explicit-path config loader plus adapter exports, rslint accepting per-root // fallback config candidates on `rslint/configRefresh`, and a generic // evaluator-module seam shared by the config host and plugin workers). The -// loader is kept for the phase-2 fmt stack (evaluating `define.fmt()`) and -// possible future status refinements; today only -// `nativeTypeStrippingAvailable` is consumed. +// formatter deliberately leaves `define.fmt()` evaluation to the CLI. This +// copy remains for the Rslint jiti preflight's `nativeTypeStrippingAvailable` +// probe and for direct loader unit coverage. // // Vendored from rstackjs/rstack-cli `packages/rstack/src/config.ts` // (origin/main @ 6494ba2, rstack@0.3.2). Only three things differ from upstream: diff --git a/packages/vscode/src/shared/versionCheck.ts b/packages/vscode/src/shared/versionCheck.ts index 5dcf73b..9470b20 100644 --- a/packages/vscode/src/shared/versionCheck.ts +++ b/packages/vscode/src/shared/versionCheck.ts @@ -11,12 +11,13 @@ import { readPackageJson } from './packageResolve'; * - `@rslint/core >= 0.7.2` — first version whose package exports * `./config-loader` and `./eslint-plugin`. * - `@rstest/core >= 0.6.0` — the existing `MIN_CORE_VERSION` upstream. - * - `rstack >= 0.3.2` — first release containing `rs fmt --stdin-filepath`. + * - `rstack >= 0.3.5` — first release with the full supported config and + * formatter surface. */ export const SUPPORT_MATRIX = { '@rslint/core': '>=0.7.2', '@rstest/core': '>=0.6.0', - rstack: '>=0.3.2', + rstack: '>=0.3.5', } as const; export type SupportedPackage = keyof typeof SUPPORT_MATRIX; diff --git a/packages/vscode/src/stacks/fmt/index.ts b/packages/vscode/src/stacks/fmt/index.ts index ecdfdc1..fb2645e 100644 --- a/packages/vscode/src/stacks/fmt/index.ts +++ b/packages/vscode/src/stacks/fmt/index.ts @@ -1,27 +1,293 @@ -import type { StackContext, StackController } from '../../types'; +import path from 'node:path'; +import vscode from 'vscode'; +import { + findPackageJsonUncached, + readPackageJson, +} from '../../shared/packageResolve'; +import { + readPackageVersion, + reportVersionCheck, +} from '../../shared/versionCheck'; +import type { + DetectionSnapshot, + StackContext, + StackController, +} from '../../types'; +import { + isRsFmtLaunchError, + minimalEdit, + pickConfigDir, + runRsFmt, + stderrTail, +} from './run'; + +// prettier 3.9.6 getSupportInfo() vscodeLanguageIds snapshot (rs fmt's pinned +// prettier). Revisit when the pinned prettier changes. +const LANGUAGE_IDS = [ + 'ansible', + 'css', + 'dockercompose', + 'github-actions-workflow', + 'graphql', + 'handlebars', + 'home-assistant', + 'html', + 'javascript', + 'javascriptreact', + 'json', + 'json5', + 'jsonc', + 'less', + 'markdown', + 'mdx', + 'mjml', + 'mongo', + 'postcss', + 'scss', + 'typescript', + 'typescriptreact', + 'vue', + 'yaml', +] as const; + +const SELECTOR: vscode.DocumentSelector = LANGUAGE_IDS.map((language) => ({ + language, + scheme: 'file', +})); /** - * `rs fmt` is phase 2. Detection already lights the stack so - * the status bar can tell the user it was found, but nothing is registered: - * the MVP is a `DocumentFormattingEditProvider` spawning - * `rs fmt --stdin-filepath ` with cwd = the directory containing - * `rstack.config.*`, later replaced by the `rs fmt` LSP. + * Spawn-per-request formatter backed by the project-resolved rstack CLI. The + * process cwd selects the nearest governing rstack config because `rs fmt` + * intentionally performs cwd-only config resolution. */ class FmtController implements StackController { readonly id = 'fmt' as const; - async register(context: StackContext): Promise { - context.output.info( - 'rs fmt detected, but formatting support is phase 2 and is not registered yet', + #context: StackContext | undefined; + #snapshot: DetectionSnapshot | undefined; + readonly #subscriptions: vscode.Disposable[] = []; + // One-shot log lines, keyed by `:`. Cleared when detection + // changes so a fixed setup gets a fresh explanation. + readonly #loggedOnce = new Set(); + readonly #abortController = new AbortController(); + #disposed = false; + + async register(context: StackContext): Promise> { + this.#context = context; + this.#snapshot = context.detection; + const provider: vscode.DocumentFormattingEditProvider = { + provideDocumentFormattingEdits: (document, _options, token) => + this.provideDocumentFormattingEdits(document, token), + }; + this.#subscriptions.push( + context.onDidChangeDetection((snapshot) => { + this.#snapshot = snapshot; + this.#loggedOnce.clear(); + // The reconcile leaves a still-detected controller alone, so the + // running reason must follow the new snapshot here rather than wait + // for the next successful format. + this.reportRunning(context, snapshot); + }), + vscode.languages.registerDocumentFormattingEditProvider( + SELECTOR, + provider, + ), ); - context.status.report({ - kind: 'disabled', - reason: 'rs fmt support arrives in phase 2', + this.reportRunning(context, context.detection); + return { languages: LANGUAGE_IDS, provider }; + } + + /** `running` always carries the reason the stack is on: where it was detected. */ + private reportRunning( + context: StackContext, + snapshot: DetectionSnapshot, + ): void { + const names = snapshot.foldersFor('fmt').map((entry) => entry.folder.name); + if (names.length === 0) { + // Nothing detected means the shell is about to retire this controller; + // its gate state, not `running`, is the truthful report. + return; + } + context.status.running( + names.length <= 3 + ? `detected in ${names.join(', ')}` + : `detected in ${names.length} folders`, + ); + } + + private async provideDocumentFormattingEdits( + document: vscode.TextDocument, + token: vscode.CancellationToken, + ): Promise { + const context = this.#context; + const snapshot = this.#snapshot; + if ( + this.#disposed || + !context || + !snapshot || + document.uri.scheme !== 'file' + ) { + return []; + } + + const folder = vscode.workspace.getWorkspaceFolder(document.uri); + if (!folder) { + return []; + } + const fmtDetection = snapshot.forFolder(folder)?.stacks.fmt; + if (!fmtDetection?.detected) { + // The formatter is offered per language, so a request can land in a + // folder without an rstack setup. That is routine, not a fault — one + // info line per folder says why nothing happened. + if (!this.#loggedOnce.has(`undetected:${folder.uri.toString()}`)) { + this.#loggedOnce.add(`undetected:${folder.uri.toString()}`); + context.output.info( + `A format request in ${folder.name} was skipped: fmt is not detected there (no rstack.config.* and no rstack CLI at the folder root)`, + ); + } + return []; + } + + const cwd = pickConfigDir( + document.uri.fsPath, + fmtDetection.rstackConfigFiles.map((uri) => uri.fsPath), + folder.uri.fsPath, + ); + // Per-request logging follows prettier-vscode's shape (same in-host, + // work-per-request architecture): a fixed entry and outcome line at info, + // resolution detail at debug — the channel is a LogOutputChannel, so the + // user raises the level from its context menu when needed. + const startedAt = Date.now(); + context.output.info(`Formatting ${document.uri.fsPath}`); + const pkgJsonPath = findPackageJsonUncached('rstack', cwd); + if (!pkgJsonPath) { + const reason = `rstack is not installed in ${folder.name} (node_modules missing)`; + context.status.report({ kind: 'disabled', reason }); + if (!this.#loggedOnce.has(`missing:${cwd}`)) { + this.#loggedOnce.add(`missing:${cwd}`); + context.output.warn(`${reason}; searched from ${cwd}`); + } + return []; + } + + if ( + !reportVersionCheck( + context.status, + 'rstack', + readPackageVersion(pkgJsonPath), + ) + ) { + return []; + } + + const pkg = readPackageJson(pkgJsonPath); + const bin = pkg?.bin; + let binEntry = 'bin/rs.js'; + if (typeof bin === 'string') { + binEntry = bin; + } else if (bin && typeof bin === 'object') { + const rs = (bin as Record).rs; + if (typeof rs === 'string') { + binEntry = rs; + } + } + const rsBinJs = path.resolve(path.dirname(pkgJsonPath), binEntry); + context.output.debug(`cwd: ${cwd}; bin: ${rsBinJs}`); + + const text = document.getText(); + const version = document.version; + const requestController = new AbortController(); + const abortRequest = (): void => requestController.abort(); + const cancellation = token.onCancellationRequested(abortRequest); + this.#abortController.signal.addEventListener('abort', abortRequest, { + once: true, }); + if (token.isCancellationRequested || this.#abortController.signal.aborted) { + requestController.abort(); + } + + let result; + try { + result = await runRsFmt({ + text, + filePath: document.uri.fsPath, + cwd, + rsBinJs, + signal: requestController.signal, + }); + } finally { + cancellation.dispose(); + this.#abortController.signal.removeEventListener('abort', abortRequest); + } + + if ( + token.isCancellationRequested || + document.version !== version || + this.#disposed + ) { + context.output.debug( + `Formatting result for ${document.uri.fsPath} discarded (document changed or request cancelled)`, + ); + return []; + } + + const elapsed = Date.now() - startedAt; + if (result.kind === 'ok' || result.kind === 'skipped') { + // Same tailing as the error path: a chatty warning stream must not land + // in the log unbounded. + const stderr = stderrTail(result.stderr); + if (stderr !== '') { + context.output.debug(`rs fmt stderr: ${stderr}`); + } + } + switch (result.kind) { + case 'ok': { + // The freshest snapshot, not the request's capture: detection may + // have changed while the format was in flight. + this.reportRunning(context, this.#snapshot ?? snapshot); + const edit = minimalEdit(text, result.formatted); + context.output.info( + `Formatting completed in ${elapsed}ms${edit ? '' : ' (already formatted)'}`, + ); + if (!edit) { + return []; + } + return [ + vscode.TextEdit.replace( + new vscode.Range( + document.positionAt(edit.start), + document.positionAt(edit.end), + ), + edit.newText, + ), + ]; + } + case 'skipped': + context.output.info( + `Skipped ${document.uri.fsPath}: rs fmt returned no output (the file is ignored or has no parser)`, + ); + return []; + case 'cancelled': + context.output.debug(`Formatting cancelled for ${document.uri.fsPath}`); + return []; + case 'error': + context.output.error(`rs fmt failed in ${cwd}: ${result.message}`); + if (isRsFmtLaunchError(result)) { + context.status.crashed(result.message); + } + return []; + } } dispose(): void { - // Nothing registered yet (phase 2). + this.#disposed = true; + this.#abortController.abort(); + for (const subscription of this.#subscriptions.splice(0)) { + subscription.dispose(); + } + this.#loggedOnce.clear(); + this.#context = undefined; + this.#snapshot = undefined; } } diff --git a/packages/vscode/src/stacks/fmt/run.test.ts b/packages/vscode/src/stacks/fmt/run.test.ts new file mode 100644 index 0000000..51a1c6e --- /dev/null +++ b/packages/vscode/src/stacks/fmt/run.test.ts @@ -0,0 +1,251 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from '@rstest/core'; +import { minimalEdit, pickConfigDir, runRsFmt, type RsFmtRun } from './run'; + +describe('pickConfigDir', () => { + let root: string; + + beforeEach(() => { + root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'rstack-fmt-path-')), + ); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + const config = (dir: string): string => path.join(dir, 'rstack.config.ts'); + + it('uses the directory of an ancestor config', () => { + const app = path.join(root, 'app'); + expect( + pickConfigDir(path.join(app, 'src', 'index.ts'), [config(app)], root), + ).toBe(app); + }); + + it('uses the deepest ancestor config', () => { + const app = path.join(root, 'app'); + const nested = path.join(app, 'packages', 'nested'); + expect( + pickConfigDir( + path.join(nested, 'src', 'index.ts'), + [config(app), config(nested)], + root, + ), + ).toBe(nested); + }); + + it('falls back when every config is outside the document tree', () => { + expect( + pickConfigDir( + path.join(root, 'app', 'index.ts'), + [config(path.join(root, 'other'))], + root, + ), + ).toBe(root); + }); + + it('does not confuse a sibling path prefix for an ancestor', () => { + expect( + pickConfigDir( + path.join(root, 'abc', 'index.ts'), + [config(path.join(root, 'ab'))], + root, + ), + ).toBe(root); + }); + + it('uses a config next to the document', () => { + const app = path.join(root, 'app'); + expect(pickConfigDir(path.join(app, 'index.ts'), [config(app)], root)).toBe( + app, + ); + }); +}); + +describe('minimalEdit', () => { + it('returns no edit for identical text', () => { + expect(minimalEdit('same', 'same')).toBeUndefined(); + }); + + it('handles a pure insertion', () => { + expect(minimalEdit('ac', 'abc')).toEqual({ + start: 1, + end: 1, + newText: 'b', + }); + }); + + it('handles a pure deletion', () => { + expect(minimalEdit('abc', 'ac')).toEqual({ + start: 1, + end: 2, + newText: '', + }); + }); + + it('handles a change at the start', () => { + expect(minimalEdit('old tail', 'new tail')).toEqual({ + start: 0, + end: 3, + newText: 'new', + }); + }); + + it('handles a change at the end', () => { + expect(minimalEdit('head old', 'head new')).toEqual({ + start: 5, + end: 8, + newText: 'new', + }); + }); + + it('handles a complete rewrite', () => { + expect(minimalEdit('abc', 'xyz')).toEqual({ + start: 0, + end: 3, + newText: 'xyz', + }); + }); + + it('inserts into an empty original', () => { + expect(minimalEdit('', 'text')).toEqual({ + start: 0, + end: 0, + newText: 'text', + }); + }); + + it('deletes the full original for an empty result', () => { + expect(minimalEdit(' ', '')).toEqual({ + start: 0, + end: 3, + newText: '', + }); + }); + + it('does not overlap the prefix and suffix scans', () => { + expect(minimalEdit('aa', 'aba')).toEqual({ + start: 1, + end: 1, + newText: 'b', + }); + }); + + it('normalizes a CRLF document in one replacement', () => { + expect(minimalEdit('a\r\nb\r\n', 'a\nb\n')).toEqual({ + start: 1, + end: 5, + newText: '\nb', + }); + }); +}); + +describe('runRsFmt', () => { + let root: string; + + beforeEach(() => { + root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'rstack-fmt-run-')), + ); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + const writeStub = (source: string): string => { + const filePath = path.join( + root, + `rs-${Math.random().toString(16).slice(2)}.js`, + ); + fs.writeFileSync(filePath, source); + return filePath; + }; + + const run = ( + text: string, + rsBinJs: string, + signal: AbortSignal = new AbortController().signal, + ): Promise< + ReturnType extends Promise ? T : never + > => { + const options: RsFmtRun = { + text, + filePath: path.join(root, 'input.ts'), + cwd: root, + rsBinJs, + signal, + }; + return runRsFmt(options); + }; + + it('captures the complete formatted stdout', async () => { + const stub = writeStub('process.stdin.pipe(process.stdout);\n'); + await expect(run('const value = 1;\n', stub)).resolves.toEqual({ + kind: 'ok', + formatted: 'const value = 1;\n', + stderr: '', + }); + }); + + it('drains stdout while writing a large document', async () => { + const stub = writeStub('process.stdin.pipe(process.stdout);\n'); + const text = 'x'.repeat(1024 * 1024 + 17); + await expect(run(text, stub)).resolves.toEqual({ + kind: 'ok', + formatted: text, + stderr: '', + }); + }); + + it('returns the stderr tail for a formatter failure', async () => { + const stub = writeStub( + "for (let i = 0; i < 12; i++) console.error('line-' + i); process.exit(2);\n", + ); + const result = await run('broken', stub); + expect(result.kind).toBe('error'); + if (result.kind === 'error') { + expect(result.message).toContain('line-11'); + expect(result.message).not.toContain('line-0'); + } + }); + + it('handles a child exiting without reading stdin', async () => { + const stub = writeStub('process.exit(2);\n'); + const result = await run('x'.repeat(1024 * 1024), stub); + expect(result).toEqual({ + kind: 'error', + message: 'rs fmt exited with code 2', + }); + }); + + it('returns cancelled when aborted mid-run', async () => { + const stub = writeStub('setTimeout(() => {}, 30_000);\n'); + const controller = new AbortController(); + const resultPromise = run('const value = 1;', stub, controller.signal); + setTimeout(() => controller.abort(), 50); + await expect(resultPromise).resolves.toEqual({ kind: 'cancelled' }); + }); + + it('does not spawn for an already-aborted signal', async () => { + const controller = new AbortController(); + controller.abort(); + await expect( + run('const value = 1;', path.join(root, 'missing.js'), controller.signal), + ).resolves.toEqual({ kind: 'cancelled' }); + }); + + it('names an unloadable rs entry path', async () => { + const missing = path.join(root, 'missing.js'); + const result = await run('const value = 1;', missing); + expect(result.kind).toBe('error'); + if (result.kind === 'error') { + expect(result.message).toContain(missing); + expect(result.message).toContain('Unable to run rs fmt'); + } + }); +}); diff --git a/packages/vscode/src/stacks/fmt/run.ts b/packages/vscode/src/stacks/fmt/run.ts new file mode 100644 index 0000000..82209c4 --- /dev/null +++ b/packages/vscode/src/stacks/fmt/run.ts @@ -0,0 +1,223 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import path from 'node:path'; + +const TIMEOUT_MS = 60_000; +const LAUNCH_ERROR_PREFIX = 'Unable to run rs fmt at '; + +/** Deepest config directory that contains the document, or the workspace root. */ +export const pickConfigDir = ( + documentPath: string, + configFilePaths: readonly string[], + fallbackDir: string, +): string => { + const documentDir = path.dirname(path.resolve(documentPath)); + let selected = path.resolve(fallbackDir); + let selectedDepth = -1; + + for (const configFilePath of configFilePaths) { + const configDir = path.dirname(path.resolve(configFilePath)); + const relative = path.relative(configDir, documentDir); + const containsDocument = + relative === '' || + (relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative)); + if (!containsDocument) { + continue; + } + + const depth = configDir.split(path.sep).filter(Boolean).length; + if (depth > selectedDepth) { + selected = configDir; + selectedDepth = depth; + } + } + + return selected; +}; + +export interface RsFmtRun { + readonly text: string; + readonly filePath: string; + readonly cwd: string; + readonly rsBinJs: string; + readonly signal: AbortSignal; +} + +export type RsFmtResult = + // `stderr` carries the CLI's own voice (warnings on otherwise-successful + // runs) so the caller can surface it, the way an LSP-based tool would push + // `window/logMessage`. + | { readonly kind: 'ok'; readonly formatted: string; readonly stderr: string } + | { readonly kind: 'skipped'; readonly stderr: string } + | { readonly kind: 'cancelled' } + | { readonly kind: 'error'; readonly message: string }; + +const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +/** Bounds any CLI stderr headed for a log line to its meaningful tail. */ +export const stderrTail = (stderr: string): string => + stderr.trim().split(/\r?\n/).slice(-10).join('\n'); + +const launchError = (rsBinJs: string, detail: string): RsFmtResult => ({ + kind: 'error', + message: `${LAUNCH_ERROR_PREFIX}${rsBinJs}: ${detail}`, +}); + +/** True only when the CLI itself could not be launched or loaded. */ +export const isRsFmtLaunchError = (result: RsFmtResult): boolean => + result.kind === 'error' && result.message.startsWith(LAUNCH_ERROR_PREFIX); + +export const runRsFmt = async (run: RsFmtRun): Promise => { + if (run.signal.aborted) { + return { kind: 'cancelled' }; + } + + return new Promise((resolve) => { + let settled = false; + const guard: { timeout?: NodeJS.Timeout } = {}; + let child: ChildProcessWithoutNullStreams | undefined; + + const onAbort = (): void => { + child?.kill(); + settle({ kind: 'cancelled' }); + }; + const settle = (result: RsFmtResult): void => { + if (settled) { + return; + } + settled = true; + if (guard.timeout) { + clearTimeout(guard.timeout); + } + run.signal.removeEventListener('abort', onAbort); + resolve(result); + }; + + try { + child = spawn( + process.execPath, + [ + run.rsBinJs, + 'fmt', + '--stdin-filepath', + run.filePath, + '--ignore-unknown', + ], + { + cwd: run.cwd, + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + signal: run.signal, + stdio: 'pipe', + }, + ); + } catch (error) { + settle( + run.signal.aborted + ? { kind: 'cancelled' } + : launchError(run.rsBinJs, errorMessage(error)), + ); + return; + } + + const stdout: string[] = []; + const stderr: string[] = []; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => stdout.push(chunk)); + child.stderr.on('data', (chunk: string) => stderr.push(chunk)); + child.on('error', (error) => { + settle( + run.signal.aborted + ? { kind: 'cancelled' } + : launchError(run.rsBinJs, errorMessage(error)), + ); + }); + child.on('close', (code) => { + if (run.signal.aborted) { + settle({ kind: 'cancelled' }); + return; + } + + const formatted = stdout.join(''); + const stderrText = stderr.join(''); + if (code === 0) { + if (formatted.length > 0 || run.text.trim() === '') { + settle({ kind: 'ok', formatted, stderr: stderrText }); + } else { + settle({ kind: 'skipped', stderr: stderrText }); + } + return; + } + const tail = stderrTail(stderrText); + const missingEntry = stderrText + .split(/\r?\n/) + .some( + (line) => + line.includes('Cannot find module') && line.includes(run.rsBinJs), + ); + if (missingEntry) { + settle(launchError(run.rsBinJs, tail)); + return; + } + settle({ + kind: 'error', + message: + tail || + `rs fmt exited with code ${code === null ? 'unknown' : String(code)}`, + }); + }); + + run.signal.addEventListener('abort', onAbort, { once: true }); + guard.timeout = setTimeout(() => { + child?.kill(); + settle({ + kind: 'error', + message: `rs fmt at ${run.rsBinJs} timed out after ${TIMEOUT_MS / 1000} seconds`, + }); + }, TIMEOUT_MS); + + // A child may reject the request before consuming stdin. Its exit status is + // authoritative; EPIPE and write-after-end must not become unhandled errors. + child.stdin.on('error', () => {}); + try { + child.stdin.end(run.text); + } catch { + // Wait for the child's close/error event. + } + }); +}; + +/** A single minimal replacement, expressed as offsets to stay vscode-free. */ +export const minimalEdit = ( + original: string, + formatted: string, +): { start: number; end: number; newText: string } | undefined => { + if (original === formatted) { + return undefined; + } + + let start = 0; + const sharedLength = Math.min(original.length, formatted.length); + while (start < sharedLength && original[start] === formatted[start]) { + start += 1; + } + + let originalEnd = original.length; + let formattedEnd = formatted.length; + while ( + originalEnd > start && + formattedEnd > start && + original[originalEnd - 1] === formatted[formattedEnd - 1] + ) { + originalEnd -= 1; + formattedEnd -= 1; + } + + return { + start, + end: originalEnd, + newText: formatted.slice(start, formattedEnd), + }; +}; diff --git a/packages/vscode/src/stacks/test/bridge.test.ts b/packages/vscode/src/stacks/test/bridge.test.ts index 7225351..1144c51 100644 --- a/packages/vscode/src/stacks/test/bridge.test.ts +++ b/packages/vscode/src/stacks/test/bridge.test.ts @@ -53,7 +53,7 @@ const makeTmpDir = (): string => { * `dist/rstestConfig.js`. */ const createWorkspace = ({ - version = '0.3.2', + version = '0.3.5', shim = true, }: { version?: string | null; shim?: boolean } = {}): string => { const root = makeTmpDir(); @@ -103,7 +103,7 @@ describe('resolveRstackShim', () => { const shim = resolveRstackShim(configDir); expect(shim).toBeDefined(); - expect(shim?.version).toBe('0.3.2'); + expect(shim?.version).toBe('0.3.5'); // The same file `rs test` injects with `--config`. expect(shim?.configFilePath).toBe( path.join(configDir, 'node_modules', 'rstack', 'dist', 'rstestConfig.js'), @@ -134,14 +134,14 @@ describe('resolveRstackShim', () => { }); it('refuses an rstack older than the support matrix floor', () => { - const configDir = createWorkspace({ version: '0.3.1' }); + const configDir = createWorkspace({ version: '0.3.4' }); expect(resolveRstackShim(configDir)).toBeUndefined(); expect(reported).toEqual([ { kind: 'version-mismatch', detail: - 'rstack 0.3.1 is not supported, this extension requires >=0.3.2', + 'rstack 0.3.4 is not supported, this extension requires >=0.3.5', }, ]); }); diff --git a/packages/vscode/src/statusBar.ts b/packages/vscode/src/statusBar.ts index b629c91..e4d88be 100644 --- a/packages/vscode/src/statusBar.ts +++ b/packages/vscode/src/statusBar.ts @@ -97,7 +97,10 @@ export class StatusBar implements vscode.Disposable { // disposed, and the reconcile that follows will retry it anyway). readonly #active = new Set(); - constructor() { + readonly #output: vscode.LogOutputChannel; + + constructor(output: vscode.LogOutputChannel) { + this.#output = output; this.#item = vscode.window.createStatusBarItem( 'rstack.status', vscode.StatusBarAlignment.Right, @@ -125,7 +128,19 @@ export class StatusBar implements vscode.Disposable { } setState(stack: StackId, state: StackState): void { + // Transitions go to the shell log so the hover's icon always has a written + // "why" behind it. Same-text repeats are dropped: a stack re-reporting its + // current state (e.g. `running` after every format) is not a transition, + // and an identical text also renders identically, so the rebuild is + // skipped along with the log line. Text alone discriminates the state: + // every kind has a distinct `stateText` prefix. + const text = stateText(state); + const unchanged = text === stateText(this.stateOf(stack)); this.#states.set(stack, state); + if (unchanged) { + return; + } + this.#output.info(`${STACK_LABELS[stack]} status: ${text}`); this.render(); } diff --git a/packages/vscode/tests/e2e/fixtures/.gitattributes b/packages/vscode/tests/e2e/fixtures/.gitattributes new file mode 100644 index 0000000..05b1b6e --- /dev/null +++ b/packages/vscode/tests/e2e/fixtures/.gitattributes @@ -0,0 +1,5 @@ +# Fixture sources must be byte-identical on every platform: the fmt E2E +# asserts the exact formatted output, and a CRLF checkout (git autocrlf on +# the Windows runners) would survive formatting — VS Code drops EOL-only +# changes from computeMoreMinimalEdits, so the assertion sees \r\n. +* text eol=lf diff --git a/packages/vscode/tests/e2e/fixtures/rstack/.vscode/settings.json b/packages/vscode/tests/e2e/fixtures/rstack/.vscode/settings.json new file mode 100644 index 0000000..f46a17d --- /dev/null +++ b/packages/vscode/tests/e2e/fixtures/rstack/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + // Playground convenience for the F5 launch: saving any file in this fixture + // formats it through the extension under development. The launch config's + // isolated `--profile` keeps personal user settings (and their + // language-scoped formatter overrides) out, so these two general keys are + // sufficient and cover every language the fmt stack registers. The E2E + // suites never save documents in this fixture, so the settings cannot leak + // into assertions. + "editor.defaultFormatter": "rstack.rstack", + "editor.formatOnSave": true +} diff --git a/packages/vscode/tests/e2e/fixtures/rstack/package.json b/packages/vscode/tests/e2e/fixtures/rstack/package.json index 38b8068..4ab416e 100644 --- a/packages/vscode/tests/e2e/fixtures/rstack/package.json +++ b/packages/vscode/tests/e2e/fixtures/rstack/package.json @@ -5,7 +5,7 @@ "type": "module", "description": "E2E fixture: an rstack-cli project whose only config is `rstack.config.ts`, which lights the Rstest and rs fmt stacks.", "dependencies": { - "rstack": "^0.3.2" + "rstack": "^0.3.5" }, "devDependencies": { "jiti": "^2.0.0" diff --git a/packages/vscode/tests/e2e/fixtures/rstack/src/needs-format.ts b/packages/vscode/tests/e2e/fixtures/rstack/src/needs-format.ts new file mode 100644 index 0000000..01bb788 --- /dev/null +++ b/packages/vscode/tests/e2e/fixtures/rstack/src/needs-format.ts @@ -0,0 +1 @@ +const answer={value:'42'}; diff --git a/packages/vscode/tests/e2e/lint/runTest.ts b/packages/vscode/tests/e2e/lint/runTest.ts index f2ce20b..02d9ecc 100644 --- a/packages/vscode/tests/e2e/lint/runTest.ts +++ b/packages/vscode/tests/e2e/lint/runTest.ts @@ -136,6 +136,9 @@ async function runIsolatedSuite( suite.workspaceEntry ? resolveSandboxEntry(workspaceCopy, suite.workspaceEntry) : workspaceCopy, + // Keep VS Code's CI-only extension inventory and AgentHost info logs + // out of test output while preserving an opt-in for diagnosis. + `--log=${process.env.VSCODE_TEST_LOG_LEVEL ?? 'warn'}`, '--disable-extensions', '--disable-updates', // The fixtures spawn project-local binaries, which Restricted Mode diff --git a/packages/vscode/tests/e2e/rstest/runTest.ts b/packages/vscode/tests/e2e/rstest/runTest.ts index 0604746..d2e92f3 100644 --- a/packages/vscode/tests/e2e/rstest/runTest.ts +++ b/packages/vscode/tests/e2e/rstest/runTest.ts @@ -78,6 +78,9 @@ async function main() { extensionTestsPath, launchArgs: [ workspaceFile, + // Keep VS Code's CI-only extension inventory and AgentHost info logs out + // of test output while preserving an opt-in for verbose diagnosis. + `--log=${process.env.VSCODE_TEST_LOG_LEVEL ?? 'warn'}`, // Only the extension under development runs: no user extension may // register a competing test controller. '--disable-extensions', diff --git a/packages/vscode/tests/e2e/runTest.ts b/packages/vscode/tests/e2e/runTest.ts index 1700787..9e16ef9 100644 --- a/packages/vscode/tests/e2e/runTest.ts +++ b/packages/vscode/tests/e2e/runTest.ts @@ -58,6 +58,9 @@ async function main() { extensionTestsPath, launchArgs: [ workspaceFile, + // Keep VS Code's CI-only extension inventory and AgentHost info logs out + // of test output while preserving an opt-in for verbose diagnosis. + `--log=${process.env.VSCODE_TEST_LOG_LEVEL ?? 'warn'}`, // Only the extension under development runs: no user extension may // register a competing formatter, test controller or language client. '--disable-extensions', diff --git a/packages/vscode/tests/e2e/suite/fmt.test.ts b/packages/vscode/tests/e2e/suite/fmt.test.ts new file mode 100644 index 0000000..1cc0933 --- /dev/null +++ b/packages/vscode/tests/e2e/suite/fmt.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import * as vscode from 'vscode'; +import type { RstackExtensionExports } from '../../../src/types'; +import { eventually } from './helpers'; + +const EXTENSION_ID = 'rstack.rstack'; +let provider: vscode.DocumentFormattingEditProvider; + +const folderNamed = (name: string): vscode.WorkspaceFolder => { + const folder = (vscode.workspace.workspaceFolders ?? []).find( + (candidate) => candidate.name === name, + ); + assert.ok(folder, `the ${name} fixture folder is not in the workspace`); + return folder; +}; + +suite('fmt', () => { + suiteSetup(async () => { + const extension = + vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(extension, `${EXTENSION_ID} is not installed in the test host`); + const api = await extension.activate(); + const exports = await api.whenStackActive('fmt'); + assert.ok(exports.provider, 'the fmt stack did not export its provider'); + provider = exports.provider as vscode.DocumentFormattingEditProvider; + }); + + test('formats through the provider without touching the workspace', async () => { + const uri = vscode.Uri.joinPath( + folderNamed('rstack').uri, + 'src', + 'needs-format.ts', + ); + const { document, edits } = await eventually(async () => { + const document = await vscode.workspace.openTextDocument(uri); + const edits = await vscode.commands.executeCommand( + 'vscode.executeFormatDocumentProvider', + uri, + { tabSize: 2, insertSpaces: true }, + ); + assert.ok(edits && edits.length > 0, 'the formatter returned no edits'); + return { document, edits }; + }, 'the rs fmt provider to return an edit'); + + const text = document.getText(); + let applied = text; + // The command post-processes our single minimal edit through VS Code's + // `computeMoreMinimalEdits`, so apply its result from the end backwards. + for (const edit of [...edits].sort( + (left, right) => + document.offsetAt(right.range.start) - + document.offsetAt(left.range.start), + )) { + const start = document.offsetAt(edit.range.start); + const end = document.offsetAt(edit.range.end); + applied = applied.slice(0, start) + edit.newText + applied.slice(end); + } + // The quote normalization is Prettier-specific, so VS Code's built-in + // TypeScript formatter cannot mask a failed Rstack provider via fallback. + assert.equal(applied, 'const answer = { value: "42" };\n'); + }); + + test('returns no edits for a folder where fmt is not detected', async () => { + const uri = vscode.Uri.joinPath( + folderNamed('rslint').uri, + 'src', + 'index.ts', + ); + const document = await vscode.workspace.openTextDocument(uri); + const cancellation = new vscode.CancellationTokenSource(); + try { + const edits = await provider.provideDocumentFormattingEdits( + document, + { tabSize: 2, insertSpaces: true }, + cancellation.token, + ); + assert.ok(!edits || edits.length === 0); + } finally { + cancellation.dispose(); + } + }); +}); diff --git a/packages/vscode/tests/e2e/suite/index.ts b/packages/vscode/tests/e2e/suite/index.ts index 1695693..dfd8ada 100644 --- a/packages/vscode/tests/e2e/suite/index.ts +++ b/packages/vscode/tests/e2e/suite/index.ts @@ -24,13 +24,24 @@ export function run(): Promise { return new Promise((resolve, reject) => { try { - mocha.run((failures) => { + // Mocha's reporter writes to the extension host's stdout, which never + // reaches the harness log — the rejection message is the only channel + // that does, so it must name the failures itself. + const failed: string[] = []; + const runner = mocha.run((failures) => { if (failures > 0) { - reject(new Error(`${failures} E2E test(s) failed.`)); + reject( + new Error(`${failures} E2E test(s) failed:\n${failed.join('\n')}`), + ); } else { resolve(); } }); + runner.on('fail', (test, error) => { + failed.push( + `- ${test.fullTitle()}: ${error instanceof Error ? error.message : String(error)}`, + ); + }); } catch (error) { reject(error instanceof Error ? error : new Error(String(error))); } diff --git a/packages/vscode/tests/unit/versionCheck.test.ts b/packages/vscode/tests/unit/versionCheck.test.ts index 16c3563..3d3e518 100644 --- a/packages/vscode/tests/unit/versionCheck.test.ts +++ b/packages/vscode/tests/unit/versionCheck.test.ts @@ -11,7 +11,7 @@ describe('support matrix', () => { expect(SUPPORT_MATRIX).toEqual({ '@rslint/core': '>=0.7.2', '@rstest/core': '>=0.6.0', - rstack: '>=0.3.2', + rstack: '>=0.3.5', }); }); }); @@ -21,7 +21,7 @@ describe('checkPackageVersion', () => { expect(checkPackageVersion('@rslint/core', '0.7.2').kind).toBe('ok'); expect(checkPackageVersion('@rslint/core', '1.2.3').kind).toBe('ok'); expect(checkPackageVersion('@rstest/core', '0.11.5').kind).toBe('ok'); - expect(checkPackageVersion('rstack', '0.3.2').kind).toBe('ok'); + expect(checkPackageVersion('rstack', '0.3.5').kind).toBe('ok'); }); it('accepts prereleases of a supported range', () => {