/** * Origami Language Server — stdio entry point for Neovim * * Adapted from origami-vscode-extension (MIT License) * Copyright (c) 2024 Jan Miksovsky and other contributors * * Starts an LSP server on stdin/stdout. Handles: * - Diagnostics (compile errors) * - Completions (paths, scope, builtins) * - Go-to-definition (local declarations, external files) * - File watching (invalidate folder caches) */ import { fileURLToPath } from "node:url"; import { readFileSync } from "node:fs"; import path from "node:path"; import { TextDocument } from "vscode-languageserver-textdocument"; import { autoComplete, folderChanged } from "./autoComplete.mjs"; import definition from "./definition.mjs"; import * as diagnostics from "./diagnostics.mjs"; import hover from "./hover.mjs"; import { documentSymbols } from "./symbols.mjs"; import languageServerPackage from "vscode-languageserver"; const { DidChangeWatchedFilesNotification, DocumentDiagnosticReportKind, FileChangeType, ProposedFeatures, TextDocumentSyncKind, TextDocuments, createConnection, } = languageServerPackage; /** @type {import("vscode-languageserver").CompletionItem[]} */ let builtinCompletions = []; /** @type {Map} Builtin label → documentation for hover. */ const builtinDocs = new Map(); try { const __dirname = path.dirname(fileURLToPath(import.meta.url)); const builtinsPath = path.join(__dirname, "builtins.json"); const raw = readFileSync(builtinsPath, "utf-8"); builtinCompletions = JSON.parse(raw); // Tag each builtin and build the hover docs map in a single pass. for (const item of builtinCompletions) { item.data = { kind: "builtin" }; if (item.documentation && !item.label.endsWith(":")) { builtinDocs.set(item.label, item.documentation); } } } catch (error) { // If the file can't be read, just ship no builtins — the server still works process.stderr.write( `[origami-language-server] Failed to load builtins: ${error.message}\n`, ); } const connection = createConnection(ProposedFeatures.all); const documents = new TextDocuments(TextDocument); /** @type {string[]} */ let workspaceFolderPaths = []; /** * Ensure a document has been compiled, returning its compile result. * Runs validation eagerly so all handlers have access to compilation data * regardless of whether the client uses push or pull diagnostics. */ function ensureCompiled(document) { if (!document) return null; let result = diagnostics.compileResults.get(document.uri); if (result === undefined) { diagnostics.validate(document); result = diagnostics.compileResults.get(document.uri); } return result; } connection.onInitialize((params) => { workspaceFolderPaths = (params.workspaceFolders ?? []).map((folder) => fileURLToPath(new URL(folder.uri)), ); return { capabilities: { completionProvider: { triggerCharacters: ["/"], }, definitionProvider: true, hoverProvider: true, documentSymbolProvider: true, diagnosticProvider: { interFileDependencies: false, workspaceDiagnostics: false, }, textDocumentSync: TextDocumentSyncKind.Incremental, workspace: { didChangeWatchedFiles: { dynamicRegistration: false, }, }, }, }; }); connection.languages.diagnostics.on(async (params) => { const document = documents.get(params.textDocument.uri); if (!document) { return { kind: DocumentDiagnosticReportKind.Full, items: [] }; } return { kind: DocumentDiagnosticReportKind.Full, items: diagnostics.validate(document), }; }); connection.onCompletion((params) => { const document = documents.get(params.textDocument.uri); if (!document) return builtinCompletions; const compileResult = ensureCompiled(document); return autoComplete( document, params.position, workspaceFolderPaths, compileResult, ).then((serverCompletions) => { // Merge LSP server completions with builtin function completions. // Server completions (paths, scope) take priority; builtins fill the gaps. const serverLabels = new Set(serverCompletions.map((c) => c.label)); const filteredBuiltins = builtinCompletions.filter( (c) => !serverLabels.has(c.label), ); return serverCompletions.concat(filteredBuiltins); }); }); connection.onCompletionResolve((item) => { if (item.documentation) { return item; } if (item.data?.kind !== "builtin") { return item; } const doc = builtinDocs.get(item.label); if (doc) { item.documentation = doc; } return item; }); connection.onDefinition((params) => { const document = documents.get(params.textDocument.uri); if (!document) return []; const compileResult = ensureCompiled(document); return definition( document, params.position, workspaceFolderPaths, compileResult, ).then((result) => { if (!result) { return []; } return result; }); }); connection.onHover((params) => { const document = documents.get(params.textDocument.uri); if (!document) return null; const compileResult = ensureCompiled(document); return hover(document, params.position, compileResult, builtinDocs); }); connection.onDocumentSymbol((params) => { const document = documents.get(params.textDocument.uri); if (!document) return []; const compileResult = ensureCompiled(document); if (!compileResult || compileResult instanceof Error) return []; return documentSymbols(compileResult); }); connection.onNotification( DidChangeWatchedFilesNotification.type, (params) => { for (const change of params.changes) { if ( change.type === FileChangeType.Created || change.type === FileChangeType.Deleted ) { folderChanged(change.uri); } } }, ); documents.onDidClose(({ document }) => { diagnostics.clearResult(document.uri); }); // Invalidate compile cache on every document change so handlers always // work with fresh compilation data instead of waiting for the next // pull-diagnostics request. documents.onDidChangeContent(({ document }) => { diagnostics.clearResult(document.uri); }); // Invalidate folder completion cache on save. Works with all LSP clients // (including Neovim, which does not send workspace/didChangeWatchedFiles // without explicit watcher registration). Clients that do support file // watching also receive updates via the notification handler below. documents.onDidSave(({ document }) => { folderChanged(document.uri); }); documents.listen(connection); connection.listen();