From 1de2c5b9671cc278f27dc016a32f0845db348141 Mon Sep 17 00:00:00 2001 From: Ferran Date: Wed, 19 Aug 2026 14:16:03 +0200 Subject: [PATCH 1/2] Hover over function returns references over modal window --- .../skills/vscode-integration-tests/SKILL.md | 75 +++++++++++++++++++ crates/pine-lsp/src/lib.rs | 40 +++++++++- .../vscode/src/test/suite/extension.test.ts | 4 + 3 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 .claude/skills/vscode-integration-tests/SKILL.md diff --git a/.claude/skills/vscode-integration-tests/SKILL.md b/.claude/skills/vscode-integration-tests/SKILL.md new file mode 100644 index 0000000..7f1b1fe --- /dev/null +++ b/.claude/skills/vscode-integration-tests/SKILL.md @@ -0,0 +1,75 @@ +--- +name: vscode-integration-tests +description: Use whenever you change the language server (crates/pine-lsp) or the VS Code extension (editors/vscode) — a new/changed capability like hover, go-to-definition, diagnostics, formatting, or client behavior. Every such change gets an end-to-end test in the VS Code extension test suite that drives the real server through VS Code's LSP client. +--- + +# Every LSP/extension change gets a VS Code integration test + +The language server is validated **end-to-end through VS Code**: a real editor +launches the extension, which spawns the real `pinecone lsp` binary, and the +test drives it with VS Code's built-in LSP commands. There are no Rust +subprocess harnesses and no mocked client — if you add or change an LSP feature, +add or extend a test here. + +## Where things live + +- `editors/vscode/src/test/suite/extension.test.ts` — the tests (Mocha `tdd`). +- `editors/vscode/testFixture/*.pine` — the `.pine` documents tests open. +- `editors/vscode/src/test/runTest.ts` — launches VS Code via + `@vscode/test-electron`. It sets `SERVER_PATH` to `target/debug/pinecone`, so + **the server binary must be built first**. +- `editors/vscode/src/test/suite/index.ts` — Mocha runner; globs `**/*.test.js`. + +## Writing a test + +1. If you need a new document, add a fixture under `testFixture/` (e.g. + `symbols.pine`). Keep it minimal. +2. Open it with the `open(uri)` helper — it awaits the server's first + diagnostics via `onDidChangeDiagnostics`, so the symbol table is ready. + **Never `setTimeout`/sleep to wait for the server**; wait on the real event. +3. Drive the feature with a VS Code command and assert on the result: + +```ts +test("hover lists a function's call sites", async () => { + const uri = fixture("symbols.pine"); + await open(uri); + + // Positions are 0-based (line, character). `double` in its call on line 4. + const hovers = await vscode.commands.executeCommand( + "vscode.executeHoverProvider", + uri, + new vscode.Position(3, 6) + ); + const content = hovers[0].contents[0] as vscode.MarkdownString; + assert.ok(content.value.includes("1 call"), content.value); +}); +``` + +Commands by feature: `vscode.executeHoverProvider`, +`vscode.executeDefinitionProvider`, `vscode.executeDocumentSymbolProvider`, +`vscode.executeFormatDocumentProvider`; diagnostics are read with +`vscode.languages.getDiagnostics(uri)` after `open()`. Positions are **0-based**; +sema reports 1-based, so subtract one when translating a fixture line/column. + +Formatting note: VS Code minimizes a full-document replace into smaller edits, so +apply the returned edits to a `WorkspaceEdit` and compare `doc.getText()` rather +than asserting on a single edit's text (see the "formats a document" test). + +## Running + +Build the server, compile the tests, then run under a virtual display. In this +environment `ELECTRON_RUN_AS_NODE` is set and must be unset or VS Code rejects +its own launch flags: + +```sh +cargo build -p pinecone +cd editors/vscode +npm run pretest # compile tests + bundle extension +env -u ELECTRON_RUN_AS_NODE xvfb-run -a npm test +``` + +Outside this sandbox it is just `npm test`. CI runs the same steps in the +`extension-tests` job of `.github/workflows/test.yml`. + +Before finishing: the extension suite passes, and `cargo test -p pine-lsp` + +`cargo clippy -p pine-lsp` are green. diff --git a/crates/pine-lsp/src/lib.rs b/crates/pine-lsp/src/lib.rs index 946995a..7d1036e 100644 --- a/crates/pine-lsp/src/lib.rs +++ b/crates/pine-lsp/src/lib.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use std::sync::Mutex; use pine_lang::diagnostics::{Diagnostic as PineDiagnostic, Severity}; -use pine_lang::sema::{Symbol, SymbolKind, SymbolTable}; +use pine_lang::sema::{Symbol, SymbolId, SymbolKind, SymbolTable}; use tower_lsp_server::lsp_types::*; use tower_lsp_server::{jsonrpc, Client, LanguageServer, LspService, Server, UriExt}; @@ -146,7 +146,7 @@ impl LanguageServer for Backend { documents.get(&at.text_document.uri).and_then(|doc| { let symbols = doc.symbols.as_ref()?; let id = symbol_at(symbols, &doc.text, at.position)?; - Some(render_symbol(symbols.symbol(id))) + Some(hover_markdown(symbols, id, &at.text_document.uri)) }) }; Ok(markdown.map(|value| Hover { @@ -225,6 +225,42 @@ fn render_symbol(symbol: &Symbol) -> String { format!("```pine\n{signature}\n```\n\n*{}*", symbol.kind.noun()) } +/// Hover text: the symbol's signature, plus — for a function — where it is +/// defined and every place it is called. Positions in this file are rendered as +/// links so the reader can jump to them. +fn hover_markdown(symbols: &SymbolTable, id: SymbolId, uri: &Uri) -> String { + let symbol = symbols.symbol(id); + let mut md = render_symbol(symbol); + if symbol.kind != SymbolKind::Function { + return md; + } + + let here = uri.as_str(); + let link = |line: u32, column: u32| format!("[{line}:{column}]({here}#L{line},{column})"); + + match symbols.declaration_location(id) { + Some((file, line, column)) if file == SymbolTable::MAIN => { + md.push_str(&format!("\n\nDefined at {}", link(line, column))); + } + Some((file, _, _)) => { + md.push_str(&format!("\n\nDefined in `{}`", symbols.file_path(file))); + } + None => {} + } + + let calls: Vec = symbols + .references(id) + .filter(|(file, _, _)| *file == SymbolTable::MAIN) + .map(|(_, line, column)| link(line, column)) + .collect(); + match calls.len() { + 0 => md.push_str("\n\nNo calls in this file."), + 1 => md.push_str(&format!("\n\n**1 call:** {}", calls[0])), + n => md.push_str(&format!("\n\n**{n} calls:** {}", calls.join(", "))), + } + md +} + fn to_lsp(diagnostic: &PineDiagnostic, text: &str) -> Diagnostic { let range = diagnostic .pos diff --git a/editors/vscode/src/test/suite/extension.test.ts b/editors/vscode/src/test/suite/extension.test.ts index e069614..3a153f9 100644 --- a/editors/vscode/src/test/suite/extension.test.ts +++ b/editors/vscode/src/test/suite/extension.test.ts @@ -79,6 +79,10 @@ suite("pinecone language server", () => { assert.ok(hovers && hovers.length > 0, "expected a hover"); const content = hovers[0].contents[0] as vscode.MarkdownString; assert.ok(content.value.includes("double(x)"), content.value); + // The signature is followed by the declaration and every call site. + assert.ok(content.value.includes("Defined at"), content.value); + assert.ok(content.value.includes("1 call"), content.value); + assert.ok(content.value.includes("4:5"), content.value); // the call on line 4 }); test("goes to a definition", async () => { From 6323f4b9941eeced5816eddbab35d7c59ed9d57a Mon Sep 17 00:00:00 2001 From: Ferran Date: Wed, 19 Aug 2026 14:23:40 +0200 Subject: [PATCH 2/2] Find references --- crates/pine-lsp/src/lib.rs | 45 +++++++++++++++++++ .../vscode/src/test/suite/extension.test.ts | 15 +++++++ 2 files changed, 60 insertions(+) diff --git a/crates/pine-lsp/src/lib.rs b/crates/pine-lsp/src/lib.rs index 7d1036e..743d576 100644 --- a/crates/pine-lsp/src/lib.rs +++ b/crates/pine-lsp/src/lib.rs @@ -70,6 +70,7 @@ impl LanguageServer for Backend { document_formatting_provider: Some(OneOf::Left(true)), hover_provider: Some(HoverProviderCapability::Simple(true)), definition_provider: Some(OneOf::Left(true)), + references_provider: Some(OneOf::Left(true)), ..Default::default() }, server_info: Some(ServerInfo { @@ -182,6 +183,39 @@ impl LanguageServer for Backend { })) } + async fn references(&self, params: ReferenceParams) -> jsonrpc::Result>> { + let at = params.text_document_position; + let uri = at.text_document.uri; + let locations = { + let documents = self.documents.lock().unwrap(); + documents.get(&uri).and_then(|doc| { + let symbols = doc.symbols.as_ref()?; + let id = symbol_at(symbols, &doc.text, at.position)?; + let width = symbols.symbol(id).name.chars().count() as u32; + let mut sites: Vec<(u32, u32)> = Vec::new(); + if params.context.include_declaration { + if let Some((file, line, column)) = symbols.declaration_location(id) { + if file == SymbolTable::MAIN { + sites.push((line, column)); + } + } + } + for (file, line, column) in symbols.references(id) { + if file == SymbolTable::MAIN { + sites.push((line, column)); + } + } + Some( + sites + .into_iter() + .map(|(line, column)| main_location(&uri, line, column, width)) + .collect::>(), + ) + }) + }; + Ok(locations.filter(|l| !l.is_empty())) + } + async fn shutdown(&self) -> jsonrpc::Result<()> { Ok(()) } @@ -204,6 +238,17 @@ fn symbol_at( symbols.symbol_at(SymbolTable::MAIN, position.line + 1, start as u32 + 1) } +/// A location in the main document spanning `width` characters from a 1-based +/// `(line, column)`. +fn main_location(uri: &Uri, line: u32, column: u32, width: u32) -> Location { + let start = Position::new(line - 1, column - 1); + let end = Position::new(line - 1, column - 1 + width); + Location { + uri: uri.clone(), + range: Range::new(start, end), + } +} + /// The start column of the identifier the cursor sits in or just after. fn identifier_start(line: &str, column: usize) -> usize { let chars: Vec = line.chars().collect(); diff --git a/editors/vscode/src/test/suite/extension.test.ts b/editors/vscode/src/test/suite/extension.test.ts index 3a153f9..e87e63a 100644 --- a/editors/vscode/src/test/suite/extension.test.ts +++ b/editors/vscode/src/test/suite/extension.test.ts @@ -97,4 +97,19 @@ suite("pinecone language server", () => { assert.ok(locations && locations.length > 0, "expected a definition"); assert.strictEqual(locations[0].range.start.line, 2); // `double(x) =>` }); + + test("finds references to a function", async () => { + const uri = fixture("symbols.pine"); + await open(uri); + + const locations = await vscode.commands.executeCommand( + "vscode.executeReferenceProvider", + uri, + new vscode.Position(3, 6) // the `double` call on line 4 + ); + assert.ok(locations && locations.length > 0, "expected references"); + const lines = locations.map((l) => l.range.start.line).sort(); + // The declaration on line 3 (0-based 2) and the call on line 4 (0-based 3). + assert.deepStrictEqual(lines, [2, 3], JSON.stringify(lines)); + }); });