Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
35 changes: 28 additions & 7 deletions editors/vscode/src/feature/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,31 @@ 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. (For fragment files opened as C++
* by detectCxxFragment, the plaintext hop may race that detector's own
* language restore — a harmless redundant set.) */
export async function resyncDocument(uri: string) {
const doc = vscode.workspace.textDocuments.find(
(candidate) => candidate.uri.toString() === uri,
);
if (!doc) {
return;
}
const language = doc.languageId;
try {
await vscode.languages.setTextDocumentLanguage(doc, "plaintext");
Comment thread
16bit-ykiko marked this conversation as resolved.
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.
}
}

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,13 +229,9 @@ 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.
void vscode.commands.executeCommand(
"vscode.executeDocumentSymbolProvider",
vscode.Uri.parse(uri),
);
// 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.
Resolved
}
await refresh(vscode.window.activeTextEditor);
}
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
54 changes: 52 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,53 @@ 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