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
178 changes: 173 additions & 5 deletions crates/pine-lsp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,22 @@ use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;

use pine_lang::builtins::{register_namespace_objects, DefaultPineOutput};
use pine_lang::core::PineVersion;
use pine_lang::diagnostics::{Diagnostic as PineDiagnostic, Severity};
use pine_lang::interpreter::{BuiltinSignature, Value};
use pine_lang::sema::{Symbol, SymbolId, SymbolKind, SymbolTable};
use tower_lsp_server::lsp_types::*;
use tower_lsp_server::{jsonrpc, Client, LanguageServer, LspService, Server, UriExt};

thread_local! {
/// The builtin namespaces (`ta`, `math`, `array`, …) as the analyzer sees
/// them, for `namespace.` member completion. `Value` is `!Send`, so it can't
/// live on the shared `Backend`; each worker thread builds its own copy once.
static BUILTINS: HashMap<String, Value<DefaultPineOutput>> =
register_namespace_objects(PineVersion::LATEST, None, None).0;
}

/// Serve the language server over stdio until the client disconnects.
#[tokio::main]
pub async fn run() {
Expand Down Expand Up @@ -38,7 +49,7 @@ impl Backend {

/// Re-analyze `text`, cache the result for `uri`, and publish its diagnostics.
async fn update(&self, uri: Uri, text: String) {
let (diagnostics, symbols) = match analyze(&text, uri_dir(&uri)) {
let (diagnostics, analyzed) = match analyze(&text, uri_dir(&uri)) {
Ok(analysis) => (
analysis
.diagnostics
Expand All @@ -50,10 +61,14 @@ impl Backend {
// A lex/parse/version error stops analysis; publish it as a single diagnostic.
Err(err) => (vec![error_diagnostic(&err, &text)], None),
};
self.documents
.lock()
.unwrap()
.insert(uri.clone(), Document { text, symbols });
{
let mut documents = self.documents.lock().unwrap();
// A transient parse error yields no table (e.g. right after typing a
// `.`); keep the last good one so completion and hover still answer.
let symbols =
analyzed.or_else(|| documents.get_mut(&uri).and_then(|d| d.symbols.take()));
documents.insert(uri.clone(), Document { text, symbols });
}
self.client
.publish_diagnostics(uri, diagnostics, None)
.await;
Expand All @@ -71,6 +86,10 @@ impl LanguageServer for Backend {
hover_provider: Some(HoverProviderCapability::Simple(true)),
definition_provider: Some(OneOf::Left(true)),
references_provider: Some(OneOf::Left(true)),
completion_provider: Some(CompletionOptions {
trigger_characters: Some(vec![".".to_string()]),
..Default::default()
}),
..Default::default()
},
server_info: Some(ServerInfo {
Expand Down Expand Up @@ -216,6 +235,21 @@ impl LanguageServer for Backend {
Ok(locations.filter(|l| !l.is_empty()))
}

async fn completion(
&self,
params: CompletionParams,
) -> jsonrpc::Result<Option<CompletionResponse>> {
let at = params.text_document_position;
let items = {
let documents = self.documents.lock().unwrap();
documents.get(&at.text_document.uri).and_then(|doc| {
let symbols = doc.symbols.as_ref()?;
member_completions(symbols, &doc.text, at.position)
})
};
Ok(items.map(CompletionResponse::Array))
}

async fn shutdown(&self) -> jsonrpc::Result<()> {
Ok(())
}
Expand Down Expand Up @@ -249,6 +283,128 @@ fn main_location(uri: &Uri, line: u32, column: u32, width: u32) -> Location {
}
}

/// Member completions after `receiver.` — a user object's fields/cases/exports,
/// or the members of a builtin namespace (`ta.`, `math.`, …).
fn member_completions(
symbols: &SymbolTable,
text: &str,
position: Position,
) -> Option<Vec<CompletionItem>> {
let line = text.lines().nth(position.line as usize).unwrap_or("");
let prefix: String = line.chars().take(position.character as usize).collect();
let receiver = receiver_before_dot(&prefix)?;

let root = symbols.file_root(SymbolTable::MAIN);
match symbols.resolve_id(root, receiver) {
// A user declaration shadows a builtin namespace of the same name.
Some(id) => user_member_completions(symbols, id),
None => builtin_member_completions(receiver),
}
}

/// The members of a user symbol: the fields/cases of a type (reached directly or
/// through a variable's declared type), or an import's exported symbols.
fn user_member_completions(symbols: &SymbolTable, id: SymbolId) -> Option<Vec<CompletionItem>> {
let symbol = symbols.symbol(id);
let members: Vec<&Symbol> = match symbol.kind {
SymbolKind::Import => {
let scope = symbol.module?;
symbols.symbols_in(scope).filter(|s| s.exported).collect()
}
SymbolKind::Type | SymbolKind::Enum => symbols.members_of(id).collect(),
SymbolKind::Var => symbols.members_of(symbol.type_ref?).collect(),
SymbolKind::Function => return None,
};
let items: Vec<CompletionItem> = members.into_iter().map(completion_item).collect();
(!items.is_empty()).then_some(items)
}

/// The members of a builtin namespace object (`ta.sma`, `math.abs`, …), read
/// straight from the registered builtins.
fn builtin_member_completions(namespace: &str) -> Option<Vec<CompletionItem>> {
BUILTINS.with(|builtins| {
let Some(Value::Object { fields, .. }) = builtins.get(namespace) else {
return None;
};
let mut items: Vec<CompletionItem> = fields
.borrow()
.iter()
.map(|(name, value)| builtin_completion_item(name, value))
.collect();
items.sort_by(|a, b| a.label.cmp(&b.label));
(!items.is_empty()).then_some(items)
})
}

/// A completion for one builtin member: a function (with its signature) when the
/// value is callable, a nested namespace, or otherwise a constant.
fn builtin_completion_item(name: &str, value: &Value<DefaultPineOutput>) -> CompletionItem {
let (kind, detail) = match value {
Value::BuiltinFunction(builtin) => (
CompletionItemKind::FUNCTION,
signature_detail(name, builtin.signature),
),
Value::Object {
call: Some(builtin),
..
} => (
CompletionItemKind::FUNCTION,
signature_detail(name, builtin.signature),
),
Value::Object { .. } => (CompletionItemKind::MODULE, None),
_ => (CompletionItemKind::CONSTANT, None),
};
CompletionItem {
label: name.to_string(),
kind: Some(kind),
detail,
..Default::default()
}
}

/// `name(param, param, …)` for a builtin whose parameters are declared, else
/// `None` (an undeclared signature would falsely read as taking no arguments).
fn signature_detail(name: &str, signature: &BuiltinSignature) -> Option<String> {
if signature.params.is_empty() {
return None;
}
let params: Vec<&str> = signature.params.iter().map(|p| p.name.as_str()).collect();
Some(format!("{name}({})", params.join(", ")))
}

/// The receiver identifier in `…receiver.partial` up to the cursor, if the text
/// before the cursor is a member access. Single hop only.
fn receiver_before_dot(prefix: &str) -> Option<&str> {
let before_dot = prefix
.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_')
.strip_suffix('.')?;
let start = before_dot
.rfind(|c: char| !(c.is_alphanumeric() || c == '_'))
.map_or(0, |i| i + 1);
let receiver = &before_dot[start..];
(!receiver.is_empty()).then_some(receiver)
}

fn completion_item(symbol: &Symbol) -> CompletionItem {
let kind = match symbol.kind {
SymbolKind::Function => CompletionItemKind::FUNCTION,
SymbolKind::Type => CompletionItemKind::STRUCT,
SymbolKind::Enum => CompletionItemKind::ENUM,
SymbolKind::Var => CompletionItemKind::FIELD,
SymbolKind::Import => CompletionItemKind::MODULE,
};
let detail = match symbol.kind {
SymbolKind::Function => Some(format!("{}({})", symbol.name, symbol.params.join(", "))),
_ => symbol.type_annotation.clone(),
};
CompletionItem {
label: symbol.name.clone(),
kind: Some(kind),
detail,
..Default::default()
}
}

/// 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 Down Expand Up @@ -421,4 +577,16 @@ mod tests {
fn end_position_is_past_the_last_char() {
assert_eq!(end_position("ab\ncd"), Position::new(1, 2));
}

#[test]
fn completes_builtin_namespace_members() {
let ta = builtin_member_completions("ta").expect("ta namespace");
assert!(ta.iter().any(|i| i.label == "sma"), "expected ta.sma");

let math = builtin_member_completions("math").expect("math namespace");
assert!(math.iter().any(|i| i.label == "abs"), "expected math.abs");

// A name that is not a builtin namespace yields nothing.
assert!(builtin_member_completions("definitely_not_a_namespace").is_none());
}
}
61 changes: 61 additions & 0 deletions editors/vscode/src/test/suite/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ function fixture(name: string): vscode.Uri {
return vscode.Uri.file(path.resolve(__dirname, "../../../testFixture", name));
}

function labelOf(item: vscode.CompletionItem): string {
return typeof item.label === "string" ? item.label : item.label.label;
}

// Resolve once the server has published diagnostics for `uri` — i.e. it has
// analyzed the document. Event-driven, so tests wait on the real signal rather
// than a fixed delay.
Expand Down Expand Up @@ -112,4 +116,61 @@ suite("pinecone language server", () => {
// 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));
});

test("completes an object's fields", async () => {
const uri = fixture("completion.pine");
await open(uri);

// Just after `p.` on line 7 (`v = p.x`).
const list = await vscode.commands.executeCommand<vscode.CompletionList>(
"vscode.executeCompletionItemProvider",
uri,
new vscode.Position(6, 6)
);
const labels = list.items.map(labelOf);
assert.ok(
labels.includes("x") && labels.includes("y"),
labels.join(", ")
);
});

test("completes a builtin namespace", async () => {
const uri = fixture("completion.pine");
await open(uri);

// Just after `ta.` on line 8 (`w = ta.sma(close, 5)`).
const list = await vscode.commands.executeCommand<vscode.CompletionList>(
"vscode.executeCompletionItemProvider",
uri,
new vscode.Position(7, 7)
);
const labels = list.items.map(labelOf);
assert.ok(labels.includes("sma"), labels.slice(0, 20).join(", "));
});

test("builtin members carry kind and signature", async () => {
const uri = fixture("completion.pine");
await open(uri);

// `ta.sma` is a function whose parameters are shown as the detail.
const ta = await vscode.commands.executeCommand<vscode.CompletionList>(
"vscode.executeCompletionItemProvider",
uri,
new vscode.Position(7, 7) // after `ta.`
);
const sma = ta.items.find((i) => labelOf(i) === "sma");
assert.ok(sma, "expected ta.sma");
assert.strictEqual(sma!.kind, vscode.CompletionItemKind.Function);
assert.strictEqual(sma!.detail, "sma(source, length)");

// `math.pi` is a constant, not a function.
const math = await vscode.commands.executeCommand<vscode.CompletionList>(
"vscode.executeCompletionItemProvider",
uri,
new vscode.Position(8, 9) // after `math.`
);
const pi = math.items.find((i) => labelOf(i) === "pi");
assert.ok(pi, "expected math.pi");
assert.strictEqual(pi!.kind, vscode.CompletionItemKind.Constant);
});
});
9 changes: 9 additions & 0 deletions editors/vscode/testFixture/completion.pine
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//@version=6
indicator("complete")
type Point
float x
float y
p = Point.new(1.0, 2.0)
v = p.x
w = ta.sma(close, 5)
r = math.pi
Loading