Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/vscode/.vscodeignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# dist/ — never source maps.
**
!dist/**/*.js
!dist/rstack-resolve-hook.mjs
!dist/**/*.node
!dist/*.licenses.json
!icon.png
Expand Down
2 changes: 1 addition & 1 deletion packages/vscode/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/vscode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`). |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
26.11.1
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
fi3ework marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
@@ -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/*'
Original file line number Diff line number Diff line change
@@ -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/`."
}
Original file line number Diff line number Diff line change
@@ -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'],
});
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
const answer={value:'42'};
Original file line number Diff line number Diff line change
@@ -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');
});
11 changes: 9 additions & 2 deletions packages/vscode/e2e/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
56 changes: 55 additions & 1 deletion packages/vscode/e2e/runTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void> {
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) => {
Expand Down
7 changes: 7 additions & 0 deletions packages/vscode/e2e/setupFixtures.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
3 changes: 3 additions & 0 deletions packages/vscode/e2e/suite-rstack-cli-package-path/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createRun } from '../runSuite';

export const run = createRun(__dirname);
Original file line number Diff line number Diff line change
@@ -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<RstackExtensionExports>('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<string, string>;
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.TextEdit[]>(
'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<string, unknown>
)();
// 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<string, string>;
assert.equal(folderStates()[folder().uri.fsPath], 'disabled');
}, 'the fmt stack to report rstack as not installed');
});
});
Loading
Loading