Skip to content
Merged
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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ Rstack Editor provides unified editor support for [Rstack](https://rstack.rs), t
| --- | --- |
| [`packages/vscode`](./packages/vscode) | The VS Code extension (`rstack.rstack`) |

## Roadmap

The extension takes its configuration from five sources. The tool-native configs are fully supported today; support for driving a stack from `rstack.config.*` is landing one stack at a time.

| Config source | Status |
| --- | --- |
| `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.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

Rstack Editor is licensed under the [MIT License](./LICENSE).
3 changes: 3 additions & 0 deletions packages/vscode/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten

- One stack failing to register or crashing must never take another stack (or the shell) down.
- The shell always activates; per-folder config detection decides which stacks start, and re-runs on config/lockfile changes without a window reload. Enable-settings are coarse kill switches only.
- Reconciles and restarts share one serialized queue (`enqueue`); a reconcile leaves a live stack alone, so the restart commands are the only path that rebuilds one. Do not add a second queue.
- Restart is a shell concern, not a stack one: `rstack.restart` rebuilds every controller, `rstack.<stack>.restart` rebuilds one. A stack must never register its own restart command — a shallower "bounce the tool's process" restart keeps that controller's stale package resolution and version check, which is the bug the command exists to clear.
- Deprecated `rslint.json` / `rslint.jsonc` are unsupported by decision, not omission — never make them detection signals.
- Never share a child process across stacks: the tools have incompatible cwd semantics (lint LSP anchors on spawn cwd; test worker pins to project root; `rs fmt` resolves config from spawn cwd with no upward walk).
- In Restricted Mode (workspace trust), only the status bar runs — no process spawns, no project code loaded.
Expand All @@ -30,6 +32,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.
- `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.

## Testing
Expand Down
4 changes: 3 additions & 1 deletion packages/vscode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ The extension activates on startup, then decides **per workspace folder** which
| Rstest | `rstest.config.{mjs,ts,js,cjs,mts,cts}` (configurable) or `rstack.config.*` |
| rstack-cli | `rstack.config.*` or `node_modules/.bin/rs` |

Config files and lockfiles are watched, so detection re-runs without a window reload. Deprecated `rslint.json` / `rslint.jsonc` configs are **not** detection signals — migrate them with `rslint --init`.
Config files and lockfiles are watched, so detection re-runs without a window reload. When something changes that none of those files record — a reinstall that leaves the lockfile untouched, or a `node_modules` that ends up broken — run **Rstack: Relaunch Extension** from the Command Palette (also on the status bar hover) to tear every tool down and start over. To rebuild a single tool, use **Rstack: Restart Rslint** / **Restart Rstest** / **Restart rs fmt**.

A restart re-resolves every binary and package version and respawns every tool process, but it cannot reload JavaScript the editor has already imported from your project — Node keeps those modules for the lifetime of the window. If a reinstall replaced `@rslint/core` in place and lint still behaves like the old version, reload the window. Deprecated `rslint.json` / `rslint.jsonc` configs are **not** detection signals — migrate them with `rslint --init`.

## Supported package versions

Expand Down
33 changes: 27 additions & 6 deletions packages/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,17 @@
},
"contributes": {
"commands": [
{
"command": "rstack.showMenu",
"title": "Show Menu",
"category": "Rstack"
},
{
"command": "rstack.showOutput",
"title": "Show Extension Log",
"category": "Rstack"
},
{
"command": "rstack.restart",
"title": "Relaunch Extension",
"category": "Rstack",
"icon": "$(debug-restart)"
},
{
"command": "rstack.migrateSettings",
"title": "Migrate Rslint/Rstest Settings",
Expand All @@ -65,7 +66,7 @@
},
{
"command": "rstack.rslint.restart",
"title": "Restart Rslint Language Server",
"title": "Restart Rslint",
"category": "Rstack",
"icon": "$(refresh)"
},
Expand All @@ -74,6 +75,12 @@
"title": "Show Rstest Log",
"category": "Rstack"
},
{
"command": "rstack.rstest.restart",
"title": "Restart Rstest",
"category": "Rstack",
"icon": "$(refresh)"
},
{
"command": "rstack.rstest.updateSnapshot",
"title": "Update Snapshot",
Expand Down Expand Up @@ -107,6 +114,12 @@
"command": "rstack.fmt.output.focus",
"title": "Show rs fmt Log",
"category": "Rstack"
},
{
"command": "rstack.fmt.restart",
"title": "Restart rs fmt",
"category": "Rstack",
"icon": "$(refresh)"
}
],
"configuration": [
Expand Down Expand Up @@ -347,10 +360,18 @@
"command": "rstack.rstest.output.focus",
"when": "rstack.rstest.active"
},
{
"command": "rstack.rstest.restart",
"when": "rstack.rstest.active"
},
{
"command": "rstack.fmt.output.focus",
"when": "rstack.fmt.active"
},
{
"command": "rstack.fmt.restart",
"when": "rstack.fmt.active"
},
{
"command": "rstack.rstest.updateSnapshot",
"when": "false"
Expand Down
8 changes: 8 additions & 0 deletions packages/vscode/rstest.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,13 @@ export default defineConfig({
externals: {
vscode: 'commonjs vscode',
},
// An externalized dependency is imported by the chunk itself, so it loads
// before any `rs.mock` can intervene — and `vscode-languageclient/node`
// does a bare `require('vscode')` at load time, which no mock can serve
// from plain Node. Bundling it routes that require through the bundler,
// where the `vscode` external (and therefore the mock) applies. This is
// what lets a test import the shell, whose module graph reaches the Rslint
// stack.
bundleDependencies: ['vscode-languageclient'],
},
});
77 changes: 75 additions & 2 deletions packages/vscode/src/detection.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,51 @@
import { describe, expect, it, rs } from '@rstest/core';
import type vscode from 'vscode';
import type { DetectionSnapshot } from './types';

