plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
plum-lsp/src/main.rs
| 6fc9e6d | 1 | // LSP server for plum. Two diagnostic sources: |
| 6fc9e6d | 2 | // |
| 6fc9e6d | 3 | // 1. Syntax errors — read directly off the tree-sitter parse tree (ERROR / |
| 6fc9e6d | 4 | // MISSING nodes), which carries real byte/line/column spans. |
| 6fc9e6d | 5 | // |
| 6fc9e6d | 6 | // 2. Type errors — from `plum_checker::checkSource`. `CheckError` carries a |
| 6fc9e6d | 7 | // `line`/`column` pointing at the enclosing `fn`/`enum`/`test` |
| 6fc9e6d | 8 | // declaration (not the exact sub-expression/token within it — the |
| 6fc9e6d | 9 | // checker doesn't track spans that fine yet), so the diagnostic range |
| 6fc9e6d | 10 | // covers that declaration's line rather than the whole document. |
| 6fc9e6d | 11 | // |
| 6fc9e6d | 12 | // Both are republished on every `did_open`/`did_change`/`did_save`. Type |
| 6fc9e6d | 13 | // checking goes through `plum_core::loadAndMergeWithOverrides`, passing the |
| 6fc9e6d | 14 | // live in-memory buffer for the edited document while every other |
| 6fc9e6d | 15 | // (transitively imported) file still reads from disk — so it reflects |
| 6fc9e6d | 16 | // unsaved edits, not just the last save. |
| 6fc9e6d | 17 | // |
| 6fc9e6d | 18 | // Completion (see `completion.rs`) is a separate, coarser-grained feature: |
| 6fc9e6d | 19 | // keywords + top-level names always; `expr.` member completion and |
| 6fc9e6d | 20 | // not-yet-imported std lib symbols only when the document currently loads |
| 6fc9e6d | 21 | // cleanly enough to build the checker's global tables. |
| 6fc9e6d | 22 | |
| 6fc9e6d | 23 | #![allow(non_snake_case)] |
| 6fc9e6d | 24 | |
| 6fc9e6d | 25 | use std::collections::HashMap; |
| 6fc9e6d | 26 | use std::path::PathBuf; |
| 6fc9e6d | 27 | use std::sync::OnceLock; |
| 6fc9e6d | 28 | |
| 6fc9e6d | 29 | use tokio::sync::Mutex; |
| 6fc9e6d | 30 | use tower_lsp::jsonrpc::Result as RpcResult; |
| 6fc9e6d | 31 | use tower_lsp::lsp_types::*; |
| 6fc9e6d | 32 | use tower_lsp::{Client, LanguageServer, LspService, Server}; |
| 6fc9e6d | 33 | |
| 6fc9e6d | 34 | mod completion; |
| 6fc9e6d | 35 | |
| 6fc9e6d | 36 | struct Backend { |
| 6fc9e6d | 37 | client: Client, |
| 6fc9e6d | 38 | docs: Mutex<HashMap<Url, String>>, |
| 6fc9e6d | 39 | libPath: PathBuf, |
| 6fc9e6d | 40 | /// Lazily built on first completion request and kept for the server's |
| 6fc9e6d | 41 | /// lifetime — the std lib doesn't change while `plum-lsp` is running. |
| 6fc9e6d | 42 | stdIndex: OnceLock<HashMap<String, String>>, |
| 6fc9e6d | 43 | } |
| 6fc9e6d | 44 | |
| 6fc9e6d | 45 | impl Backend { |
| 6fc9e6d | 46 | fn parseTree(source: &str) -> tree_sitter::Tree { |
| 6fc9e6d | 47 | let mut parser = tree_sitter::Parser::new(); |
| 6fc9e6d | 48 | parser |
| 6fc9e6d | 49 | .set_language(&tree_sitter_plum::LANGUAGE.into()) |
| 6fc9e6d | 50 | .expect("failed to load plum grammar"); |
| 6fc9e6d | 51 | parser |
| 6fc9e6d | 52 | .parse(source, None) |
| 6fc9e6d | 53 | .expect("tree-sitter parse returned None") |
| 6fc9e6d | 54 | } |
| 6fc9e6d | 55 | |
| 6fc9e6d | 56 | /// Real-span diagnostics straight from the parse tree: any ERROR or |
| 6fc9e6d | 57 | /// MISSING node becomes one diagnostic at its own range. |
| 6fc9e6d | 58 | fn syntaxDiagnostics(tree: &tree_sitter::Tree) -> Vec<Diagnostic> { |
| 6fc9e6d | 59 | let mut out = Vec::new(); |
| 6fc9e6d | 60 | let mut cursor = tree.walk(); |
| 6fc9e6d | 61 | let mut visit = |node: tree_sitter::Node| { |
| 6fc9e6d | 62 | if node.is_error() || node.is_missing() { |
| 6fc9e6d | 63 | let start = node.start_position(); |
| 6fc9e6d | 64 | let end = node.end_position(); |
| 6fc9e6d | 65 | out.push(Diagnostic { |
| 6fc9e6d | 66 | range: Range { |
| 6fc9e6d | 67 | start: Position::new(start.row as u32, start.column as u32), |
| 6fc9e6d | 68 | end: Position::new(end.row as u32, end.column as u32), |
| 6fc9e6d | 69 | }, |
| 6fc9e6d | 70 | severity: Some(DiagnosticSeverity::ERROR), |
| 6fc9e6d | 71 | source: Some("plum-syntax".into()), |
| 6fc9e6d | 72 | message: if node.is_missing() { |
| 6fc9e6d | 73 | format!("missing {}", node.kind()) |
| 6fc9e6d | 74 | } else { |
| 6fc9e6d | 75 | "syntax error".into() |
| 6fc9e6d | 76 | }, |
| 6fc9e6d | 77 | ..Default::default() |
| 6fc9e6d | 78 | }); |
| 6fc9e6d | 79 | } |
| 6fc9e6d | 80 | }; |
| 6fc9e6d | 81 | // Manual depth-first walk since `TreeCursor` has no built-in visitor. |
| 6fc9e6d | 82 | loop { |
| 6fc9e6d | 83 | visit(cursor.node()); |
| 6fc9e6d | 84 | if cursor.goto_first_child() { |
| 6fc9e6d | 85 | continue; |
| 6fc9e6d | 86 | } |
| 6fc9e6d | 87 | loop { |
| 6fc9e6d | 88 | if cursor.goto_next_sibling() { |
| 6fc9e6d | 89 | break; |
| 6fc9e6d | 90 | } |
| 6fc9e6d | 91 | if !cursor.goto_parent() { |
| 6fc9e6d | 92 | return out; |
| 6fc9e6d | 93 | } |
| 6fc9e6d | 94 | } |
| 6fc9e6d | 95 | } |
| 6fc9e6d | 96 | } |
| 6fc9e6d | 97 | |
| 6fc9e6d | 98 | /// Diagnostics from `plum_checker::checkSource`, run against the live |
| 6fc9e6d | 99 | /// in-memory buffer (via `loadAndMergeWithOverrides`) rather than |
| 6fc9e6d | 100 | /// whatever's on disk, so an unsaved edit is reflected immediately. |
| 6fc9e6d | 101 | /// Every other (transitively imported) file still resolves from disk. |
| 6fc9e6d | 102 | /// Returns `None` (rather than an error) if the document has no on-disk |
| 6fc9e6d | 103 | /// path or fails to load — those aren't type errors, so we don't want to |
| 6fc9e6d | 104 | /// flash a bogus diagnostic. |
| 6fc9e6d | 105 | fn typeDiagnostics(&self, uri: &Url, text: &str) -> Option<Vec<Diagnostic>> { |
| 6fc9e6d | 106 | let path = uri.to_file_path().ok()?; |
| 6fc9e6d | 107 | let canon = std::fs::canonicalize(&path).unwrap_or(path.clone()); |
| 6fc9e6d | 108 | let mut overrides = HashMap::new(); |
| 6fc9e6d | 109 | overrides.insert(canon, text.to_string()); |
| 6fc9e6d | 110 | let ast = plum_core::loadAndMergeWithOverrides(&path, &self.libPath, &overrides).ok()?; |
| 6fc9e6d | 111 | let lastLine = text.lines().count().max(1) as u32 - 1; |
| 6fc9e6d | 112 | match plum_checker::checkSource(&ast) { |
| 6fc9e6d | 113 | Ok(()) => Some(Vec::new()), |
| 6fc9e6d | 114 | Err(errors) => Some( |
| 6fc9e6d | 115 | errors |
| 6fc9e6d | 116 | .into_iter() |
| 6fc9e6d | 117 | .map(|e| { |
| 6fc9e6d | 118 | // `CheckError`'s position is 1-based and points at the |
| 6fc9e6d | 119 | // enclosing declaration, not a sub-expression — clamp |
| 6fc9e6d | 120 | // to the document and cover the whole line so an |
| 6fc9e6d | 121 | // editor still shows something sensible even if the |
| 6fc9e6d | 122 | // line itself is short/blank. |
| 6fc9e6d | 123 | let line = e.line.saturating_sub(1).min(lastLine as usize) as u32; |
| 6fc9e6d | 124 | Diagnostic { |
| 6fc9e6d | 125 | range: Range { |
| 6fc9e6d | 126 | start: Position::new(line, 0), |
| 6fc9e6d | 127 | end: Position::new(line, u32::MAX), |
| 6fc9e6d | 128 | }, |
| 6fc9e6d | 129 | severity: Some(DiagnosticSeverity::ERROR), |
| 6fc9e6d | 130 | source: Some("plum-check".into()), |
| 6fc9e6d | 131 | message: e.message, |
| 6fc9e6d | 132 | ..Default::default() |
| 6fc9e6d | 133 | } |
| 6fc9e6d | 134 | }) |
| 6fc9e6d | 135 | .collect(), |
| 6fc9e6d | 136 | ), |
| 6fc9e6d | 137 | } |
| 6fc9e6d | 138 | } |
| 6fc9e6d | 139 | |
| 6fc9e6d | 140 | /// Builds `plum_checker`'s global tables for the live buffer, the same |
| 6fc9e6d | 141 | /// way `typeDiagnostics` does — used by member/std-lib completion, which |
| 6fc9e6d | 142 | /// both need method/field/import-target lookups, not just the document's |
| 6fc9e6d | 143 | /// own AST. |
| 6fc9e6d | 144 | fn mergedSource(&self, uri: &Url, text: &str) -> Option<plum_core::ast::Source> { |
| 6fc9e6d | 145 | let path = uri.to_file_path().ok()?; |
| 6fc9e6d | 146 | let canon = std::fs::canonicalize(&path).unwrap_or(path.clone()); |
| 6fc9e6d | 147 | let mut overrides = HashMap::new(); |
| 6fc9e6d | 148 | overrides.insert(canon, text.to_string()); |
| 6fc9e6d | 149 | plum_core::loadAndMergeWithOverrides(&path, &self.libPath, &overrides).ok() |
| 6fc9e6d | 150 | } |
| 6fc9e6d | 151 | |
| 6fc9e6d | 152 | async fn completionItems(&self, uri: &Url, pos: Position) -> Vec<CompletionItem> { |
| 6fc9e6d | 153 | let text = match self.docs.lock().await.get(uri) { |
| 6fc9e6d | 154 | Some(t) => t.clone(), |
| 6fc9e6d | 155 | None => return Vec::new(), |
| 6fc9e6d | 156 | }; |
| 6fc9e6d | 157 | let line_text = text.lines().nth(pos.line as usize).unwrap_or(""); |
| 6fc9e6d | 158 | let sanitized = completion::sanitizeForParsing(&text, pos); |
| 6fc9e6d | 159 | let tree = Self::parseTree(&sanitized); |
| 6fc9e6d | 160 | let own_source = plum_core::AstParser::new(&sanitized).parseSource(tree.root_node()); |
| 6fc9e6d | 161 | |
| 6fc9e6d | 162 | if let Some(receiver) = completion::receiverBeforeDot(line_text, pos.character as usize) { |
| 6fc9e6d | 163 | let Some(merged) = self.mergedSource(uri, &sanitized) else { |
| 6fc9e6d | 164 | return Vec::new(); |
| 6fc9e6d | 165 | }; |
| 6fc9e6d | 166 | let (global_env, methods, enum_variants, enum_params) = |
| 6fc9e6d | 167 | plum_checker::buildGlobalTables(&merged); |
| 6fc9e6d | 168 | let min_required = plum_checker::buildMinRequiredArgs(&merged); |
| 6fc9e6d | 169 | let ctx = plum_checker::CheckCtx { |
| 6fc9e6d | 170 | methods: &methods, |
| 6fc9e6d | 171 | enum_variants: &enum_variants, |
| 6fc9e6d | 172 | enum_params: &enum_params, |
| 6fc9e6d | 173 | min_required: &min_required, |
| 6fc9e6d | 174 | }; |
| 6fc9e6d | 175 | let cursor_fn = completion::enclosingFn(&own_source, pos.line as usize + 1); |
| 6fc9e6d | 176 | return completion::memberItems(cursor_fn, &receiver, &global_env, &ctx); |
| 6fc9e6d | 177 | } |
| 6fc9e6d | 178 | |
| 6fc9e6d | 179 | let mut items = completion::keywordItems(); |
| 6fc9e6d | 180 | let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new(); |
| 6fc9e6d | 181 | let pushUnique = |item: CompletionItem, |
| 6fc9e6d | 182 | items: &mut Vec<CompletionItem>, |
| 6fc9e6d | 183 | seen: &mut std::collections::HashSet<String>| { |
| 6fc9e6d | 184 | if seen.insert(item.label.clone()) { |
| 6fc9e6d | 185 | items.push(item); |
| 6fc9e6d | 186 | } |
| 6fc9e6d | 187 | }; |
| 6fc9e6d | 188 | |
| 6fc9e6d | 189 | // The merged source (own file + imports) if it loads cleanly, else |
| 6fc9e6d | 190 | // just the document's own declarations — still useful mid-edit. |
| 6fc9e6d | 191 | let base_source = self.mergedSource(uri, &sanitized); |
| 6fc9e6d | 192 | for item in completion::topLevelItems(base_source.as_ref().unwrap_or(&own_source)) { |
| 6fc9e6d | 193 | pushUnique(item, &mut items, &mut seen); |
| 6fc9e6d | 194 | } |
| 6fc9e6d | 195 | |
| 6fc9e6d | 196 | let stdIndex = self |
| 6fc9e6d | 197 | .stdIndex |
| 6fc9e6d | 198 | .get_or_init(|| completion::stdLibIndex(&self.libPath)); |
| 6fc9e6d | 199 | for (name, import_path) in stdIndex { |
| 6fc9e6d | 200 | if seen.contains(name) { |
| 6fc9e6d | 201 | continue; |
| 6fc9e6d | 202 | } |
| 6fc9e6d | 203 | let already_imported = own_source.imports.iter().any(|i| &i.path == import_path); |
| 6fc9e6d | 204 | if already_imported { |
| 6fc9e6d | 205 | continue; |
| 6fc9e6d | 206 | } |
| 6fc9e6d | 207 | items.push(CompletionItem { |
| 6fc9e6d | 208 | label: name.clone(), |
| 6fc9e6d | 209 | kind: Some(CompletionItemKind::CLASS), |
| 6fc9e6d | 210 | detail: Some(format!("{import_path} (not imported)")), |
| 6fc9e6d | 211 | additional_text_edits: Some(vec![completion::importInsertionEdit( |
| 6fc9e6d | 212 | &text, |
| 6fc9e6d | 213 | import_path, |
| 6fc9e6d | 214 | )]), |
| 6fc9e6d | 215 | ..Default::default() |
| 6fc9e6d | 216 | }); |
| 6fc9e6d | 217 | } |
| 6fc9e6d | 218 | |
| 6fc9e6d | 219 | items |
| 6fc9e6d | 220 | } |
| 6fc9e6d | 221 | |
| 6fc9e6d | 222 | async fn publishAll(&self, uri: Url) { |
| 6fc9e6d | 223 | let text = match self.docs.lock().await.get(&uri) { |
| 6fc9e6d | 224 | Some(t) => t.clone(), |
| 6fc9e6d | 225 | None => return, |
| 6fc9e6d | 226 | }; |
| 6fc9e6d | 227 | let tree = Self::parseTree(&text); |
| 6fc9e6d | 228 | let mut diagnostics = Self::syntaxDiagnostics(&tree); |
| 6fc9e6d | 229 | if diagnostics.is_empty() { |
| 6fc9e6d | 230 | if let Some(mut typeErrors) = self.typeDiagnostics(&uri, &text) { |
| 6fc9e6d | 231 | diagnostics.append(&mut typeErrors); |
| 6fc9e6d | 232 | } |
| 6fc9e6d | 233 | } |
| 6fc9e6d | 234 | self.client |
| 6fc9e6d | 235 | .publish_diagnostics(uri, diagnostics, None) |
| 6fc9e6d | 236 | .await; |
| 6fc9e6d | 237 | } |
| 6fc9e6d | 238 | } |
| 6fc9e6d | 239 | |
| 6fc9e6d | 240 | #[tower_lsp::async_trait] |
| 6fc9e6d | 241 | impl LanguageServer for Backend { |
| 6fc9e6d | 242 | async fn initialize(&self, _: InitializeParams) -> RpcResult<InitializeResult> { |
| 6fc9e6d | 243 | Ok(InitializeResult { |
| 6fc9e6d | 244 | capabilities: ServerCapabilities { |
| 6fc9e6d | 245 | text_document_sync: Some(TextDocumentSyncCapability::Kind( |
| 6fc9e6d | 246 | TextDocumentSyncKind::FULL, |
| 6fc9e6d | 247 | )), |
| 6fc9e6d | 248 | completion_provider: Some(CompletionOptions { |
| 6fc9e6d | 249 | trigger_characters: Some(vec![".".to_string()]), |
| 6fc9e6d | 250 | ..Default::default() |
| 6fc9e6d | 251 | }), |
| 6fc9e6d | 252 | ..Default::default() |
| 6fc9e6d | 253 | }, |
| 6fc9e6d | 254 | server_info: Some(ServerInfo { |
| 6fc9e6d | 255 | name: "plum-lsp".into(), |
| 6fc9e6d | 256 | version: Some(env!("CARGO_PKG_VERSION").into()), |
| 6fc9e6d | 257 | }), |
| 6fc9e6d | 258 | }) |
| 6fc9e6d | 259 | } |
| 6fc9e6d | 260 | |
| 6fc9e6d | 261 | async fn initialized(&self, _: InitializedParams) { |
| 6fc9e6d | 262 | self.client |
| 6fc9e6d | 263 | .log_message(MessageType::INFO, "plum-lsp initialized") |
| 6fc9e6d | 264 | .await; |
| 6fc9e6d | 265 | } |
| 6fc9e6d | 266 | |
| 6fc9e6d | 267 | async fn shutdown(&self) -> RpcResult<()> { |
| 6fc9e6d | 268 | Ok(()) |
| 6fc9e6d | 269 | } |
| 6fc9e6d | 270 | |
| 6fc9e6d | 271 | async fn did_open(&self, params: DidOpenTextDocumentParams) { |
| 6fc9e6d | 272 | let uri = params.text_document.uri; |
| 6fc9e6d | 273 | self.docs |
| 6fc9e6d | 274 | .lock() |
| 6fc9e6d | 275 | .await |
| 6fc9e6d | 276 | .insert(uri.clone(), params.text_document.text); |
| 6fc9e6d | 277 | self.publishAll(uri).await; |
| 6fc9e6d | 278 | } |
| 6fc9e6d | 279 | |
| 6fc9e6d | 280 | async fn did_change(&self, mut params: DidChangeTextDocumentParams) { |
| 6fc9e6d | 281 | let uri = params.text_document.uri; |
| 6fc9e6d | 282 | // FULL sync: the last content change carries the whole new text. |
| 6fc9e6d | 283 | if let Some(change) = params.content_changes.pop() { |
| 6fc9e6d | 284 | self.docs.lock().await.insert(uri.clone(), change.text); |
| 6fc9e6d | 285 | } |
| 6fc9e6d | 286 | self.publishAll(uri).await; |
| 6fc9e6d | 287 | } |
| 6fc9e6d | 288 | |
| 6fc9e6d | 289 | async fn did_save(&self, params: DidSaveTextDocumentParams) { |
| 6fc9e6d | 290 | self.publishAll(params.text_document.uri).await; |
| 6fc9e6d | 291 | } |
| 6fc9e6d | 292 | |
| 6fc9e6d | 293 | async fn did_close(&self, params: DidCloseTextDocumentParams) { |
| 6fc9e6d | 294 | self.docs.lock().await.remove(¶ms.text_document.uri); |
| 6fc9e6d | 295 | } |
| 6fc9e6d | 296 | |
| 6fc9e6d | 297 | async fn completion(&self, params: CompletionParams) -> RpcResult<Option<CompletionResponse>> { |
| 6fc9e6d | 298 | let uri = params.text_document_position.text_document.uri; |
| 6fc9e6d | 299 | let pos = params.text_document_position.position; |
| 6fc9e6d | 300 | Ok(Some(CompletionResponse::Array( |
| 6fc9e6d | 301 | self.completionItems(&uri, pos).await, |
| 6fc9e6d | 302 | ))) |
| 6fc9e6d | 303 | } |
| 6fc9e6d | 304 | } |
| 6fc9e6d | 305 | |
| 6fc9e6d | 306 | #[tokio::main] |
| 6fc9e6d | 307 | async fn main() { |
| 6fc9e6d | 308 | // `--lib-path` mirrors `plum-cli`'s own required flag (see |
| 6fc9e6d | 309 | // plum-cli/src/main.rs's `Command::Run`/`Compile`) — the checker has no |
| 6fc9e6d | 310 | // built-in default location for the std lib. It's the directory |
| 6fc9e6d | 311 | // CONTAINING `plum-std/` (an `import std/Bool` resolves to |
| 6fc9e6d | 312 | // `<lib_path>/plum-std/Bool.plum` — see `plum_core::loader::loadImport`), |
| 6fc9e6d | 313 | // not `plum-std` itself; the repo root is `.` when run from a checkout. |
| 6fc9e6d | 314 | let libPath = std::env::args() |
| 6fc9e6d | 315 | .collect::<Vec<_>>() |
| 6fc9e6d | 316 | .windows(2) |
| 6fc9e6d | 317 | .find(|w| w[0] == "--lib-path") |
| 6fc9e6d | 318 | .map(|w| PathBuf::from(&w[1])) |
| 6fc9e6d | 319 | .unwrap_or_else(|| PathBuf::from(".")); |
| 6fc9e6d | 320 | |
| 6fc9e6d | 321 | let stdin = tokio::io::stdin(); |
| 6fc9e6d | 322 | let stdout = tokio::io::stdout(); |
| 6fc9e6d | 323 | |
| 6fc9e6d | 324 | let (service, socket) = LspService::new(|client| Backend { |
| 6fc9e6d | 325 | client, |
| 6fc9e6d | 326 | docs: Mutex::new(HashMap::new()), |
| 6fc9e6d | 327 | libPath, |
| 6fc9e6d | 328 | stdIndex: OnceLock::new(), |
| 6fc9e6d | 329 | }); |
| 6fc9e6d | 330 | Server::new(stdin, stdout, socket).serve(service).await; |
| 6fc9e6d | 331 | } |