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
75 changes: 75 additions & 0 deletions .claude/skills/vscode-integration-tests/SKILL.md
Original file line number Diff line number Diff line change
@@ -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.Hover[]>(
"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.
85 changes: 83 additions & 2 deletions crates/pine-lsp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -146,7 +147,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 {
Expand Down Expand Up @@ -182,6 +183,39 @@ impl LanguageServer for Backend {
}))
}

async fn references(&self, params: ReferenceParams) -> jsonrpc::Result<Option<Vec<Location>>> {
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::<Vec<_>>(),
)
})
};
Ok(locations.filter(|l| !l.is_empty()))
}

async fn shutdown(&self) -> jsonrpc::Result<()> {
Ok(())
}
Expand All @@ -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<char> = line.chars().collect();
Expand All @@ -225,6 +270,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<String> = 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
Expand Down
19 changes: 19 additions & 0 deletions editors/vscode/src/test/suite/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -93,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.Location[]>(
"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));
});
});
Loading