// extension.js — the Klammertext VS Code extension. // // Everything structural (diagnostics, reindentation, table alignment, // delimiter matching) comes from the Klammertext language server // (klammertext_ls.py), which itself runs the shared editor core used by the // Sublime Text and Vim integrations. This file is glue: it spawns the // server and speaks the Language Server Protocol to it directly — the // framing and dispatch below are small, so the extension has NO npm // dependencies and no build step (deliberately, matching Klammertext's // no-third-party-libraries ethos). // // What the extension wires up: // * document sync (full text) for klammertext documents // * publishDiagnostics -> the Problems panel // * Format Document / Format Selection -> textDocument/(range)formatting // (structural reindentation; explicit-only — no format-on-type) // * occurrences highlighting -> textDocument/documentHighlight (the // matching delimiter lights up as the cursor sits on one) // * Go to Definition on a delimiter -> its matching delimiter // * klammertext.jumpToMatch (Ctrl+Alt+J) -> move the cursor to the match // * klammertext.alignTable (Ctrl+Alt+A) -> workspace/executeCommand; the // server answers with workspace/applyEdit // // The server is located via the klammertext.serverPath setting, a vendored // copy next to this file (the editing-zip layout), ../shared/ relative to // it (the Klammertext repository layout), or $KLAMMERTEXT_HOME. 'use strict'; const vscode = require('vscode'); const cp = require('child_process'); const fs = require('fs'); const path = require('path'); // --- a minimal LSP client over a child process ----------------------------- class LspClient { constructor(command, args, log) { this.log = log; this.nextId = 1; this.pending = new Map(); // id -> {resolve, reject} this.handlers = new Map(); // method -> fn(params, id) this.buffer = Buffer.alloc(0); this.dead = false; this.proc = cp.spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] }); this.proc.stdout.on('data', (chunk) => this._onData(chunk)); this.proc.stderr.on('data', (chunk) => log(chunk.toString())); this.proc.on('error', (err) => { this.dead = true; log('spawn error: ' + err.message); }); this.proc.on('exit', (code) => { this.dead = true; log('server exited: ' + code); }); } _onData(chunk) { this.buffer = Buffer.concat([this.buffer, chunk]); for (;;) { const sep = this.buffer.indexOf('\r\n\r\n'); if (sep === -1) return; const header = this.buffer.slice(0, sep).toString(); const m = /content-length:\s*(\d+)/i.exec(header); if (!m) { this.buffer = this.buffer.slice(sep + 4); continue; } const length = parseInt(m[1], 10); if (this.buffer.length < sep + 4 + length) return; const body = this.buffer.slice(sep + 4, sep + 4 + length).toString(); this.buffer = this.buffer.slice(sep + 4 + length); let msg; try { msg = JSON.parse(body); } catch (e) { continue; } this._dispatch(msg); } } _dispatch(msg) { if (msg.method !== undefined) { const handler = this.handlers.get(msg.method); if (handler) { Promise.resolve(handler(msg.params, msg.id)).then((result) => { if (msg.id !== undefined && msg.id !== null) { this._send({ jsonrpc: '2.0', id: msg.id, result: result === undefined ? null : result }); } }); } else if (msg.id !== undefined && msg.id !== null) { this._send({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'method not found' } }); } } else if (this.pending.has(msg.id)) { const p = this.pending.get(msg.id); this.pending.delete(msg.id); if (msg.error) p.reject(new Error(msg.error.message)); else p.resolve(msg.result); } } _send(msg) { if (this.dead) return; const body = Buffer.from(JSON.stringify(msg), 'utf8'); this.proc.stdin.write('Content-Length: ' + body.length + '\r\n\r\n'); this.proc.stdin.write(body); } request(method, params) { if (this.dead) return Promise.reject(new Error('server not running')); const id = this.nextId++; return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); this._send({ jsonrpc: '2.0', id, method, params }); }); } notify(method, params) { this._send({ jsonrpc: '2.0', method, params }); } onRequest(method, handler) { this.handlers.set(method, handler); } stop() { if (this.dead) return; this.request('shutdown', null).then( () => { this.notify('exit', null); }, () => { try { this.proc.kill(); } catch (e) { /* gone */ } }); } } // --- LSP <-> VS Code conversions ------------------------------------------- function toVsRange(r) { return new vscode.Range(r.start.line, r.start.character, r.end.line, r.end.character); } function toVsEdits(edits) { return (edits || []).map((e) => new vscode.TextEdit(toVsRange(e.range), e.newText)); } function fromVsPosition(p) { return { line: p.line, character: p.character }; } function docParams(document) { return { textDocument: { uri: document.uri.toString() } }; } // --- locating the server --------------------------------------------------- function findServer(context) { const configured = vscode.workspace.getConfiguration('klammertext').get('serverPath'); const candidates = []; if (configured) candidates.push(configured); candidates.push(path.join(context.extensionPath, 'klammertext_ls.py')); candidates.push(path.join(context.extensionPath, '..', 'shared', 'klammertext_ls.py')); if (process.env.KLAMMERTEXT_HOME) { candidates.push(path.join(process.env.KLAMMERTEXT_HOME, 'doc', 'edit', 'shared', 'klammertext_ls.py')); } return candidates.find((c) => { try { return fs.statSync(c).isFile(); } catch (e) { return false; } }); } // --- activation ------------------------------------------------------------ let client = null; function activate(context) { const output = vscode.window.createOutputChannel('Klammertext'); const log = (s) => output.append(s.endsWith('\n') ? s : s + '\n'); const serverPath = findServer(context); if (!serverPath) { vscode.window.showWarningMessage( 'Klammertext: cannot locate klammertext_ls.py — set the ' + 'klammertext.serverPath setting. Highlighting works; ' + 'diagnostics, formatting, matching and alignment need the server.'); return; } const python = vscode.workspace.getConfiguration('klammertext').get('pythonPath') || 'python3'; client = new LspClient(python, [serverPath], log); client.proc.on('error', (err) => { vscode.window.showWarningMessage( 'Klammertext: could not start the language server (' + err.message + ') — check the klammertext.pythonPath setting.'); }); const diagnostics = vscode.languages.createDiagnosticCollection('klammertext'); context.subscriptions.push(diagnostics, output); client.onRequest('textDocument/publishDiagnostics', (params) => { diagnostics.set(vscode.Uri.parse(params.uri), (params.diagnostics || []).map((d) => { const diag = new vscode.Diagnostic( toVsRange(d.range), d.message, d.severity === 1 ? vscode.DiagnosticSeverity.Error : vscode.DiagnosticSeverity.Warning); diag.source = d.source; return diag; })); }); client.onRequest('workspace/applyEdit', (params) => { const we = new vscode.WorkspaceEdit(); const changes = (params.edit && params.edit.changes) || {}; for (const uri of Object.keys(changes)) { for (const e of changes[uri]) { we.replace(vscode.Uri.parse(uri), toVsRange(e.range), e.newText); } } return vscode.workspace.applyEdit(we).then((applied) => ({ applied })); }); client.onRequest('window/showMessage', (params) => { vscode.window.setStatusBarMessage(params.message, 5000); }); // -- document sync (full text) -- const isKt = (doc) => doc.languageId === 'klammertext'; const open = (doc) => { if (!isKt(doc)) return; client.notify('textDocument/didOpen', { textDocument: { uri: doc.uri.toString(), languageId: 'klammertext', version: doc.version, text: doc.getText() }, }); }; client.request('initialize', { processId: process.pid, rootUri: null, capabilities: {}, }).then(() => { client.notify('initialized', {}); vscode.workspace.textDocuments.forEach(open); }, (err) => log('initialize failed: ' + err.message)); context.subscriptions.push( vscode.workspace.onDidOpenTextDocument(open), vscode.workspace.onDidChangeTextDocument((event) => { if (!isKt(event.document)) return; client.notify('textDocument/didChange', { textDocument: { uri: event.document.uri.toString(), version: event.document.version }, contentChanges: [{ text: event.document.getText() }], }); }), vscode.workspace.onDidCloseTextDocument((doc) => { if (!isKt(doc)) return; client.notify('textDocument/didClose', docParams(doc)); })); // -- providers -- context.subscriptions.push( vscode.languages.registerDocumentFormattingEditProvider('klammertext', { provideDocumentFormattingEdits(document) { return client.request('textDocument/formatting', Object.assign(docParams(document), { options: {} })) .then(toVsEdits); }, }), vscode.languages.registerDocumentRangeFormattingEditProvider('klammertext', { provideDocumentRangeFormattingEdits(document, range) { return client.request('textDocument/rangeFormatting', Object.assign(docParams(document), { range: { start: fromVsPosition(range.start), end: fromVsPosition(range.end) }, options: {}, })).then(toVsEdits); }, }), vscode.languages.registerDocumentHighlightProvider('klammertext', { provideDocumentHighlights(document, position) { return client.request('textDocument/documentHighlight', Object.assign(docParams(document), { position: fromVsPosition(position) })) .then((result) => (result || []).map((h) => new vscode.DocumentHighlight(toVsRange(h.range)))); }, }), vscode.languages.registerDefinitionProvider('klammertext', { provideDefinition(document, position) { return client.request('textDocument/definition', Object.assign(docParams(document), { position: fromVsPosition(position) })) .then((result) => result ? new vscode.Location(vscode.Uri.parse(result.uri), toVsRange(result.range)) : null); }, })); // -- live match/mismatch decorations -- // Drawn on every cursor move from the server's klammertext/matchInfo. // Deliberately NOT left to occurrence highlighting: VS Code only asks // documentHighlight providers when the cursor is on a word, so a bare @ // close would never light up — and a mismatch could not show in red. const matchDecoration = vscode.window.createTextEditorDecorationType({ border: '1px solid', borderColor: new vscode.ThemeColor('editorBracketMatch.border'), backgroundColor: new vscode.ThemeColor('editorBracketMatch.background'), }); const mismatchDecoration = vscode.window.createTextEditorDecorationType({ border: '1px solid #ff5555', fontWeight: 'bold', }); context.subscriptions.push(matchDecoration, mismatchDecoration); const updateMatchDecorations = (editor) => { if (!editor || !isKt(editor.document)) return; client.request('klammertext/matchInfo', Object.assign(docParams(editor.document), { position: fromVsPosition(editor.selection.active) })) .then((info) => { if (!info) { editor.setDecorations(matchDecoration, []); editor.setDecorations(mismatchDecoration, []); return; } const ranges = [toVsRange(info.token)]; if (info.matchToken) ranges.push(toVsRange(info.matchToken)); if (info.mismatch) { editor.setDecorations(matchDecoration, []); editor.setDecorations(mismatchDecoration, ranges); if (info.message) { vscode.window.setStatusBarMessage( 'Klammertext: ' + info.message, 5000); } } else { editor.setDecorations(mismatchDecoration, []); editor.setDecorations(matchDecoration, ranges); } }, () => { /* server gone: leave decorations as they are */ }); }; let matchTimer = null; context.subscriptions.push( vscode.window.onDidChangeTextEditorSelection((event) => { if (matchTimer) clearTimeout(matchTimer); matchTimer = setTimeout( () => updateMatchDecorations(event.textEditor), 50); }), vscode.window.onDidChangeActiveTextEditor( (editor) => updateMatchDecorations(editor))); // -- commands -- context.subscriptions.push( vscode.commands.registerCommand('klammertext.jumpToMatch', () => { const editor = vscode.window.activeTextEditor; if (!editor || !isKt(editor.document)) return; return client.request('textDocument/definition', Object.assign(docParams(editor.document), { position: fromVsPosition(editor.selection.active) })) .then((result) => { if (!result) { vscode.window.setStatusBarMessage( 'Klammertext: no matching delimiter here', 5000); return; } const pos = toVsRange(result.range).start; editor.selection = new vscode.Selection(pos, pos); editor.revealRange(new vscode.Range(pos, pos)); }); }), vscode.commands.registerCommand('klammertext.alignTable', () => { const editor = vscode.window.activeTextEditor; if (!editor || !isKt(editor.document)) return; return client.request('workspace/executeCommand', { command: 'klammertext.alignTable', arguments: [{ uri: editor.document.uri.toString(), position: fromVsPosition(editor.selection.active) }], }); })); } function deactivate() { if (client) client.stop(); client = null; } module.exports = { activate, deactivate };