plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
plum-lsp/src/main.rs
// LSP server for plum. Two diagnostic sources:
//
// 1. Syntax errors — read directly off the tree-sitter parse tree (ERROR /
// MISSING nodes), which carries real byte/line/column spans.
//
// 2. Type errors — from `plum_checker::checkSource`. `CheckError` carries a
// `line`/`column` pointing at the enclosing `fn`/`enum`/`test`
// declaration (not the exact sub-expression/token within it — the
// checker doesn't track spans that fine yet), so the diagnostic range
// covers that declaration's line rather than the whole document.
//
// Both are republished on every `did_open`/`did_change`/`did_save`. Type
// checking goes through `plum_core::loadAndMergeWithOverrides`, passing the
// live in-memory buffer for the edited document while every other
// (transitively imported) file still reads from disk — so it reflects
// unsaved edits, not just the last save.
//
// Completion (see `completion.rs`) is a separate, coarser-grained feature:
// keywords + top-level names always; `expr.` member completion and
// not-yet-imported std lib symbols only when the document currently loads
// cleanly enough to build the checker's global tables.
#![allow(non_snake_case)]
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::OnceLock;
use tokio::sync::Mutex;
use tower_lsp::jsonrpc::Result as RpcResult;
use tower_lsp::lsp_types::*;
use tower_lsp::{Client, LanguageServer, LspService, Server};
mod completion;
struct Backend {
client: Client,
docs: Mutex<HashMap<Url, String>>,
libPath: PathBuf,
/// Lazily built on first completion request and kept for the server's
/// lifetime — the std lib doesn't change while `plum-lsp` is running.
stdIndex: OnceLock<HashMap<String, String>>,
}
impl Backend {
fn parseTree(source: &str) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&tree_sitter_plum::LANGUAGE.into())
.expect("failed to load plum grammar");
parser
.parse(source, None)
.expect("tree-sitter parse returned None")
}
/// Real-span diagnostics straight from the parse tree: any ERROR or
/// MISSING node becomes one diagnostic at its own range.
fn syntaxDiagnostics(tree: &tree_sitter::Tree) -> Vec<Diagnostic> {
let mut out = Vec::new();
let mut cursor = tree.walk();
let mut visit = |node: tree_sitter::Node| {
if node.is_error() || node.is_missing() {
let start = node.start_position();
let end = node.end_position();
out.push(Diagnostic {
range: Range {
start: Position::new(start.row as u32, start.column as u32),
end: Position::new(end.row as u32, end.column as u32),
},
severity: Some(DiagnosticSeverity::ERROR),
source: Some("plum-syntax".into()),
message: if node.is_missing() {
format!("missing {}", node.kind())
} else {
"syntax error".into()
},
..Default::default()
});
}
};
// Manual depth-first walk since `TreeCursor` has no built-in visitor.
loop {
visit(cursor.node());
if cursor.goto_first_child() {
continue;
}
loop {
if cursor.goto_next_sibling() {
break;
}
if !cursor.goto_parent() {
return out;
}
}
}
}
/// Diagnostics from `plum_checker::checkSource`, run against the live
/// in-memory buffer (via `loadAndMergeWithOverrides`) rather than
/// whatever's on disk, so an unsaved edit is reflected immediately.
/// Every other (transitively imported) file still resolves from disk.
/// Returns `None` (rather than an error) if the document has no on-disk
/// path or fails to load — those aren't type errors, so we don't want to
/// flash a bogus diagnostic.
fn typeDiagnostics(&self, uri: &Url, text: &str) -> Option<Vec<Diagnostic>> {
let path = uri.to_file_path().ok()?;
let canon = std::fs::canonicalize(&path).unwrap_or(path.clone());
let mut overrides = HashMap::new();
overrides.insert(canon, text.to_string());
let ast = plum_core::loadAndMergeWithOverrides(&path, &self.libPath, &overrides).ok()?;
let lastLine = text.lines().count().max(1) as u32 - 1;
match plum_checker::checkSource(&ast) {
Ok(()) => Some(Vec::new()),
Err(errors) => Some(
errors
.into_iter()
.map(|e| {
// `CheckError`'s position is 1-based and points at the
// enclosing declaration, not a sub-expression — clamp
// to the document and cover the whole line so an
// editor still shows something sensible even if the
// line itself is short/blank.
let line = e.line.saturating_sub(1).min(lastLine as usize) as u32;
Diagnostic {
range: Range {
start: Position::new(line, 0),
end: Position::new(line, u32::MAX),
},
severity: Some(DiagnosticSeverity::ERROR),
source: Some("plum-check".into()),
message: e.message,
..Default::default()
}
})
.collect(),
),
}
}
/// Builds `plum_checker`'s global tables for the live buffer, the same
/// way `typeDiagnostics` does — used by member/std-lib completion, which
/// both need method/field/import-target lookups, not just the document's
/// own AST.
fn mergedSource(&self, uri: &Url, text: &str) -> Option<plum_core::ast::Source> {
let path = uri.to_file_path().ok()?;
let canon = std::fs::canonicalize(&path).unwrap_or(path.clone());
let mut overrides = HashMap::new();
overrides.insert(canon, text.to_string());
plum_core::loadAndMergeWithOverrides(&path, &self.libPath, &overrides).ok()
}
async fn completionItems(&self, uri: &Url, pos: Position) -> Vec<CompletionItem> {
let text = match self.docs.lock().await.get(uri) {
Some(t) => t.clone(),
None => return Vec::new(),
};
let line_text = text.lines().nth(pos.line as usize).unwrap_or("");
let sanitized = completion::sanitizeForParsing(&text, pos);
let tree = Self::parseTree(&sanitized);
let own_source = plum_core::AstParser::new(&sanitized).parseSource(tree.root_node());
if let Some(receiver) = completion::receiverBeforeDot(line_text, pos.character as usize) {
let Some(merged) = self.mergedSource(uri, &sanitized) else {
return Vec::new();
};
let (global_env, methods, enum_variants, enum_params) =
plum_checker::buildGlobalTables(&merged);
let min_required = plum_checker::buildMinRequiredArgs(&merged);
let ctx = plum_checker::CheckCtx {
methods: &methods,
enum_variants: &enum_variants,
enum_params: &enum_params,
min_required: &min_required,
};
let cursor_fn = completion::enclosingFn(&own_source, pos.line as usize + 1);
return completion::memberItems(cursor_fn, &receiver, &global_env, &ctx);
}
let mut items = completion::keywordItems();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let pushUnique = |item: CompletionItem,
items: &mut Vec<CompletionItem>,
seen: &mut std::collections::HashSet<String>| {
if seen.insert(item.label.clone()) {
items.push(item);
}
};
// The merged source (own file + imports) if it loads cleanly, else
// just the document's own declarations — still useful mid-edit.
let base_source = self.mergedSource(uri, &sanitized);
for item in completion::topLevelItems(base_source.as_ref().unwrap_or(&own_source)) {
pushUnique(item, &mut items, &mut seen);
}
let stdIndex = self
.stdIndex
.get_or_init(|| completion::stdLibIndex(&self.libPath));
for (name, import_path) in stdIndex {
if seen.contains(name) {
continue;
}
let already_imported = own_source.imports.iter().any(|i| &i.path == import_path);
if already_imported {
continue;
}
items.push(CompletionItem {
label: name.clone(),
kind: Some(CompletionItemKind::CLASS),
detail: Some(format!("{import_path} (not imported)")),
additional_text_edits: Some(vec![completion::importInsertionEdit(
&text,
import_path,
)]),
..Default::default()
});
}
items
}
async fn publishAll(&self, uri: Url) {
let text = match self.docs.lock().await.get(&uri) {
Some(t) => t.clone(),
None => return,
};
let tree = Self::parseTree(&text);
let mut diagnostics = Self::syntaxDiagnostics(&tree);
if diagnostics.is_empty() {
if let Some(mut typeErrors) = self.typeDiagnostics(&uri, &text) {
diagnostics.append(&mut typeErrors);
}
}
self.client
.publish_diagnostics(uri, diagnostics, None)
.await;
}
}
#[tower_lsp::async_trait]
impl LanguageServer for Backend {
async fn initialize(&self, _: InitializeParams) -> RpcResult<InitializeResult> {
Ok(InitializeResult {
capabilities: ServerCapabilities {
text_document_sync: Some(TextDocumentSyncCapability::Kind(
TextDocumentSyncKind::FULL,
)),
completion_provider: Some(CompletionOptions {
trigger_characters: Some(vec![".".to_string()]),
..Default::default()
}),
..Default::default()
},
server_info: Some(ServerInfo {
name: "plum-lsp".into(),
version: Some(env!("CARGO_PKG_VERSION").into()),
}),
})
}
async fn initialized(&self, _: InitializedParams) {
self.client
.log_message(MessageType::INFO, "plum-lsp initialized")
.await;
}
async fn shutdown(&self) -> RpcResult<()> {
Ok(())
}
async fn did_open(&self, params: DidOpenTextDocumentParams) {
let uri = params.text_document.uri;
self.docs
.lock()
.await
.insert(uri.clone(), params.text_document.text);
self.publishAll(uri).await;
}
async fn did_change(&self, mut params: DidChangeTextDocumentParams) {
let uri = params.text_document.uri;
// FULL sync: the last content change carries the whole new text.
if let Some(change) = params.content_changes.pop() {
self.docs.lock().await.insert(uri.clone(), change.text);
}
self.publishAll(uri).await;
}
async fn did_save(&self, params: DidSaveTextDocumentParams) {
self.publishAll(params.text_document.uri).await;
}
async fn did_close(&self, params: DidCloseTextDocumentParams) {
self.docs.lock().await.remove(¶ms.text_document.uri);
}
async fn completion(&self, params: CompletionParams) -> RpcResult<Option<CompletionResponse>> {
let uri = params.text_document_position.text_document.uri;
let pos = params.text_document_position.position;
Ok(Some(CompletionResponse::Array(
self.completionItems(&uri, pos).await,
)))
}
}
#[tokio::main]
async fn main() {
// `--lib-path` mirrors `plum-cli`'s own required flag (see
// plum-cli/src/main.rs's `Command::Run`/`Compile`) — the checker has no
// built-in default location for the std lib. It's the directory
// CONTAINING `plum-std/` (an `import std/Bool` resolves to
// `<lib_path>/plum-std/Bool.plum` — see `plum_core::loader::loadImport`),
// not `plum-std` itself; the repo root is `.` when run from a checkout.
let libPath = std::env::args()
.collect::<Vec<_>>()
.windows(2)
.find(|w| w[0] == "--lib-path")
.map(|w| PathBuf::from(&w[1]))
.unwrap_or_else(|| PathBuf::from("."));
let stdin = tokio::io::stdin();
let stdout = tokio::io::stdout();
let (service, socket) = LspService::new(|client| Backend {
client,
docs: Mutex::new(HashMap::new()),
libPath,
stdIndex: OnceLock::new(),
});
Server::new(stdin, stdout, socket).serve(service).await;
}