From 1fbe57e07845b5d6d194f6781fcd160dd4a77ac0 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Thu, 6 Aug 2026 19:16:30 +0800 Subject: [PATCH 01/10] feat(vscode): implement the rs fmt document formatter --- packages/vscode/AGENTS.md | 2 +- packages/vscode/README.md | 11 +- packages/vscode/package.json | 9 +- .../src/shared/vendored/loadRstackConfig.ts | 6 +- packages/vscode/src/shared/versionCheck.ts | 5 +- packages/vscode/src/stacks/fmt/index.ts | 228 +++++++++++++++- packages/vscode/src/stacks/fmt/run.test.ts | 249 ++++++++++++++++++ packages/vscode/src/stacks/fmt/run.ts | 220 ++++++++++++++++ .../vscode/src/stacks/test/bridge.test.ts | 8 +- .../tests/e2e/fixtures/rstack/package.json | 2 +- .../e2e/fixtures/rstack/src/needs-format.ts | 1 + packages/vscode/tests/e2e/suite/fmt.test.ts | 82 ++++++ .../vscode/tests/unit/versionCheck.test.ts | 4 +- 13 files changed, 788 insertions(+), 39 deletions(-) create mode 100644 packages/vscode/src/stacks/fmt/run.test.ts create mode 100644 packages/vscode/src/stacks/fmt/run.ts create mode 100644 packages/vscode/tests/e2e/fixtures/rstack/src/needs-format.ts create mode 100644 packages/vscode/tests/e2e/suite/fmt.test.ts 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/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..904979c 100644 --- a/packages/vscode/src/stacks/fmt/index.ts +++ b/packages/vscode/src/stacks/fmt/index.ts @@ -1,27 +1,229 @@ -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, +} 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[] = []; + readonly #warnedCwds = 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.#warnedCwds.clear(); + }), + vscode.languages.registerDocumentFormattingEditProvider( + SELECTOR, + provider, + ), + ); + context.status.running(); + return { languages: LANGUAGE_IDS, provider }; + } + + 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); + const fmtDetection = folder + ? snapshot.forFolder(folder)?.stacks.fmt + : undefined; + if (!folder || !fmtDetection?.detected) { + return []; + } + + const cwd = pickConfigDir( + document.uri.fsPath, + fmtDetection.rstackConfigFiles.map((uri) => uri.fsPath), + folder.uri.fsPath, ); - context.status.report({ - kind: 'disabled', - reason: 'rs fmt support arrives in phase 2', + 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.#warnedCwds.has(cwd)) { + this.#warnedCwds.add(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); + + 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 + ) { + return []; + } + + switch (result.kind) { + case 'ok': { + context.status.running(); + const edit = minimalEdit(text, result.formatted); + if (!edit) { + return []; + } + return [ + vscode.TextEdit.replace( + new vscode.Range( + document.positionAt(edit.start), + document.positionAt(edit.end), + ), + edit.newText, + ), + ]; + } + case 'skipped': + case 'cancelled': + 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.#warnedCwds.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..50e023f --- /dev/null +++ b/packages/vscode/src/stacks/fmt/run.test.ts @@ -0,0 +1,249 @@ +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', + }); + }); + + 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, + }); + }); + + 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..4539d43 --- /dev/null +++ b/packages/vscode/src/stacks/fmt/run.ts @@ -0,0 +1,220 @@ +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 = + | { readonly kind: 'ok'; readonly formatted: string } + | { readonly kind: 'skipped' } + | { readonly kind: 'cancelled' } + | { readonly kind: 'error'; readonly message: string }; + +const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +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(''); + if (code === 0) { + if (formatted.length > 0 || run.text.trim() === '') { + settle({ kind: 'ok', formatted }); + } else { + settle({ kind: 'skipped' }); + } + return; + } + + const stderrText = stderr.join(''); + 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/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/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/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', () => { From 776cb9c1e9b2abe4ceaf91773cffa5386b04376b Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 7 Aug 2026 11:29:32 +0800 Subject: [PATCH 02/10] feat(vscode): log fmt requests and stack status transitions Status-bar state changes now land in the shell log with their reason, and identical re-reports skip both the log line and the tooltip rebuild. The fmt stack logs each request the way prettier-vscode does (entry and outcome at info with duration, resolution detail at debug), forwards the CLI's stderr tail on successful runs, and explains once per folder why a request in an undetected folder did nothing. --- packages/vscode/src/extension.ts | 2 +- packages/vscode/src/stacks/fmt/index.ts | 75 ++++++++++++++++++---- packages/vscode/src/stacks/fmt/run.test.ts | 2 + packages/vscode/src/stacks/fmt/run.ts | 17 +++-- packages/vscode/src/statusBar.ts | 17 ++++- 5 files changed, 93 insertions(+), 20 deletions(-) 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/stacks/fmt/index.ts b/packages/vscode/src/stacks/fmt/index.ts index 904979c..45b1b96 100644 --- a/packages/vscode/src/stacks/fmt/index.ts +++ b/packages/vscode/src/stacks/fmt/index.ts @@ -18,6 +18,7 @@ import { minimalEdit, pickConfigDir, runRsFmt, + stderrTail, } from './run'; // prettier 3.9.6 getSupportInfo() vscodeLanguageIds snapshot (rs fmt's pinned @@ -65,7 +66,9 @@ class FmtController implements StackController { #context: StackContext | undefined; #snapshot: DetectionSnapshot | undefined; readonly #subscriptions: vscode.Disposable[] = []; - readonly #warnedCwds = new Set(); + // 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; @@ -79,17 +82,30 @@ class FmtController implements StackController { this.#subscriptions.push( context.onDidChangeDetection((snapshot) => { this.#snapshot = snapshot; - this.#warnedCwds.clear(); + this.#loggedOnce.clear(); }), vscode.languages.registerDocumentFormattingEditProvider( SELECTOR, provider, ), ); - context.status.running(); + 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); + context.status.running( + names.length <= 3 + ? `detected in ${names.join(', ')}` + : `detected in ${names.length} folders`, + ); + } + private async provideDocumentFormattingEdits( document: vscode.TextDocument, token: vscode.CancellationToken, @@ -106,10 +122,20 @@ class FmtController implements StackController { } const folder = vscode.workspace.getWorkspaceFolder(document.uri); - const fmtDetection = folder - ? snapshot.forFolder(folder)?.stacks.fmt - : undefined; - if (!folder || !fmtDetection?.detected) { + 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 []; } @@ -118,12 +144,18 @@ class FmtController implements StackController { 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.#warnedCwds.has(cwd)) { - this.#warnedCwds.add(cwd); + if (!this.#loggedOnce.has(`missing:${cwd}`)) { + this.#loggedOnce.add(`missing:${cwd}`); context.output.warn(`${reason}; searched from ${cwd}`); } return []; @@ -151,6 +183,7 @@ class FmtController implements StackController { } } const rsBinJs = path.resolve(path.dirname(pkgJsonPath), binEntry); + context.output.debug(`cwd: ${cwd}; bin: ${rsBinJs}`); const text = document.getText(); const version = document.version; @@ -183,13 +216,28 @@ class FmtController implements StackController { 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': { - context.status.running(); + this.reportRunning(context, snapshot); const edit = minimalEdit(text, result.formatted); + context.output.info( + `Formatting completed in ${elapsed}ms${edit ? '' : ' (already formatted)'}`, + ); if (!edit) { return []; } @@ -204,7 +252,12 @@ class FmtController implements StackController { ]; } 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}`); @@ -221,7 +274,7 @@ class FmtController implements StackController { for (const subscription of this.#subscriptions.splice(0)) { subscription.dispose(); } - this.#warnedCwds.clear(); + 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 index 50e023f..51a1c6e 100644 --- a/packages/vscode/src/stacks/fmt/run.test.ts +++ b/packages/vscode/src/stacks/fmt/run.test.ts @@ -188,6 +188,7 @@ describe('runRsFmt', () => { await expect(run('const value = 1;\n', stub)).resolves.toEqual({ kind: 'ok', formatted: 'const value = 1;\n', + stderr: '', }); }); @@ -197,6 +198,7 @@ describe('runRsFmt', () => { await expect(run(text, stub)).resolves.toEqual({ kind: 'ok', formatted: text, + stderr: '', }); }); diff --git a/packages/vscode/src/stacks/fmt/run.ts b/packages/vscode/src/stacks/fmt/run.ts index 4539d43..82209c4 100644 --- a/packages/vscode/src/stacks/fmt/run.ts +++ b/packages/vscode/src/stacks/fmt/run.ts @@ -45,15 +45,19 @@ export interface RsFmtRun { } export type RsFmtResult = - | { readonly kind: 'ok'; readonly formatted: string } - | { readonly kind: 'skipped' } + // `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); -const stderrTail = (stderr: string): string => +/** 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 => ({ @@ -137,16 +141,15 @@ export const runRsFmt = async (run: RsFmtRun): Promise => { } const formatted = stdout.join(''); + const stderrText = stderr.join(''); if (code === 0) { if (formatted.length > 0 || run.text.trim() === '') { - settle({ kind: 'ok', formatted }); + settle({ kind: 'ok', formatted, stderr: stderrText }); } else { - settle({ kind: 'skipped' }); + settle({ kind: 'skipped', stderr: stderrText }); } return; } - - const stderrText = stderr.join(''); const tail = stderrTail(stderrText); const missingEntry = stderrText .split(/\r?\n/) 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(); } From fd985ec4fc09bbe4387da76e83d0b34b57ef500a Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 7 Aug 2026 11:29:46 +0800 Subject: [PATCH 03/10] chore(vscode): isolate the F5 playground from personal settings The extension-host debugger inherits the developer's own profile, so a personal language-scoped defaultFormatter silently outranks the fixture's folder settings; with extensions disabled that formatter is unavailable and format-on-save does nothing. Launch the playground with an isolated, auto-created profile instead (the debugger strips --user-data-dir, so --profile is the supported mechanism), and give the rstack fixture format-on-save through the extension under development. --- .vscode/launch.json | 12 ++++++++++++ .../tests/e2e/fixtures/rstack/.vscode/settings.json | 11 +++++++++++ 2 files changed, 23 insertions(+) create mode 100644 packages/vscode/tests/e2e/fixtures/rstack/.vscode/settings.json 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/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 +} From 71bc94d6d7cffc6b147d0eb57756a8e8341b6289 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 7 Aug 2026 11:29:47 +0800 Subject: [PATCH 04/10] chore(vscode): quiet the E2E hosts to warn-level logs VS Code's CI-only extension inventory and AgentHost info logs drowned the test output; --log=warn keeps them out while VSCODE_TEST_LOG_LEVEL preserves an opt-in for verbose diagnosis. --- packages/vscode/tests/e2e/lint/runTest.ts | 3 +++ packages/vscode/tests/e2e/rstest/runTest.ts | 3 +++ packages/vscode/tests/e2e/runTest.ts | 3 +++ 3 files changed, 9 insertions(+) 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', From ff3ea28a84cf8a702811be08301213dab2b4c45a Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 7 Aug 2026 11:30:58 +0800 Subject: [PATCH 05/10] docs: mark the fmt stack as supported on the roadmap --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 753f59b69377dfbe2269878a7d245a8e7f9f2c0b Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 7 Aug 2026 11:37:16 +0800 Subject: [PATCH 06/10] fix(vscode): hold the F5 launch until every watch target is built MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The watch matcher's endsPattern matched the per-target "built in" line, and the worker bundle finishes seconds before the extension bundle — the first F5 launched against a half-written dist/ and the extension failed to load. Match the once-per-build "build completed" line instead. --- .vscode/tasks.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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": { From 4fbf53787eabeac9664de1a6de6e47cc7fb9b1af Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 7 Aug 2026 11:40:08 +0800 Subject: [PATCH 07/10] fix(vscode): refresh the fmt running reason when detection changes A config or lockfile change can reshape the set of fmt-detected folders while reconciliation deliberately keeps the controller alive, leaving the status hover and shell log with stale folder names until the next successful format. Report from the detection listener instead; an empty snapshot stays silent because the shell's retire path owns the off states. --- packages/vscode/src/stacks/fmt/index.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/vscode/src/stacks/fmt/index.ts b/packages/vscode/src/stacks/fmt/index.ts index 45b1b96..9b9d243 100644 --- a/packages/vscode/src/stacks/fmt/index.ts +++ b/packages/vscode/src/stacks/fmt/index.ts @@ -83,6 +83,10 @@ class FmtController implements StackController { 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, @@ -99,6 +103,11 @@ class FmtController implements StackController { 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(', ')}` From a4754fc8db960ea4b6b607f4075ba9b7e6cb5e37 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 7 Aug 2026 11:50:56 +0800 Subject: [PATCH 08/10] test(vscode): name failing E2E tests in the runner's rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mocha's reporter writes to the extension host's stdout, which never reaches the harness log — CI showed only the failure count. The rejection message is the one channel that does surface, so it now carries each failed test's title and assertion message. --- packages/vscode/tests/e2e/suite/index.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) 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))); } From 51630c5217eb7983ed90270e736df269c2d54530 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 7 Aug 2026 11:50:56 +0800 Subject: [PATCH 09/10] fix(vscode): report the running reason from the latest snapshot Detection can change while a format request is in flight; the success path reported from the request's captured snapshot and reverted the hover and shell log to the pre-change folder list. --- packages/vscode/src/stacks/fmt/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/vscode/src/stacks/fmt/index.ts b/packages/vscode/src/stacks/fmt/index.ts index 9b9d243..fb2645e 100644 --- a/packages/vscode/src/stacks/fmt/index.ts +++ b/packages/vscode/src/stacks/fmt/index.ts @@ -242,7 +242,9 @@ class FmtController implements StackController { } switch (result.kind) { case 'ok': { - this.reportRunning(context, snapshot); + // 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)'}`, From a6e1817b8c1cb4f3291618561d3e6c3deace6dc2 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 7 Aug 2026 12:00:40 +0800 Subject: [PATCH 10/10] fix(vscode): pin E2E fixture sources to LF on every platform The Windows runners check fixtures out with CRLF (autocrlf), and VS Code drops EOL-only changes from computeMoreMinimalEdits, so the formatted document keeps \r\n and the fmt E2E's exact-output assertion fails. Byte- identical fixtures make the assertion hold everywhere without loosening it. --- packages/vscode/tests/e2e/fixtures/.gitattributes | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/vscode/tests/e2e/fixtures/.gitattributes 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