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
42 changes: 38 additions & 4 deletions editors/vscode/src/feature/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,37 @@ class ContextTreeProvider implements vscode.TreeDataProvider<ContextTreeItem> {
}
}

/** Re-sync an open document with the server (didClose + didOpen) so the
* editor re-requests every language feature — tokens, links, hints — and
* the recompile publishes fresh diagnostics. Used after a context switch:
* the pull-based server only re-targets the session. The language-id
* round-trip is the only stable way to force a full re-sync; buffer
* content and unsaved edits survive it. */
export async function resyncDocument(uri: string) {
const doc = vscode.workspace.textDocuments.find(
(candidate) => candidate.uri.toString() === uri,
);
if (!doc) {
return;
}
const language = doc.languageId;
resyncing.add(uri);
try {
await vscode.languages.setTextDocumentLanguage(doc, "plaintext");
Comment thread
16bit-ykiko marked this conversation as resolved.
await vscode.languages.setTextDocumentLanguage(doc, language);
} catch {
// The document was closed mid-round-trip; nothing left to resync,
// and the caller's UI refresh must still run.
} finally {
resyncing.delete(uri);
}
}

/** Documents mid-resync: their transient plaintext hop must not be
* mistaken by detectCxxFragment for a fragment awaiting detection — the
* detector would race the restore and pin a c/cuda-cpp file to cpp. */
const resyncing = new Set<string>();

export function registerCompilationContext(client: LanguageClient, ext: vscode.ExtensionContext) {
const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
status.command = "clice.switchContext";
Expand Down Expand Up @@ -204,9 +235,12 @@ export function registerCompilationContext(client: LanguageClient, ext: vscode.E
} else if (!switched?.success) {
vscode.window.showWarningMessage("clice: failed to switch compilation context");
} else {
// The switch only marks the session dirty (pull-based server);
// fire a cheap feature request so diagnostics and inactive
// regions refresh without waiting for user interaction.
// The server is pull-based: the switch only re-targets the
// session, and the refresh is the client's job.
await resyncDocument(uri);
Comment thread
16bit-ykiko marked this conversation as resolved.
// Editors with automatic feature pulls disabled stop at the
// reopen (didOpen alone compiles nothing); one cheap explicit
// pull guarantees diagnostics come back regardless.
void vscode.commands.executeCommand(
"vscode.executeDocumentSymbolProvider",
vscode.Uri.parse(uri),
Expand Down Expand Up @@ -279,7 +313,7 @@ export function registerCompilationContext(client: LanguageClient, ext: vscode.E
// by some C++ TU (it has compilation contexts), flip its language so
// the whole toolchain attaches.
async function detectCxxFragment(document: vscode.TextDocument) {
if (document.languageId !== "plaintext") {
if (document.languageId !== "plaintext" || resyncing.has(document.uri.toString())) {
return;
}
if (!/\.(def|inc|inl|tpp|ipp)$/.test(document.uri.fsPath)) {
Expand Down
33 changes: 33 additions & 0 deletions editors/vscode/src/test/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import * as assert from "assert";
import * as path from "path";
import * as vscode from "vscode";

import { resyncDocument } from "../feature/context";

// E2E smoke tests against a real clice binary. The binary path comes from
// CLICE_EXECUTABLE; without it (plain `pnpm test`) the suite is skipped.
// The workspace folder is set by .vscode-test.mjs and selects the scenario.
Expand Down Expand Up @@ -169,6 +171,37 @@ suite("clice E2E", function () {
current.context && current.context.uri.includes("main.cpp"),
"currentContext should report the switched host",
);

// The client contract: after a successful switch the extension
// re-syncs the document (didClose + didOpen) so every feature
// refreshes. A fresh diagnostics publish is the proof the
// round-trip reached the server — currentContext alone would pass
// without any reopen (it reads the persisted choice directly).
const diagnosticsChanged = new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
subscription.dispose();
reject(new Error("no diagnostics publish after resync"));
}, 30 * 1000);
const subscription = vscode.languages.onDidChangeDiagnostics((event) => {
if (event.uris.some((changed) => changed.toString() === uri)) {
clearTimeout(timer);
subscription.dispose();
resolve();
}
});
});
await resyncDocument(uri);
await diagnosticsChanged;
assert.strictEqual(
document.languageId,
"cpp",
"language id restored after the resync round-trip",
);
const resynced = await client.sendRequest("clice/currentContext", { uri });
assert.ok(
resynced.context && resynced.context.uri.includes("main.cpp"),
"switched context should survive the resync",
);
});

test("completion", async function () {
Expand Down
53 changes: 51 additions & 2 deletions tests/integration/extensions/test_context_switching.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@

from tests.tools.compile_commands import write_cdb, write_entries
from tests.tools.workspace import get_field
from tests.tools.checks import assert_clean_compile, assert_has_errors
from tests.tools.checks import MTIME_GRANULARITY, wait_for_recompile
from tests.tools.checks import assert_clean_compile, assert_has_errors, get_errors
from tests.tools.checks import MTIME_GRANULARITY, SETTLE_TIME, wait_for_recompile


async def test_source_command_switch(client, tmp_path):
Expand Down Expand Up @@ -400,3 +400,52 @@ async def test_chain_change_resynthesizes(client, tmp_path):

after = {p.name: p.stat().st_mtime_ns for p in artifact_dir.iterdir()}
assert after != snapshot, "stale preamble must be re-synthesized"


async def test_switched_context_survives_reopen(client, tmp_path):
"""The client resync contract: after a successful switch the client
closes and reopens the document (the pull-based server only re-targets
the session); the reopened compile runs under the persisted choice."""
(tmp_path / "main.cpp").write_text(
"#ifdef USE_B\nint broken() { return undefined_b_symbol; }\n#endif\n"
"int main() { return 0; }\n"
)
write_entries(tmp_path, [("main.cpp", ["-DUSE_A"]), ("main.cpp", ["-DUSE_B"])])
await client.initialize(tmp_path)

uri, _ = await client.open_and_wait(tmp_path / "main.cpp")
assert_clean_compile(client, uri)

query = await client.query_context(uri)
contexts = get_field(query, "contexts", [])
assert get_field(query, "total") == 2, f"expected both entries: {contexts}"
target_hash = next(
get_field(c, "commandHash")
for c in contexts
if "USE_B" in (get_field(c, "label") or "")
)

switch = await client.switch_context(
uri, uri, command_hash=target_hash, epoch=get_field(query, "epoch")
)
assert get_field(switch, "success") is True, f"switch failed: {switch}"

client.close(uri)
await asyncio.sleep(SETTLE_TIME)
client.diagnostics.pop(uri, None)

# The close publishes an empty retract that can race the reopen's
# first publish; poll past it for the real compile's errors.
uri2, _ = await client.open_and_wait(tmp_path / "main.cpp")
for _ in range(50):
if get_errors(client.diagnostics.get(uri2, [])):
break
await asyncio.sleep(0.2)
assert any(
"undefined_b_symbol" in d.message for d in client.diagnostics.get(uri2, [])
), f"expected the USE_B error after reopen: {client.diagnostics.get(uri2, [])}"

current = await client.current_context(uri2)
assert get_field(get_field(current, "context"), "commandHash") == target_hash, (
f"persisted choice must survive the reopen: {current}"
)
Loading