plum

#treesitter#compiler#wasm

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(&params.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
}