// `detection.ts` imports the `vscode` namespace for the watcher/`findFiles`
// paths. `detectionWatchPatterns` is pure, but the module still has to load, so
// the namespace is stubbed away: unit tests run in plain Node, with no
// extension host (unit tests are Rstest, E2E is Electron).
// extension host (unit tests are Rstest, E2E is Electron). The stub carries
// exactly what `DetectionService` touches with no workspace folder open — its
// event plumbing — so the notification rules can be exercised here too.
rs.mock('vscode', () => {
const vscode = {};
class EventEmitter {
readonly #listeners = new Set<(value: unknown) => void>();
readonly event = (listener: (value: unknown) => void) => {
this.#listeners.add(listener);
return {
dispose: () => {
this.#listeners.delete(listener);
},
};
};
fire(value: unknown): void {
for (const listener of [...this.#listeners]) {
listener(value);
}
}
dispose(): void {
this.#listeners.clear();
}
}
const disposable = { dispose: () => undefined };
const vscode = {
EventEmitter,
workspace: {
// No folder is open: every pass produces the empty snapshot, so the
// detection signature is unchanged by construction.
workspaceFolders: undefined,
onDidChangeWorkspaceFolders: () => disposable,
onDidChangeConfiguration: () => disposable,
},
};
return { ...vscode, default: vscode };
});

import {
DEFAULT_RSTEST_CONFIG_GLOBS,
DETECTION_WATCH_NAMES,
DetectionService,
detectionWatchPatterns,
} from './detection';

Expand Down Expand Up @@ -160,3 +194,42 @@ describe('detectionWatchPatterns', () => {
}
});
});

/**
* With no workspace folder open every pass yields the empty snapshot, so the
* detection signature is identical across passes by construction — exactly the
* shape a lockfile write or a `rstack.restart` produces in a real workspace
* whose `node_modules` was replaced without touching a watched file.
*/
describe('DetectionService — notification rules', () => {
const fakeOutput = () =>
({
info: () => undefined,
warn: () => undefined,
error: () => undefined,
}) as unknown as vscode.LogOutputChannel;

const listen = (service: DetectionService) => {
const seen: DetectionSnapshot[] = [];
service.onDidChange((snapshot) => seen.push(snapshot));
return seen;
};

it('stays quiet when the signature did not change', async () => {
const service = new DetectionService(fakeOutput());
const seen = listen(service);
await service.initialize();
await service.refresh();
expect(seen).toHaveLength(0);
service.dispose();
});

it('does not notify after disposal', async () => {
const service = new DetectionService(fakeOutput());
const seen = listen(service);
await service.initialize();
service.dispose();
await service.refresh();
expect(seen).toHaveLength(0);
});
});
11 changes: 8 additions & 3 deletions packages/vscode/src/detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,9 @@ export class DetectionService implements vscode.Disposable {
// out identical while every project-resolved package (Rslint binary, Rstest
// core, the rstack shim) may now resolve differently. Such a pass must
// notify subscribers even when the signature is unchanged, or failed
// resolutions are never retried until a window reload.
// resolutions are never retried until a window reload. Set by the lockfile
// watcher only — a caller that drives the rebuild itself does not need the
// event, it already has the fresh snapshot.
#notifyUnchanged = false;
#watchers: vscode.Disposable[] = [];
#debounce: ReturnType<typeof setTimeout> | undefined;
Expand Down Expand Up @@ -301,12 +303,15 @@ export class DetectionService implements vscode.Disposable {
// Virtual filesystems cannot host a project-local toolchain.
(folder) => folder.uri.scheme === 'file',
);
// Consumed before the first `await`: a pass that rejects (a folder removed
// mid-scan, a filesystem provider erroring) must not leave the flag set for
// an unrelated later pass to act on.
const notifyUnchanged = this.#notifyUnchanged;
this.#notifyUnchanged = false;
const detections = await Promise.all(folders.map(detectFolder));
const snapshot = new Snapshot(detections);
const signature = signatureOf(snapshot);
this.#snapshot = snapshot;
const notifyUnchanged = this.#notifyUnchanged;
this.#notifyUnchanged = false;
if (signature !== this.#signature || notifyUnchanged) {
this.#signature = signature;
this.log(snapshot);
Expand Down
Loading