367 lines
16 KiB
JavaScript
367 lines
16 KiB
JavaScript
// 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 };
|