diff --git a/crates/pine-lsp/src/lib.rs b/crates/pine-lsp/src/lib.rs index 743d576..ee91bc2 100644 --- a/crates/pine-lsp/src/lib.rs +++ b/crates/pine-lsp/src/lib.rs @@ -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> = + register_namespace_objects(PineVersion::LATEST, None, None).0; +} + /// Serve the language server over stdio until the client disconnects. #[tokio::main] pub async fn run() { @@ -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 @@ -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; @@ -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 { @@ -216,6 +235,21 @@ impl LanguageServer for Backend { Ok(locations.filter(|l| !l.is_empty())) } + async fn completion( + &self, + params: CompletionParams, + ) -> jsonrpc::Result> { + 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(()) } @@ -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> { + 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> { + 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 = 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> { + BUILTINS.with(|builtins| { + let Some(Value::Object { fields, .. }) = builtins.get(namespace) else { + return None; + }; + let mut items: Vec = 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) -> 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 { + 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 = line.chars().collect(); @@ -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()); + } } diff --git a/editors/vscode/src/test/suite/extension.test.ts b/editors/vscode/src/test/suite/extension.test.ts index e87e63a..bc6501d 100644 --- a/editors/vscode/src/test/suite/extension.test.ts +++ b/editors/vscode/src/test/suite/extension.test.ts @@ -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. @@ -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.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.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.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.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); + }); }); diff --git a/editors/vscode/testFixture/completion.pine b/editors/vscode/testFixture/completion.pine new file mode 100644 index 0000000..be45de7 --- /dev/null +++ b/editors/vscode/testFixture/completion.pine @@ -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