diff --git a/.gitignore b/.gitignore index b2f67e3..33093f8 100644 --- a/.gitignore +++ b/.gitignore @@ -154,6 +154,7 @@ packages/vscode/.playground/ # their generated node_modules directories and lockfiles stay disposable. packages/vscode/e2e/fixtures/*/node_modules/ packages/vscode/e2e/fixtures/*/pnpm-lock.yaml +packages/vscode/e2e/fixtures/rstack-cli-package-path/infra/pnpm-lock.yaml packages/vscode/e2e/lint/fixtures/pnpm-lock.yaml packages/vscode/e2e/lint/fixtures/*/pnpm-lock.yaml packages/vscode/e2e/rstest/fixtures/*/pnpm-lock.yaml diff --git a/packages/vscode/.vscodeignore b/packages/vscode/.vscodeignore index 8b657f0..b5ff7c4 100644 --- a/packages/vscode/.vscodeignore +++ b/packages/vscode/.vscodeignore @@ -2,6 +2,7 @@ # dist/ — never source maps. ** !dist/**/*.js +!dist/rstack-resolve-hook.mjs !dist/**/*.node !dist/*.licenses.json !icon.png diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index fb3408b..095dd02 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -12,7 +12,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten ## The twelve adaptations 1. **Shell activation** — stacks never self-activate; `register()` returns fast and never blocks on starting a server/worker. -2. **Namespace** — everything user-visible is `rstack.*`. Legacy `rslint.*` / `rstest.*` settings and command ids are not read, aliased or migrated (breaking old settings and keybindings was an accepted cost). Both `rstack.rslint.corePath` and `rstack.rstest.corePath` accept a package directory or its `package.json`, resolving relative paths from the workspace folder; only Rstest expands `${workspaceFolder}`. Preserve these unified override names without legacy aliases when syncing. +2. **Namespace** — everything user-visible is `rstack.*`. Legacy `rslint.*` / `rstest.*` settings and command ids are not read, aliased or migrated (breaking old settings and keybindings was an accepted cost). Both `rstack.rslint.corePath` and `rstack.rstest.corePath` accept a package directory or its `package.json`, resolving relative paths from the workspace folder; only Rstest expands `${workspaceFolder}`. Preserve these unified override names without legacy aliases when syncing. Every consumer of the `rstack` package (fmt, bridged lint, bridged test) also reads the shared `rstack.rstackCli.packagePath` (`shared/rstackPackage.ts`), which replaces the walk-up for `rstack` itself; the tool core is still resolved from the directory it selects unless `corePath` is set. Like `corePath`, it changes resolution only, never detection. While it is set, every process that loads `rstack.config.*` (fmt server, bridged lint and Rstest workers, Rstest's Run in Terminal) also gets a resolve hook as a `--import` node arg so the config's own `import 'rstack'` reaches the same package (`rstackResolveHookArgs` in `shared/rstackPackage.ts`). The hook bridges to Node's resolver; never replace it with `NODE_PATH`, `JITI_ALIAS` or anything else that imitates a package-manager shim or names files inside `rstack`. 3. **Resolve-from-project** — no tool binaries or tool packages in the VSIX; everything resolves from the user's project so the editor runs the CLI's exact versions. Version floors surface as a status, never a crash. All cooperating lint pieces (binary, config loader, plugin host) must come from one resolution root. Enforced by lint: `@typescript-eslint/no-restricted-imports` in the root `rstack.config.ts` rejects any non-type import of `@rslint/core`, `@rstest/core`, `rstack` or `jiti` under `src/` — types only at compile time, runtime modules through explicit project paths. 4. **Status aggregation** — stacks own no UI chrome; they report to the shell's single status bar item, which always exists. Upstream's plugin-host failure toast becomes a status verdict (adaptation 7). Rslint LSP tracing shares the **Rstack: Rslint** Output channel to preserve the four-channel cap; see `stacks/lint/index.ts`. In CI the test stack's `MasterLogger` also mirrors every entry to stderr (`RSTACK_E2E_MIRROR_LOGS=1`, set by `e2e/rstest/runTest.ts`) — the output channel is unreadable there; rationale in `stacks/test/logger.ts`. 5. **Worker-cwd decoupling** (test) — a project's cwd is explicit, not derived from the config file path; for native configs behavior stays byte-identical to upstream. diff --git a/packages/vscode/README.md b/packages/vscode/README.md index 7baf3bf..2563603 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -77,6 +77,7 @@ All settings live under the unified `rstack.*` namespace. There are no `rslint.* | Setting | Default | Description | | --- | --- | --- | | `rstack.nodeExecutable` | — | Node binary used for the processes that load your project: the lint worker, test worker and `rs fmt` language server. Empty means the extension picks one (`PATH` first, then the `node` your interactive shell resolves). | +| `rstack.rstackCli.packagePath` | — | Path to an `rstack` package directory or its `package.json` (e.g. `infra/node_modules/rstack`); relative paths resolve from the workspace folder and `${workspaceFolder}` is supported. Used by the formatter, and by Rslint and Rstest when a root `rstack.config.*` drives them. Use only when `rstack` is not installed in a `node_modules` above the workspace folder. The config's own `import 'rstack'` resolves to the same package. On Node 22 and Node 24 below 24.15, this works only for configs Node can load natively: no `enum`, `namespace` or parameter properties, and relative imports with their file extension. | | `rstack.rslint.enable` | `true` | Enable/disable the Rslint integration. | | `rstack.rslint.corePath` | — | Path to an `@rslint/core` package directory or its `package.json`; relative paths resolve from the workspace folder. Use only to override automatic resolution. | | `rstack.rslint.trace.server` | `off` | LSP trace level (`off` / `messages` / `verbose`). | diff --git a/packages/vscode/e2e/fixtures/rstack-cli-package-path/.nvmrc b/packages/vscode/e2e/fixtures/rstack-cli-package-path/.nvmrc new file mode 100644 index 0000000..d875019 --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstack-cli-package-path/.nvmrc @@ -0,0 +1 @@ +26.11.1 diff --git a/packages/vscode/e2e/fixtures/rstack-cli-package-path/.vscode/settings.json b/packages/vscode/e2e/fixtures/rstack-cli-package-path/.vscode/settings.json new file mode 100644 index 0000000..6df6d1b --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstack-cli-package-path/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + // The setting under test (#103): the only way the extension can find the + // `rstack` installed under `infra/`. + "rstack.rstackCli.packagePath": "infra/node_modules/rstack" +} diff --git a/packages/vscode/e2e/fixtures/rstack-cli-package-path/infra/package.json b/packages/vscode/e2e/fixtures/rstack-cli-package-path/infra/package.json new file mode 100644 index 0000000..95a275c --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstack-cli-package-path/infra/package.json @@ -0,0 +1,13 @@ +{ + "name": "rstack-editor-fixture-rstack-cli-package-path-infra", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "The tooling install of the rstack-cli-package-path fixture, outside the folder root's walk-up.", + "dependencies": { + "rstack": "0.8.4" + }, + "devDependencies": { + "jiti": "^2.7.0" + } +} diff --git a/packages/vscode/e2e/fixtures/rstack-cli-package-path/infra/pnpm-workspace.yaml b/packages/vscode/e2e/fixtures/rstack-cli-package-path/infra/pnpm-workspace.yaml new file mode 100644 index 0000000..0330b39 --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstack-cli-package-path/infra/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +# E2E fixture install config; rationale in packages/vscode/e2e/setupFixtures.mjs. +minimumReleaseAgeExclude: + - rstack + - '@rslint/*' + - '@rstest/core' + - '@rsbuild/core' + - '@rslib/core' + - '@rstackjs/*' + - rsbuild-plugin-dts + - '@rspack/*' diff --git a/packages/vscode/e2e/fixtures/rstack-cli-package-path/package.json b/packages/vscode/e2e/fixtures/rstack-cli-package-path/package.json new file mode 100644 index 0000000..7b494b9 --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstack-cli-package-path/package.json @@ -0,0 +1,7 @@ +{ + "name": "rstack-editor-fixture-rstack-cli-package-path", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "E2E fixture (#103): a root `rstack.config.ts` whose `rstack` is installed only under `infra/`." +} diff --git a/packages/vscode/e2e/fixtures/rstack-cli-package-path/rstack.config.ts b/packages/vscode/e2e/fixtures/rstack-cli-package-path/rstack.config.ts new file mode 100644 index 0000000..480de34 --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstack-cli-package-path/rstack.config.ts @@ -0,0 +1,23 @@ +// Rstack configuration guide: https://rstack.rs/config +// +// The #103 layout: this config sits at the workspace folder root, but +// `rstack` is installed only under `infra/`, where no `node_modules` walk-up +// from the root can see it. `.vscode/settings.json` points +// `rstack.rstackCli.packagePath` at that installation, which must light all +// three stacks. The E2E harness copies the fixture out of this repository first, +// so the repository's own `node_modules/rstack` cannot satisfy the walk-up. +import { define } from 'rstack'; + +define.lint([ + { + files: ['src/**/*.ts'], + rules: { + 'no-debugger': 'error', + }, + }, +]); + +define.test({ + name: 'rstack-editor-fixture-rstack-cli-package-path', + include: ['tests/**/*.test.ts'], +}); diff --git a/packages/vscode/e2e/fixtures/rstack-cli-package-path/src/index.ts b/packages/vscode/e2e/fixtures/rstack-cli-package-path/src/index.ts new file mode 100644 index 0000000..ef833c6 --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstack-cli-package-path/src/index.ts @@ -0,0 +1,6 @@ +// A lintable issue for the `no-debugger` rule configured through +// `define.lint()` in `rstack.config.ts`. +export function trace(value: unknown): unknown { + debugger; + return value; +} diff --git a/packages/vscode/e2e/fixtures/rstack-cli-package-path/src/needs-format.ts b/packages/vscode/e2e/fixtures/rstack-cli-package-path/src/needs-format.ts new file mode 100644 index 0000000..01bb788 --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstack-cli-package-path/src/needs-format.ts @@ -0,0 +1 @@ +const answer={value:'42'}; diff --git a/packages/vscode/e2e/fixtures/rstack-cli-package-path/tests/basic.test.ts b/packages/vscode/e2e/fixtures/rstack-cli-package-path/tests/basic.test.ts new file mode 100644 index 0000000..4db5dbb --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstack-cli-package-path/tests/basic.test.ts @@ -0,0 +1,7 @@ +import { expect, test } from '@rstest/core'; + +// Discovered through `define.test()` in `rstack.config.ts`, not through an +// `rstest.config.*` file. +test('trims a string', () => { + expect(' rstack '.trim()).toBe('rstack'); +}); diff --git a/packages/vscode/e2e/run.mjs b/packages/vscode/e2e/run.mjs index 781eda1..e230089 100644 --- a/packages/vscode/e2e/run.mjs +++ b/packages/vscode/e2e/run.mjs @@ -24,9 +24,16 @@ const SLICES = [ }, { // The shell/detection/fmt suites (`e2e/suite/`) over the multi-root - // workspace of the three shared fixtures. + // workspace of the three shared fixtures, plus the single-folder + // launches in `e2e/runTest.ts`. name: 'vscode', - fixtures: ['rslint', 'rstest', 'rstack', 'fmt-missing-config-dependency'], + fixtures: [ + 'rslint', + 'rstest', + 'rstack', + 'fmt-missing-config-dependency', + 'rstack-cli-package-path', + ], entry: 'tests-dist/e2e/runTest.js', compile: true, }, diff --git a/packages/vscode/e2e/runTest.ts b/packages/vscode/e2e/runTest.ts index dad4d53..d485fbf 100644 --- a/packages/vscode/e2e/runTest.ts +++ b/packages/vscode/e2e/runTest.ts @@ -6,7 +6,7 @@ * control to `suite/index.ts` inside the extension host. */ import { createHash } from 'node:crypto'; -import { existsSync, mkdtempSync } from 'node:fs'; +import { cpSync, existsSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { runTests } from '@vscode/test-electron'; @@ -110,6 +110,60 @@ async function main() { workspace: path.join(fixturesDir, 'fmt-missing-config-dependency'), profileSuffix: 'fmt-missing-config-dependency', }); + await launchRstackCliPackagePath(extensionDevelopmentPath, fixturesDir); +} + +/** + * The #103 layout must run outside this repository: a walk-up from a fixture + * inside the checkout would reach the repository's own `node_modules/rstack`, + * which would satisfy all three stacks without the setting under test. The + * copy keeps the layout and links `infra/node_modules` back to the installed + * fixture, so `rstack` stays reachable only through + * `rstack.rstackCli.packagePath`. + */ +async function launchRstackCliPackagePath( + extensionDevelopmentPath: string, + fixturesDir: string, +): Promise { + const fixture = path.join(fixturesDir, 'rstack-cli-package-path'); + const installed = path.join(fixture, 'infra', 'node_modules'); + if (!existsSync(installed)) { + throw new Error( + 'the rstack-cli-package-path E2E fixture is not installed — run `pnpm test:e2e:fixtures`.', + ); + } + // Short on Unix: VS Code appends socket names to paths under the workspace. + const sandbox = mkdtempSync( + path.join(process.platform === 'win32' ? tmpdir() : '/tmp', 'rsp-'), + ); + const workspace = path.join(sandbox, 'w'); + try { + cpSync(fixture, workspace, { + recursive: true, + filter: (source) => path.basename(source) !== 'node_modules', + }); + symlinkSync( + installed, + path.join(workspace, 'infra', 'node_modules'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + await launch({ + extensionDevelopmentPath, + extensionTestsPath: path.resolve( + __dirname, + './suite-rstack-cli-package-path/index', + ), + workspace, + profileSuffix: 'rstack-cli-package-path', + }); + } finally { + rmSync(sandbox, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 200, + }); + } } main().catch((error) => { diff --git a/packages/vscode/e2e/setupFixtures.mjs b/packages/vscode/e2e/setupFixtures.mjs index 05a6845..0a6d673 100644 --- a/packages/vscode/e2e/setupFixtures.mjs +++ b/packages/vscode/e2e/setupFixtures.mjs @@ -49,6 +49,13 @@ export const FIXTURES = { FIXTURES_DIR, 'fmt-missing-config-dependency', ), + // The #103 layout installs only its `infra/` tooling directory; the folder + // root has no dependencies of its own. + 'rstack-cli-package-path': path.join( + FIXTURES_DIR, + 'rstack-cli-package-path', + 'infra', + ), 'rstest-workspace-1': path.join(here, 'rstest', 'fixtures', 'workspace-1'), 'rstest-workspace-2': path.join(here, 'rstest', 'fixtures', 'workspace-2'), lint: path.join(here, 'lint', 'fixtures'), diff --git a/packages/vscode/e2e/suite-rstack-cli-package-path/index.ts b/packages/vscode/e2e/suite-rstack-cli-package-path/index.ts new file mode 100644 index 0000000..e86c367 --- /dev/null +++ b/packages/vscode/e2e/suite-rstack-cli-package-path/index.ts @@ -0,0 +1,3 @@ +import { createRun } from '../runSuite'; + +export const run = createRun(__dirname); diff --git a/packages/vscode/e2e/suite-rstack-cli-package-path/rstack-cli-package-path.test.ts b/packages/vscode/e2e/suite-rstack-cli-package-path/rstack-cli-package-path.test.ts new file mode 100644 index 0000000..967d707 --- /dev/null +++ b/packages/vscode/e2e/suite-rstack-cli-package-path/rstack-cli-package-path.test.ts @@ -0,0 +1,159 @@ +// The #103 layout end to end: `rstack.config.ts` at the folder root, `rstack` +// installed only under `infra/`, and `rstack.rstackCli.packagePath` pointing at +// it (`.vscode/settings.json`). `e2e/runTest.ts` runs this from a copy outside +// the repository, so no `node_modules` walk-up can find any `rstack`: every +// stack that works here found it through the setting. +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import * as vscode from 'vscode'; +import { findPackageJsonUncached } from '../../src/shared/packageResolve'; +import { RSTACK_PACKAGE_PATH_SETTING } from '../../src/shared/rstackPackage'; +import type { RstackExtensionExports } from '../../src/types'; +import { + diagnosticRuleIdIncludes, + waitForRslintDiagnostics, +} from '../lint/utils/diagnostics'; +import { + createCollectingMockRun, + currentRstestExports, + getTestItemByLabels, + waitFor, +} from '../rstest/suite/helpers'; +import { applyEdits, eventually } from '../suite/helpers'; + +const folder = (): vscode.WorkspaceFolder => { + const [workspaceFolder] = vscode.workspace.workspaceFolders ?? []; + assert.ok(workspaceFolder, 'the fixture workspace folder is unavailable'); + return workspaceFolder; +}; + +/** Where `infra/node_modules/rstack` really lives (the installed fixture). */ +const infraStore = (): string => + fs.realpathSync(path.join(folder().uri.fsPath, 'infra', 'node_modules')); + +let api: RstackExtensionExports; + +suite('rstack.rstackCli.packagePath', () => { + suiteSetup(async () => { + const extension = + vscode.extensions.getExtension('rstack.rstack'); + assert.ok(extension, 'rstack.rstack is not installed in the test host'); + api = await extension.activate(); + // No `rstack` is reachable by walk-up from the sandbox copy; the precondition + // that makes every assertion below about the setting. + assert.equal( + findPackageJsonUncached('rstack', folder().uri.fsPath), + undefined, + 'a walk-up from the folder root reaches an rstack installation', + ); + }); + + test('formats through the configured rstack', async () => { + const exports = await api.whenStackActive('fmt'); + const folderStates = exports.folderStates as () => Record; + await eventually(() => { + assert.equal(folderStates()[folder().uri.fsPath], 'running'); + }, 'the rs fmt server to start'); + + const uri = vscode.Uri.joinPath(folder().uri, 'src', 'needs-format.ts'); + const document = await vscode.workspace.openTextDocument(uri); + // Retry: until the server's client registers its provider, VS Code's + // built-in TypeScript formatter answers with different text. + await eventually(async () => { + const edits = await vscode.commands.executeCommand( + 'vscode.executeFormatDocumentProvider', + uri, + { tabSize: 2, insertSpaces: true }, + ); + assert.ok(edits && edits.length > 0, 'expected formatting edits'); + // Prettier's quote normalization: VS Code's built-in formatter cannot + // produce it, so a fallback cannot satisfy this. + assert.equal( + applyEdits(document, edits), + 'const answer = { value: "42" };\n', + ); + }, 'rs fmt to format the document'); + }); + + test('lints through the configured rstack and its @rslint/core', async () => { + const document = await vscode.workspace.openTextDocument( + vscode.Uri.joinPath(folder().uri, 'src', 'index.ts'), + ); + await vscode.window.showTextDocument(document); + await waitForRslintDiagnostics(document, (diagnostics) => + diagnostics.some((diagnostic) => + diagnosticRuleIdIncludes(diagnostic, 'no-debugger'), + ), + ); + + const lint = api.getStackExports('rslint'); + assert.ok(lint, 'the Rslint stack should be active'); + const runtimes = ( + lint.getRuntimeStates as () => ReadonlyMap + )(); + // The runtime key carries the core and shim paths: both come from the + // configured installation's store. + assert.ok( + [...runtimes.keys()].some((key) => key.includes(infraStore())), + `expected a runtime on ${infraStore()}, got: ${[...runtimes.keys()].join(' | ')}`, + ); + }); + + test('discovers and runs bridged tests through the configured rstack', async () => { + const { rstestInstance, item } = await waitFor( + () => { + const rstestInstance = currentRstestExports(); + return { + rstestInstance, + item: getTestItemByLabels(rstestInstance.testController.items, [ + 'tests', + 'basic.test.ts', + ]), + }; + }, + { timeoutMs: 60_000 }, + ); + + const sourceUri = vscode.Uri.joinPath( + folder().uri, + 'rstack.config.ts', + ).toString(); + const rstestPath = rstestInstance.getResolvedRstestPath(sourceUri); + assert.ok( + rstestPath?.startsWith(infraStore()), + `expected rstack's own @rstest/core under ${infraStore()}, got: ${rstestPath}`, + ); + + const collecting = createCollectingMockRun(); + void rstestInstance.startTestRun( + new vscode.TestRunRequest([item], undefined, rstestInstance.runProfile), + new vscode.CancellationTokenSource().token, + false, + collecting.createMockRun, + ); + await collecting.ended; + assert.equal(collecting.failedMessages.length, 0, collecting.output); + assert.deepEqual( + collecting.passedItems.map((passed) => passed.label).sort(), + ['basic.test.ts', 'trims a string'], + ); + }); + + test('falls back to not installed once the setting is cleared', async () => { + // The setting is a restart trigger; the rebuilt fmt stack walks up again + // and finds nothing. + await vscode.workspace + .getConfiguration(undefined, folder().uri) + .update( + RSTACK_PACKAGE_PATH_SETTING, + undefined, + vscode.ConfigurationTarget.WorkspaceFolder, + ); + await eventually(async () => { + const exports = await api.whenStackActive('fmt'); + const folderStates = exports.folderStates as () => Record; + assert.equal(folderStates()[folder().uri.fsPath], 'disabled'); + }, 'the fmt stack to report rstack as not installed'); + }); +}); diff --git a/packages/vscode/e2e/suite/fmt.test.ts b/packages/vscode/e2e/suite/fmt.test.ts index fe0b006..e537ae4 100644 --- a/packages/vscode/e2e/suite/fmt.test.ts +++ b/packages/vscode/e2e/suite/fmt.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import * as vscode from 'vscode'; import type { RstackExtensionExports } from '../../src/types'; -import { eventually } from './helpers'; +import { applyEdits, eventually } from './helpers'; const EXTENSION_ID = 'rstack.rstack'; /** @@ -80,21 +80,12 @@ suite('fmt', () => { ); assert.ok(edits && edits.length > 0, 'the formatter returned no edits'); - let applied = document.getText(); - // 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'); + assert.equal( + applyEdits(document, edits), + 'const answer = { value: "42" };\n', + ); return document; }, 'the rs fmt language server to return a Prettier-formatted edit'); diff --git a/packages/vscode/e2e/suite/helpers.ts b/packages/vscode/e2e/suite/helpers.ts index 559582e..3786f65 100644 --- a/packages/vscode/e2e/suite/helpers.ts +++ b/packages/vscode/e2e/suite/helpers.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import type * as vscode from 'vscode'; export const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); @@ -35,3 +36,25 @@ export const eventually = async ( await delay(intervalMs); } }; + +/** + * Applies the edits a `vscode.executeFormatDocumentProvider` call returned. + * The command post-processes a provider's edits through VS Code's + * `computeMoreMinimalEdits`, so they are applied from the end backwards. + */ +export const applyEdits = ( + document: vscode.TextDocument, + edits: readonly vscode.TextEdit[], +): string => { + let applied = document.getText(); + 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); + } + return applied; +}; diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 9b46314..8ccd3b6 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -132,6 +132,19 @@ } } }, + { + "type": "object", + "title": "Rstack › Rstack CLI", + "properties": { + "rstack.rstackCli.packagePath": { + "order": 0, + "type": "string", + "default": "", + "scope": "resource", + "markdownDescription": "Path to an `rstack` installation, either its package directory or its `package.json` file (for example `infra/node_modules/rstack`). Relative paths are resolved from the workspace folder. Supports the `${workspaceFolder}` placeholder. It applies wherever the extension uses `rstack`: the `rs fmt` formatter, and Rslint and Rstest when a root `rstack.config.*` drives them, which then resolve `@rslint/core` and `@rstest/core` from this installation. Tools still run from the workspace folder, so its `rstack.config.*` applies. Use this override only when `rstack` is not installed in a `node_modules` above the workspace folder." + } + } + }, { "type": "object", "title": "Rstack › Rslint", diff --git a/packages/vscode/rslib.config.mts b/packages/vscode/rslib.config.mts index 038a7c6..5f6465b 100644 --- a/packages/vscode/rslib.config.mts +++ b/packages/vscode/rslib.config.mts @@ -72,6 +72,14 @@ const libs: LibConfig[] = [ from: yukuBindingPath, to: `${yukuBindingPackage}/${path.basename(yukuBindingPath)}`, }, + { + // Preloaded via a `--import` node arg, so a file of its own. + from: path.join( + rootDir, + 'src/shared/rstackResolveHookPreload.mjs', + ), + to: 'rstack-resolve-hook.mjs', + }, { // The VSIX must carry a LICENSE (`.vscodeignore` keeps it), and // the workspace root LICENSE is the single source of truth. The diff --git a/packages/vscode/src/shared/nodeExecutableSetting.ts b/packages/vscode/src/shared/nodeExecutableSetting.ts index 67140ac..9526ce0 100644 --- a/packages/vscode/src/shared/nodeExecutableSetting.ts +++ b/packages/vscode/src/shared/nodeExecutableSetting.ts @@ -25,10 +25,17 @@ export const expandWorkspaceFolder = ( */ export const getConfiguredNodeExecutable = ( folder: vscode.WorkspaceFolder, +): string | undefined => + getConfiguredPathSetting(NODE_EXECUTABLE_SETTING, folder); + +/** A folder's path setting: unset or empty is `undefined`, else expanded. */ +export const getConfiguredPathSetting = ( + setting: string, + folder: vscode.WorkspaceFolder, ): string | undefined => { const value = vscode.workspace .getConfiguration(undefined, folder.uri) - .get(NODE_EXECUTABLE_SETTING); + .get(setting); if (typeof value !== 'string' || value === '') { return undefined; } diff --git a/packages/vscode/src/shared/rstackPackage.ts b/packages/vscode/src/shared/rstackPackage.ts new file mode 100644 index 0000000..ef3c094 --- /dev/null +++ b/packages/vscode/src/shared/rstackPackage.ts @@ -0,0 +1,80 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { findPackageJsonUncached, readPackageJson } from './packageResolve'; + +/** + * Where an `rstack` installation lives that the walk-up cannot see (#103). + * Read by fmt and by bridged lint and test; like `corePath`, it changes + * resolution only, never detection. + */ +export const RSTACK_PACKAGE_PATH_SETTING = 'rstack.rstackCli.packagePath'; + +/** The `not-installed` log's pointer to the setting, for every rstack caller. */ +export const RSTACK_PACKAGE_PATH_HINT = `if it is installed elsewhere, set "${RSTACK_PACKAGE_PATH_SETTING}" to it`; + +export type RstackPackageResolution = + | { readonly kind: 'found'; readonly packageJsonPath: string } + | { readonly kind: 'not-installed' } + | { readonly kind: 'invalid-setting'; readonly message: string }; + +const invalidSetting = ( + configuredPath: string, + problem: string, +): RstackPackageResolution => ({ + kind: 'invalid-setting', + message: `The "${RSTACK_PACKAGE_PATH_SETTING}" setting points at ${configuredPath}, which ${problem}. Update the setting to an installed "rstack" package directory or its package.json, or clear it to use the nearest installation.`, +}); + +/** + * Locates `rstack/package.json`: the setting's value (`configured`, relative + * to `folderRoot`) when set, else the walk-up from `folderRoot`. A setting + * that does not resolve is `invalid-setting`, never the not-installed state. + * The path is a realpath: under pnpm only the store path has rstack's own + * dependencies beside it, which lint and test resolve their cores from. + */ +export const resolveRstackPackageJson = ( + folderRoot: string, + configured?: string, +): RstackPackageResolution => { + if (configured === undefined) { + const packageJsonPath = findPackageJsonUncached('rstack', folderRoot); + return packageJsonPath === undefined + ? { kind: 'not-installed' } + : { kind: 'found', packageJsonPath }; + } + + const resolved = path.resolve(folderRoot, configured); + const configuredPath = configured.endsWith('package.json') + ? resolved + : path.join(resolved, 'package.json'); + let packageJsonPath: string; + try { + if (!fs.statSync(configuredPath).isFile()) throw new Error('not a file'); + packageJsonPath = fs.realpathSync(configuredPath); + } catch { + return invalidSetting(configuredPath, 'does not exist'); + } + if (readPackageJson(packageJsonPath)?.name !== 'rstack') { + return invalidSetting(configuredPath, 'is not the "rstack" package'); + } + return { kind: 'found', packageJsonPath }; +}; + +/** + * The node args (before the script path) that preload the resolve hook + * (`rstackResolveHookPreload.mjs`) when the setting chose the rstack at + * `rstackDirectory`, so the config's own `import 'rstack'` reaches that + * package. A node arg, not env, so a terminal command line can carry it; the + * package.json path rides in the URL's `pkg` query. + */ +export const rstackResolveHookArgs = ( + rstackDirectory: string | undefined, + fromSetting: boolean | undefined, +): string[] => { + if (rstackDirectory === undefined || !fromSetting) return []; + // Copied beside the bundles by `rslib.config.mts`. + const url = pathToFileURL(path.resolve(__dirname, 'rstack-resolve-hook.mjs')); + url.searchParams.set('pkg', path.join(rstackDirectory, 'package.json')); + return ['--import', url.href]; +}; diff --git a/packages/vscode/src/shared/rstackResolveHookPreload.mjs b/packages/vscode/src/shared/rstackResolveHookPreload.mjs new file mode 100644 index 0000000..8e49ab4 --- /dev/null +++ b/packages/vscode/src/shared/rstackResolveHookPreload.mjs @@ -0,0 +1,41 @@ +// Preloaded via a `--import` node arg whose `pkg` query carries the +// configured package.json path (why: `rstackResolveHookArgs` in +// `rstackPackage.ts`). +// Resolves `rstack` and `rstack/*` as if from inside the configured package, +// through its own `exports`; every other specifier passes through. Plain JS, +// copied to `dist/` by `rslib.config.mts`: Node loads it outside the bundle. +import nodeModule from 'node:module'; +import { pathToFileURL } from 'node:url'; + +const packageJson = new URL(import.meta.url).searchParams.get('pkg'); + +if (packageJson) { + const anchorURL = pathToFileURL(packageJson).href; + const requireFromPackage = nodeModule.createRequire(packageJson); + let inside = false; + + nodeModule.registerHooks({ + resolve(specifier, context, nextResolve) { + if ( + inside || + !(specifier === 'rstack' || specifier.startsWith('rstack/')) + ) { + return nextResolve(specifier, context); + } + if (context.conditions?.includes('require')) { + // The CJS default resolver ignores a rewritten parentURL, so ask the + // package's own require() instead. On Node 22 and Node 24 below 24.15 + // `require.resolve` (jiti's path) never reaches this hook at all. + inside = true; + let filename; + try { + filename = requireFromPackage.resolve(specifier); + } finally { + inside = false; + } + return { url: pathToFileURL(filename).href, shortCircuit: true }; + } + return nextResolve(specifier, { ...context, parentURL: anchorURL }); + }, + }); +} diff --git a/packages/vscode/src/stacks/fmt/index.ts b/packages/vscode/src/stacks/fmt/index.ts index b2c9472..6fa0a76 100644 --- a/packages/vscode/src/stacks/fmt/index.ts +++ b/packages/vscode/src/stacks/fmt/index.ts @@ -16,7 +16,10 @@ import { RSTACK_CONFIG_GLOB } from '../../detection'; import { MessageLatch } from '../../shared/messageLatch'; import { displayPath } from '../../shared/displayPath'; import { classifyMissingDependencyMessage } from '../../shared/missingDependency'; -import { getConfiguredNodeExecutable } from '../../shared/nodeExecutableSetting'; +import { + getConfiguredNodeExecutable, + getConfiguredPathSetting, +} from '../../shared/nodeExecutableSetting'; import { NotInstalledEpisode, formatNotInstalledLog, @@ -28,10 +31,13 @@ import { NodePreflightError, resolveUserNodeOnce, } from '../../shared/nodeResolution'; +import { readPackageJson } from '../../shared/packageResolve'; import { - findPackageJsonUncached, - readPackageJson, -} from '../../shared/packageResolve'; + RSTACK_PACKAGE_PATH_HINT, + RSTACK_PACKAGE_PATH_SETTING, + resolveRstackPackageJson, + rstackResolveHookArgs, +} from '../../shared/rstackPackage'; import { checkPackageVersion, formatVersionMismatch, @@ -363,19 +369,38 @@ class FmtFolderRuntime { this.#closing = false; this.setState('starting'); - const pkgJsonPath = findPackageJsonUncached('rstack', folderRoot); - if (!pkgJsonPath) { + const configured = getConfiguredPathSetting( + RSTACK_PACKAGE_PATH_SETTING, + this.folder, + ); + const rstackPackage = resolveRstackPackageJson(folderRoot, configured); + if (rstackPackage.kind === 'invalid-setting') { + // A wrong setting is fixed in the setting, not by an install, so it is + // `crashed` rather than the not-installed `disabled`. + this.setState('crashed', rstackPackage.message); + if (this.#packageEpisode.changed(rstackPackage.message)) { + context.output.error(rstackPackage.message); + } + return; + } + if (rstackPackage.kind === 'not-installed') { // The shell polls while this state remains disabled. The trailing restart // hint stays as the explicit fallback if recovery is delayed. this.setState('disabled', formatNotInstalledStatus('fmt', 'rstack')); if (this.#packageEpisode.changed(folderRoot)) { context.output.warn( - formatNotInstalledLog('rstack', this.folder.name, folderRoot), + formatNotInstalledLog( + 'rstack', + this.folder.name, + folderRoot, + RSTACK_PACKAGE_PATH_HINT, + ), ); } return; } this.#packageEpisode.clear(); + const pkgJsonPath = rstackPackage.packageJsonPath; // One read for the version and the bin entry; `readPackageJson` re-reads // from disk by design, so a reinstall is picked up on the next start. @@ -401,7 +426,15 @@ class FmtFolderRuntime { const owner = new LanguageServerProcessOwner( nodeExecutable, - [rsBinJs, 'fmt', '--lsp'], + [ + ...rstackResolveHookArgs( + path.dirname(pkgJsonPath), + configured !== undefined, + ), + rsBinJs, + 'fmt', + '--lsp', + ], folderRoot, ); this.#owner = owner; @@ -651,10 +684,14 @@ class FmtFolderRuntime { class FmtController implements StackController { readonly id = 'fmt' as const; /** - * The Node runtime is chosen once per server start, so a changed pin can only - * reach a live server through a rebuild of this controller. + * The Node runtime and the `rstack` package are chosen once per server + * start, so a changed pin can only reach a live server through a rebuild of + * this controller. */ - readonly restartOnSettings = [NODE_EXECUTABLE_SETTING]; + readonly restartOnSettings = [ + NODE_EXECUTABLE_SETTING, + RSTACK_PACKAGE_PATH_SETTING, + ]; #context: StackContext | undefined; /** The newest detection result, which `running` reports the reason from. */ diff --git a/packages/vscode/src/stacks/lint/CoreResolver.ts b/packages/vscode/src/stacks/lint/CoreResolver.ts index d499625..151f867 100644 --- a/packages/vscode/src/stacks/lint/CoreResolver.ts +++ b/packages/vscode/src/stacks/lint/CoreResolver.ts @@ -55,6 +55,8 @@ export interface CoreInstallation { readonly shimPath?: string; readonly rstackDirectory?: string; readonly rstackVersion?: string; + /** `rstack.rstackCli.packagePath` chose rstack, so the worker needs the hook. */ + readonly rstackFromSetting?: boolean; } export interface ResolvedCoreRuntime { @@ -68,6 +70,8 @@ export interface CoreResolutionRequest { readonly mode: RslintMode; /** `rstack.rslint.corePath`, read with the document's URI (resource scope). */ readonly corePath?: string; + /** `rstack.rstackCli.packagePath`; bridged mode only. */ + readonly rstackPackagePath?: string; } function normalizeIdentity(filePath: string): string { @@ -93,6 +97,7 @@ export function runtimeKey( function validateInstallation( identity: string, resolution: RslintResolution, + rstackFromSetting: boolean, ): CoreInstallation { if (resolution.mode === 'bridged') { const rstackCheck = checkPackageVersion('rstack', resolution.rstackVersion); @@ -119,6 +124,7 @@ function validateInstallation( shimPath: resolution.shimPath, rstackDirectory: resolution.rstackDir, rstackVersion: resolution.rstackVersion, + rstackFromSetting, }), }; } @@ -140,11 +146,13 @@ export class CoreResolver { folderRoot: workspaceFolder.uri.fsPath, mode: request.mode, corePath: request.corePath, + rstackPackagePath: request.rstackPackagePath, documentDirectory: path.dirname(document.uri.fsPath), }); const installation = validateInstallation( normalizeIdentity(resolution.coreDir), resolution, + request.rstackPackagePath !== undefined, ); return { key: runtimeKey(workspaceFolder, installation), diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index 119ee55..025a319 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -35,6 +35,7 @@ import { resolveUserNodeOnce, } from '../../shared/nodeResolution'; import { getConfiguredNodeExecutable } from '../../shared/nodeExecutableSetting'; +import { rstackResolveHookArgs } from '../../shared/rstackPackage'; import type { StackState } from '../../types'; import type { CoreInstallation } from './CoreResolver'; import { LanguageServerProcessOwner } from './LanguageServerProcessOwner'; @@ -482,7 +483,17 @@ export class Rslint implements Disposable { const nodeExecutable = await this.resolveNodeExecutable(); this.assertStartCurrent(epoch, signal); const workerPath = path.resolve(__dirname, 'lint-worker.js'); - const workerArgs = [workerPath, '--lsp', '--core', packageDirectory]; + const workerArgs = [ + // Adaptation 2: the bridged worker's shim loads `rstack.config.*`. + ...rstackResolveHookArgs( + this.installation.rstackDirectory, + this.installation.rstackFromSetting, + ), + workerPath, + '--lsp', + '--core', + packageDirectory, + ]; if (shimPath !== undefined) { workerArgs.push('--config', shimPath); } diff --git a/packages/vscode/src/stacks/lint/RuntimeManager.ts b/packages/vscode/src/stacks/lint/RuntimeManager.ts index 5b55ce2..864357e 100644 --- a/packages/vscode/src/stacks/lint/RuntimeManager.ts +++ b/packages/vscode/src/stacks/lint/RuntimeManager.ts @@ -8,7 +8,8 @@ // - Detection is the gate. A document no detected config governs is never // linted, so the manager asks `documentMode` for the document's ownership // choice (ADR 0006) and detaches the document when it has none. -// - Settings live under `rstack.rslint.*`. +// - Settings live under `rstack.rslint.*`, plus the shared +// `rstack.rstackCli.packagePath`, which bridged mode reads to locate rstack. // - A failed resolution reports through the **folder status**, never a // `window.showWarningMessage`: this stack owns no UI chrome (adaptation 4). // The last-good rule is upstream's — the document keeps the runtime it @@ -20,6 +21,8 @@ // stopped same-key clients on reconcile — see AGENTS.md ("Ahead of upstream"). import { workspace, type TextDocument, type WorkspaceFolder } from 'vscode'; +import { getConfiguredPathSetting } from '../../shared/nodeExecutableSetting'; +import { RSTACK_PACKAGE_PATH_SETTING } from '../../shared/rstackPackage'; import type { CoreResolutionRequest, ResolvedCoreRuntime, @@ -243,6 +246,10 @@ export class RuntimeManager { const resolved = await this.resolver.resolve(document, workspaceFolder, { mode, corePath: configuration.get('corePath'), + rstackPackagePath: getConfiguredPathSetting( + RSTACK_PACKAGE_PATH_SETTING, + workspaceFolder, + ), }); return { action: 'bind', workspaceFolder, resolved }; } catch (error) { diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index 65fb8cc..ead524a 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -8,6 +8,10 @@ import type { } from '../../types'; import { NODE_EXECUTABLE_SETTING } from '../../shared/nodeResolution'; import { formatNotInstalledLog } from '../../shared/notInstalled'; +import { + RSTACK_PACKAGE_PATH_HINT, + RSTACK_PACKAGE_PATH_SETTING, +} from '../../shared/rstackPackage'; import { CoreResolver, type ResolvedCoreRuntime } from './CoreResolver'; import { Logger } from './logger'; import { Rslint } from './Rslint'; @@ -59,7 +63,11 @@ class RslintController implements StackController { * full restart pass (a stack must never rebuild itself), which also clears * the shared User Node preflight memo — an in-place reconcile would keep it. */ - readonly restartOnSettings = [NODE_EXECUTABLE_SETTING, 'corePath']; + readonly restartOnSettings = [ + NODE_EXECUTABLE_SETTING, + RSTACK_PACKAGE_PATH_SETTING, + 'corePath', + ]; #context: StackContext | undefined; #logger: Logger | undefined; @@ -215,7 +223,9 @@ class RslintController implements StackController { missing, workspaceFolder.name, workspaceFolder.uri.fsPath, - `${document.uri} ${keeping ? `keeps ${keeping}` : 'will not lint'} until it is installed`, + `${document.uri} ${keeping ? `keeps ${keeping}` : 'will not lint'} until it is installed${ + missing === 'rstack' ? `; ${RSTACK_PACKAGE_PATH_HINT}` : '' + }`, ); logger.warn(warning); } else { diff --git a/packages/vscode/src/stacks/lint/resolution.ts b/packages/vscode/src/stacks/lint/resolution.ts index cbfc233..83eb36c 100644 --- a/packages/vscode/src/stacks/lint/resolution.ts +++ b/packages/vscode/src/stacks/lint/resolution.ts @@ -4,6 +4,7 @@ import { findPackageJsonUncached, readPackageJson, } from '../../shared/packageResolve'; +import { resolveRstackPackageJson } from '../../shared/rstackPackage'; export type RslintMode = 'native' | 'bridged'; @@ -22,8 +23,8 @@ export type RslintResolutionErrorCode = export class RslintResolutionError extends Error { /** * The package whose absence caused this failure — set only by the - * not-installed throws (`resolveInstalledPackage`), the state the three - * stacks report uniformly (AGENTS.md). `missing-shim` and + * not-installed throws (`resolveInstalledCore`, `resolveRstack`), the + * state the three stacks report uniformly (AGENTS.md). `missing-shim` and * `invalid-package` leave it unset on purpose: there the package is * present and the fix is an upgrade or the setting, not an install. */ @@ -188,26 +189,45 @@ function resolveConfiguredCore( return readPackageLocation('@rslint/core', fs.realpathSync(packageJsonPath)); } -function resolveInstalledPackage( - packageName: 'rstack' | '@rslint/core', - searchRoot: string, - code: Extract, +function resolveRstack( + folderRoot: string, + configured: string | undefined, ): PackageLocation { - const packageJsonPath = findPackageJsonUncached(packageName, searchRoot); + const resolution = resolveRstackPackageJson(folderRoot, configured); + switch (resolution.kind) { + case 'found': + return readPackageLocation('rstack', resolution.packageJsonPath); + case 'not-installed': + throw new RslintResolutionError( + 'missing-rstack', + `Could not resolve rstack from ${folderRoot}`, + { missingPackage: 'rstack' }, + ); + case 'invalid-setting': + // Not `missing-rstack`: like a wrong `corePath`, the fix is the + // setting, so it must not take the not-installed state. + throw new RslintResolutionError('invalid-package', resolution.message); + } +} + +function resolveInstalledCore(searchRoot: string): PackageLocation { + const packageJsonPath = findPackageJsonUncached('@rslint/core', searchRoot); if (packageJsonPath === undefined) { throw new RslintResolutionError( - code, - `Could not resolve ${packageName} from ${searchRoot}`, - { missingPackage: packageName }, + 'missing-core', + `Could not resolve @rslint/core from ${searchRoot}`, + { missingPackage: '@rslint/core' }, ); } - return readPackageLocation(packageName, packageJsonPath); + return readPackageLocation('@rslint/core', packageJsonPath); } export interface ResolveRslintOptions { readonly folderRoot: string; readonly mode: RslintMode; readonly corePath?: string; + /** `rstack.rstackCli.packagePath`; replaces bridged mode's rstack walk-up. */ + readonly rstackPackagePath?: string; /** * Where the native walk-up starts (per-document resolution, rslint #1617); * defaults to the folder root. Bridged mode starts at rstack's directory @@ -221,12 +241,13 @@ export function resolveRslint({ folderRoot, mode, corePath, + rstackPackagePath, documentDirectory, }: ResolveRslintOptions): RslintResolution { let rstack: PackageLocation | undefined; let shimPath: string | undefined; if (mode === 'bridged') { - rstack = resolveInstalledPackage('rstack', folderRoot, 'missing-rstack'); + rstack = resolveRstack(folderRoot, rstackPackagePath); shimPath = path.join(rstack.directory, 'dist', 'rslintConfig.js'); try { if (!fs.statSync(shimPath).isFile()) throw new Error('not a file'); @@ -242,10 +263,8 @@ export function resolveRslint({ const configuredCorePath = corePath?.trim(); const core = configuredCorePath ? resolveConfiguredCore(folderRoot, configuredCorePath) - : resolveInstalledPackage( - '@rslint/core', + : resolveInstalledCore( rstack?.directory ?? documentDirectory ?? folderRoot, - 'missing-core', ); return { diff --git a/packages/vscode/src/stacks/test/bridge.ts b/packages/vscode/src/stacks/test/bridge.ts index 66e457e..11051e9 100644 --- a/packages/vscode/src/stacks/test/bridge.ts +++ b/packages/vscode/src/stacks/test/bridge.ts @@ -1,6 +1,10 @@ import { existsSync } from 'node:fs'; import path from 'node:path'; -import { findPackageJsonUncached } from '../../shared/packageResolve'; +import { + RSTACK_PACKAGE_PATH_HINT, + type RstackPackageResolution, + resolveRstackPackageJson, +} from '../../shared/rstackPackage'; import { checkPackageVersion, formatVersionMismatch, @@ -74,22 +78,39 @@ export type RstackShim = { * * Resolution is anchored on the config directory rather than the workspace * folder so a monorepo package with its own `rstack` install wins over the root. + * A set `rstack.rstackCli.packagePath` replaces that walk-up. */ export function resolveRstackShim( configDir: string, - // The caller re-resolves on every tree refresh (a fresh `pnpm install` has to - // be picked up without a reload), so a persistent failure must not re-log. - { silent = false }: { silent?: boolean } = {}, + { + configured, + // The caller re-resolves on every tree refresh (a fresh `pnpm install` has + // to be picked up without a reload), so a persistent failure must not + // re-log. + silent = false, + }: { + /** The resolution of a set `rstack.rstackCli.packagePath`. */ + configured?: RstackPackageResolution; + silent?: boolean; + } = {}, ): RstackShim | undefined { - const packageJsonPath = findPackageJsonUncached('rstack', configDir); - if (packageJsonPath === undefined) { + const resolution = configured ?? resolveRstackPackageJson(configDir); + if (resolution.kind === 'invalid-setting') { + // Not the not-installed state: the fix is the setting, not an install. + if (!silent) { + logger.error(resolution.message); + } + status.crashed(resolution.message, configDir); + return undefined; + } + if (resolution.kind === 'not-installed') { if (!silent) { logger.warn( formatNotInstalledLog( 'rstack', path.basename(configDir), configDir, - 'Rstest cannot be driven by "rstack.config.*" until it is installed', + `Rstest cannot be driven by "rstack.config.*" until it is installed; ${RSTACK_PACKAGE_PATH_HINT}`, ), ); } @@ -100,6 +121,7 @@ export function resolveRstackShim( status.notInstalled(RSTACK_NOT_INSTALLED_STATUS, configDir); return undefined; } + const packageJsonPath = resolution.packageJsonPath; const packageDirectory = path.dirname(packageJsonPath); const configFilePath = path.join(packageDirectory, SHIM_RELATIVE_PATH); @@ -135,9 +157,9 @@ export function resolveRstackShim( packageDirectory, version, }); - // One recovery observation: a passed version check proves the package is - // installed, so `versionOk` retires whichever of the two latches the - // previous pass left. - status.versionOk(configDir); + // One recovery observation: the shim resolved completely, so `resolved` + // retires whatever latch the previous pass left here, an invalid-setting + // crash included. + status.resolved(configDir); return { configFilePath, packageDirectory, version }; } diff --git a/packages/vscode/src/stacks/test/index.ts b/packages/vscode/src/stacks/test/index.ts index f185926..e5c2254 100644 --- a/packages/vscode/src/stacks/test/index.ts +++ b/packages/vscode/src/stacks/test/index.ts @@ -15,6 +15,7 @@ import { } from './master'; import { quoteFilter } from './vendored/coreInternals'; import { NODE_EXECUTABLE_SETTING } from '../../shared/nodeResolution'; +import { RSTACK_PACKAGE_PATH_SETTING } from '../../shared/rstackPackage'; import { Project, WorkspaceManager } from './project'; import { routeToOwners } from './runRouting'; import { status } from './status'; @@ -560,7 +561,10 @@ class RstestController implements StackController { // qualifies: installing a newer Node on the machine is not observable by the // extension and stays a manual restart, the deliberate half of the deal (see // `docs/adr/0001-node-runtime-selection.md`). - readonly restartOnSettings = [NODE_EXECUTABLE_SETTING]; + readonly restartOnSettings = [ + NODE_EXECUTABLE_SETTING, + RSTACK_PACKAGE_PATH_SETTING, + ]; #rstest: Rstest | undefined; diff --git a/packages/vscode/src/stacks/test/master.ts b/packages/vscode/src/stacks/test/master.ts index 433b324..4c2e576 100644 --- a/packages/vscode/src/stacks/test/master.ts +++ b/packages/vscode/src/stacks/test/master.ts @@ -20,6 +20,7 @@ import { import { CONFIG_SECTION, getConfigValue } from './config'; import { createDebugWorker } from './debugWorker'; import { MessageLatch } from '../../shared/messageLatch'; +import { rstackResolveHookArgs } from '../../shared/rstackPackage'; import { formatNotInstalledLog, formatNotInstalledStatus, @@ -223,7 +224,8 @@ export class RstestApi { } // The node executable + exec args honoring the shared `rstack.nodeExecutable` - // and the stack's `nodeExecArgs` settings (`${workspaceFolder}` expanded). + // and the stack's `nodeExecArgs` settings (`${workspaceFolder}` expanded), + // plus the bridge's rstack resolve hook. // Used verbatim by the terminal CLI, which deliberately skips the worker // preflight below: the command runs inside the user's own shell, which is the // very thing the preflight exists to emulate. `configured` reports whether @@ -238,9 +240,16 @@ export class RstestApi { return { nodeExecutable: configuredExecutable ?? 'node', configured: configuredExecutable !== undefined, - nodeExecArgs: getConfigValue('nodeExecArgs', this.workspace).map((arg) => - this.expandWorkspaceFolder(arg), - ), + nodeExecArgs: [ + ...getConfigValue('nodeExecArgs', this.workspace).map((arg) => + this.expandWorkspaceFolder(arg), + ), + // Adaptation 2: the bridge's shim loads `rstack.config.*`. + ...rstackResolveHookArgs( + this.rstestResolutionDir, + this.project.source.rstackFromSetting, + ), + ], }; } diff --git a/packages/vscode/src/stacks/test/project.ts b/packages/vscode/src/stacks/test/project.ts index 00c70dc..2428582 100644 --- a/packages/vscode/src/stacks/test/project.ts +++ b/packages/vscode/src/stacks/test/project.ts @@ -5,6 +5,11 @@ import picomatch from 'picomatch'; import { glob } from 'tinyglobby'; import vscode from 'vscode'; import { RSTACK_CONFIG_NAMES } from '../../detection'; +import { getConfiguredPathSetting } from '../../shared/nodeExecutableSetting'; +import { + RSTACK_PACKAGE_PATH_SETTING, + resolveRstackPackageJson, +} from '../../shared/rstackPackage'; import { resolveRstackShim } from './bridge'; import { watchConfigValue } from './config'; import { @@ -75,6 +80,8 @@ export type ProjectSource = { readonly rstestResolutionDir?: string; /** True for a `Project` synthesized by the rstack bridge. */ readonly isBridge?: boolean; + /** `rstack.rstackCli.packagePath` chose rstack, so workers need the hook. */ + readonly rstackFromSetting?: boolean; }; export class WorkspaceManager implements vscode.Disposable { @@ -336,11 +343,24 @@ export class WorkspaceManager implements vscode.Disposable { const candidateDirs = new Set(); const wanted = new Map(); + // A set `rstack.rstackCli.packagePath` is one package for every bridge. + const configuredPath = getConfiguredPathSetting( + RSTACK_PACKAGE_PATH_SETTING, + this.workspaceFolder, + ); + const configuredRstack = + configuredPath === undefined + ? undefined + : resolveRstackPackageJson( + this.workspaceFolder.uri.fsPath, + configuredPath, + ); for (const [cwd, rstackConfig] of winnerByDir) { const key = rstackConfig.toString(); if (nativeProjectDirs.has(cwd)) continue; candidateDirs.add(cwd); const shim = resolveRstackShim(cwd, { + configured: configuredRstack, silent: this.reportedShimFailures.has(cwd), }); if (!shim) { @@ -371,6 +391,7 @@ export class WorkspaceManager implements vscode.Disposable { cwd, rstestResolutionDir: shim.packageDirectory, isBridge: true, + rstackFromSetting: configuredRstack !== undefined, }); logger.info( `Driving Rstest from ${rstackConfig.fsPath} through the rstack config shim`, diff --git a/packages/vscode/src/stacks/test/status.ts b/packages/vscode/src/stacks/test/status.ts index 5d54db5..c84e857 100644 --- a/packages/vscode/src/stacks/test/status.ts +++ b/packages/vscode/src/stacks/test/status.ts @@ -39,8 +39,9 @@ class StatusHolder implements StatusReporter { // A recovery observed under one key must not clear another key's // live failure. An entry is cleared by the code path that observes the // corresponding recovery (a worker that actually spawned, a version check - // that passed) or by `forget` when its reporter goes away — neither of which - // reaches a host-scoped entry, since both are per-resolution-root. A + // that passed, a bridge resolution that succeeded) or by `forget` when its + // reporter goes away — neither of which reaches a host-scoped entry, since + // both are per-resolution-root. A // host-scoped latch is cleared only by `bind`, i.e. by re-registering the // stack, which is exactly the lifetime of the resolution it reports on. #crashes = new Map(); @@ -190,6 +191,16 @@ class StatusHolder implements StatusReporter { if (hadMismatch || hadNotInstalled) this.#paintOrRun(); } + /** + * A bridge shim resolved completely under its config directory: every latch + * there is over, a crash included. Workers report under their project URI, + * so the only crash under that key is the resolution's own invalid + * `rstack.rstackCli.packagePath`, a package-state fact like the other two. + */ + resolved(source: string): void { + this.forget(source); + } + /** A root's dependencies are not installed: `disabled`, with the way out. */ notInstalled(reason: string, source = ''): void { this.#observePackageState(this.#notInstalled, reason, source); diff --git a/packages/vscode/tests/extension.test.ts b/packages/vscode/tests/extension.test.ts index 2342c32..333a6c9 100644 --- a/packages/vscode/tests/extension.test.ts +++ b/packages/vscode/tests/extension.test.ts @@ -549,6 +549,7 @@ describe('the extension manifest', () => { expect(settings['rstack.fmt.trace.server']?.scope).toBe('window'); expect(settings['rstack.rslint.corePath']?.scope).toBe('resource'); expect(settings['rstack.rstest.corePath']?.scope).toBe('resource'); + expect(settings['rstack.rstackCli.packagePath']?.scope).toBe('resource'); }); }); diff --git a/packages/vscode/tests/shared/rstackPackage.test.ts b/packages/vscode/tests/shared/rstackPackage.test.ts new file mode 100644 index 0000000..8f73688 --- /dev/null +++ b/packages/vscode/tests/shared/rstackPackage.test.ts @@ -0,0 +1,110 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from '@rstest/core'; +import { resolveRstackPackageJson } from '../../src/shared/rstackPackage'; +import { + installPackage, + removeTemporaryDirectories, + temporaryDirectory, + writePackage, +} from '../stacks/lint/packageFixtures'; + +afterEach(removeTemporaryDirectories); + +describe('resolveRstackPackageJson', () => { + it.each([ + 'infra/node_modules/rstack', + 'infra/node_modules/rstack/package.json', + ])( + 'resolves a relative setting from the folder root, outside the walk-up (%s)', + (configured) => { + // The #103 layout: rstack only under `infra/`, invisible to a walk-up + // from the folder root. + const root = temporaryDirectory(); + const rstackDir = installPackage( + path.join(root, 'infra'), + 'rstack', + '0.8.4', + ); + + expect(resolveRstackPackageJson(root, configured)).toEqual({ + kind: 'found', + packageJsonPath: path.join(rstackDir, 'package.json'), + }); + }, + ); + + it('wins over an installation the walk-up would find', () => { + const root = temporaryDirectory(); + installPackage(root, 'rstack', '0.8.3'); + const pinned = writePackage( + path.join(root, 'tools', 'rstack'), + 'rstack', + '0.8.4', + ); + + expect(resolveRstackPackageJson(root, pinned)).toEqual({ + kind: 'found', + packageJsonPath: path.join(pinned, 'package.json'), + }); + }); + + it('returns the realpath, so a pnpm store layout keeps its siblings', () => { + // Lint and test resolve their cores from the rstack directory; through + // the `node_modules/rstack` symlink they would miss rstack's dependencies, + // which pnpm places beside the store path. + const root = temporaryDirectory(); + const storeDir = writePackage( + path.join( + root, + 'node_modules', + '.pnpm', + 'rstack@0.8.4', + 'node_modules', + 'rstack', + ), + 'rstack', + '0.8.4', + ); + const link = path.join(root, 'infra', 'node_modules', 'rstack'); + fs.mkdirSync(path.dirname(link), { recursive: true }); + fs.symlinkSync(storeDir, link, 'junction'); + + expect(resolveRstackPackageJson(root, 'infra/node_modules/rstack')).toEqual( + { + kind: 'found', + packageJsonPath: path.join(storeDir, 'package.json'), + }, + ); + }); + + it('treats a missing path as a setting error, not a missing install', () => { + const root = temporaryDirectory(); + // An installation the walk-up would find must not mask the mistake. + installPackage(root, 'rstack', '0.8.4'); + + const resolution = resolveRstackPackageJson( + root, + 'infra/node_modules/rstack', + ); + + expect(resolution.kind).toBe('invalid-setting'); + const message = resolution.kind === 'invalid-setting' && resolution.message; + expect(message).toContain('rstack.rstackCli.packagePath'); + expect(message).toContain( + path.join(root, 'infra', 'node_modules', 'rstack', 'package.json'), + ); + }); + + it('rejects a package that is not rstack', () => { + const root = temporaryDirectory(); + const coreDir = installPackage(root, '@rslint/core', '0.9.5'); + + const resolution = resolveRstackPackageJson(root, coreDir); + + expect(resolution.kind).toBe('invalid-setting'); + expect( + resolution.kind === 'invalid-setting' && resolution.message, + ).toContain('is not the "rstack" package'); + }); +}); diff --git a/packages/vscode/tests/stacks/fmt/runtime.test.ts b/packages/vscode/tests/stacks/fmt/runtime.test.ts index faf1b58..29bbeb6 100644 --- a/packages/vscode/tests/stacks/fmt/runtime.test.ts +++ b/packages/vscode/tests/stacks/fmt/runtime.test.ts @@ -36,6 +36,7 @@ rs.mock('../../../src/detection', () => ({ })); rs.mock('../../../src/shared/nodeExecutableSetting', () => ({ getConfiguredNodeExecutable: () => undefined, + getConfiguredPathSetting: () => undefined, })); rs.mock('../../../src/shared/nodeResolution', () => ({ resolveUserNodeOnce: async () => ({ executable: 'node' }), diff --git a/packages/vscode/tests/stacks/lint/resolution.test.ts b/packages/vscode/tests/stacks/lint/resolution.test.ts index a1635ad..b77c2b4 100644 --- a/packages/vscode/tests/stacks/lint/resolution.test.ts +++ b/packages/vscode/tests/stacks/lint/resolution.test.ts @@ -68,6 +68,74 @@ describe('resolveRslint', () => { }, ); + it('follows a configured rstack outside the walk-up for a bridged runtime', () => { + // The #103 layout: rstack only under `infra/`. The core hop starts from + // the configured rstack directory, so rstack's own `@rslint/core` wins. + const root = temporaryDirectory(); + const rstackDir = installPackage( + path.join(root, 'infra'), + 'rstack', + '0.8.4', + ); + const coreDir = installPackage(rstackDir, '@rslint/core', '0.9.5'); + const shimPath = installShim(rstackDir); + + expect(() => resolveRslint({ folderRoot: root, mode: 'bridged' })).toThrow( + 'Could not resolve rstack', + ); + expect( + resolveRslint({ + folderRoot: root, + mode: 'bridged', + rstackPackagePath: 'infra/node_modules/rstack', + }), + ).toEqual({ + mode: 'bridged', + coreDir, + coreVersion: '0.9.5', + rstackDir, + rstackVersion: '0.8.4', + shimPath, + }); + }); + + it('reports a configured rstack that does not resolve as a setting error', () => { + const root = temporaryDirectory(); + // A rstack the walk-up would find must not mask the wrong setting. + installShim(installPackage(root, 'rstack', '0.8.4')); + + let thrown: unknown; + try { + resolveRslint({ + folderRoot: root, + mode: 'bridged', + rstackPackagePath: 'infra/node_modules/rstack', + }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(RslintResolutionError); + // `invalid-package` without `missingPackage`: not the not-installed state. + expect(thrown).toMatchObject({ + code: 'invalid-package', + missingPackage: undefined, + }); + expect((thrown as Error).message).toContain('rstack.rstackCli.packagePath'); + }); + + it('ignores the rstack setting in native mode', () => { + const root = temporaryDirectory(); + const coreDir = installPackage(root, '@rslint/core', '0.8.0'); + + expect( + resolveRslint({ + folderRoot: root, + mode: 'native', + rstackPackagePath: 'missing', + }).coreDir, + ).toBe(coreDir); + }); + it('reports an invalid configured path', () => { const corePath = 'missing-core'; const root = temporaryDirectory(); diff --git a/packages/vscode/tests/stacks/test/bridge.test.ts b/packages/vscode/tests/stacks/test/bridge.test.ts index ce7c666..4b7ec53 100644 --- a/packages/vscode/tests/stacks/test/bridge.test.ts +++ b/packages/vscode/tests/stacks/test/bridge.test.ts @@ -2,6 +2,7 @@ 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 { resolveRstackPackageJson } from '../../../src/shared/rstackPackage'; import { resolveRstackShim } from '../../../src/stacks/test/bridge'; import { logger } from '../../../src/stacks/test/logger'; import { status } from '../../../src/stacks/test/status'; @@ -118,6 +119,8 @@ describe('resolveRstackShim', () => { expect(resolveRstackShim(root)).toBeUndefined(); // The shared not-installed wording, with the bridge's own consequence. expect(logged.join('\n')).toContain('rstack is not installed'); + // The way out for an install the walk-up cannot see (#103). + expect(logged.join('\n')).toContain('set "rstack.rstackCli.packagePath"'); expect(logged.join('\n')).toContain('Rstest cannot be driven'); // The uniform not-installed policy: the same `disabled` shape fmt and // lint report, with the restart command as the way out. @@ -152,6 +155,45 @@ describe('resolveRstackShim', () => { expect(logged).toEqual([]); }); + it('reports a configured path that does not resolve as a crash, not a missing install', () => { + const root = makeTmpDir(); + + expect( + resolveRstackShim(root, { + configured: resolveRstackPackageJson(root, 'infra/node_modules/rstack'), + }), + ).toBeUndefined(); + expect(logged.join('\n')).toContain('rstack.rstackCli.packagePath'); + expect(reported.map((state) => state.kind)).toEqual(['crashed']); + }); + + it('retires the setting crash once the configured path resolves', () => { + const root = makeTmpDir(); + const configured = 'infra/node_modules/rstack'; + resolveRstackShim(root, { + configured: resolveRstackPackageJson(root, configured), + }); + expect(status.hasFailed(root)).toBe(true); + + // The path appears without a settings edit (e.g. `pnpm install`); the + // next sync pass re-resolves it. + const rstackDir = path.join(root, configured); + fs.mkdirSync(path.join(rstackDir, 'dist'), { recursive: true }); + fs.writeFileSync( + path.join(rstackDir, 'package.json'), + JSON.stringify({ name: 'rstack', version: '0.7.6' }), + ); + fs.writeFileSync(path.join(rstackDir, 'dist', 'rstestConfig.js'), ''); + + expect( + resolveRstackShim(root, { + configured: resolveRstackPackageJson(root, configured), + }), + ).toBeDefined(); + expect(status.hasFailed(root)).toBe(false); + expect(reported.map((state) => state.kind)).toEqual(['crashed', 'running']); + }); + it('refuses an rstack install that ships no Rstest shim', () => { const configDir = createWorkspace({ shim: false }); diff --git a/packages/vscode/tests/stacks/test/master.test.ts b/packages/vscode/tests/stacks/test/master.test.ts index 00a098e..06bd95f 100644 --- a/packages/vscode/tests/stacks/test/master.test.ts +++ b/packages/vscode/tests/stacks/test/master.test.ts @@ -193,7 +193,10 @@ const createApi = (cwd = noCoreDir, rstestResolutionDir = cwd) => { const workspace = { uri: { fsPath: cwd } }; // `sourceUri` backs the per-project status latch key, which the version // check on the spawn path reads before anything can fail. - const project = { sourceUri: { toString: () => `test://${cwd}` } }; + const project = { + sourceUri: { toString: () => `test://${cwd}` }, + source: {}, + }; return new RstestApi( workspace as any, cwd,