Editor support generalized: shared core, language server, Vim and VS Code (from dev eb5baf9cbe59)

doc/edit/ now holds a shared Python implementation of the language's
structural layer (klammertext_edit.py) and a dependency-free language
server (klammertext_ls.py), with integrations for Emacs, Sublime Text,
Vim, and Visual Studio Code.  The editor test suite in tst/ covers the
core's API and CLI, the language server protocol, the VS Code
extension, headless Vim, and Emacs byte-equality.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 15:01:49 +02:00
parent 73ed7f3d5d
commit f855c5ccae
27 changed files with 3918 additions and 1170 deletions

View File

@@ -0,0 +1,225 @@
// vscode_ext_test.js — behavioral test for the Klammertext VS Code
// extension (doc/edit/vscode/extension.js) OUTSIDE VS Code.
//
// The `vscode` module is stubbed with just enough API for activation, and
// the extension then talks to the REAL language server it spawned — so this
// exercises the whole chain: extension glue -> hand-rolled LSP client ->
// klammertext_ls.py -> shared core. Checks: activation + handshake,
// publishDiagnostics reaching the diagnostic collection, Format Document
// equalling the indent fixtures, documentHighlight pairs, and the
// alignTable command round-tripping through workspace/applyEdit.
//
// Runs under any Node >= 16 — including VS Code's own Electron binary
// (ELECTRON_RUN_AS_NODE=1 code vscode_ext_test.js EXT_DIR FIXTURE_DIR).
// Prints PASS/FAIL lines; exits nonzero on any failure.
'use strict';
const fs = require('fs');
const path = require('path');
const Module = require('module');
const extDir = path.resolve(process.argv[2]);
const fixDir = path.resolve(process.argv[3]);
let passed = 0, failed = 0;
function check(name, ok, detail) {
if (ok) { passed++; console.log('PASS ' + name); }
else { failed++; console.log('FAIL ' + name + ' ' + (detail || '')); }
}
// --- the vscode stub -------------------------------------------------------
class Position {
constructor(line, character) { this.line = line; this.character = character; }
}
class Range {
constructor(a, b, c, d) {
if (typeof a === 'number') { this.start = new Position(a, b); this.end = new Position(c, d); }
else { this.start = a; this.end = b; }
}
}
class Selection extends Range {
constructor(a, b) { super(a, b); this.active = b; }
}
class TextEdit {
constructor(range, newText) { this.range = range; this.newText = newText; }
}
class Diagnostic {
constructor(range, message, severity) { this.range = range; this.message = message; this.severity = severity; }
}
class Location {
constructor(uri, range) { this.uri = uri; this.range = range; }
}
class DocumentHighlight {
constructor(range) { this.range = range; }
}
class WorkspaceEdit {
constructor() { this.edits = []; }
replace(uri, range, newText) { this.edits.push({ uri, range, newText }); }
}
const listeners = { open: [], change: [], close: [] };
const providers = {};
const commands = {};
const collections = {};
let appliedEdits = [];
let statusMessages = [];
const fakeDoc = {
uri: { toString: () => 'file:///ext_test.kt' },
languageId: 'klammertext',
version: 1,
text: '@i abc\n',
getText() { return this.text; },
};
const vscodeStub = {
Position, Range, Selection, TextEdit, Diagnostic, Location,
DocumentHighlight, WorkspaceEdit,
Uri: { parse: (s) => ({ toString: () => s }) },
DiagnosticSeverity: { Error: 0, Warning: 1 },
workspace: {
getConfiguration: () => ({ get: (k) => (k === 'pythonPath' ? 'python3' : '') }),
textDocuments: [fakeDoc],
onDidOpenTextDocument: (fn) => { listeners.open.push(fn); return { dispose() {} }; },
onDidChangeTextDocument: (fn) => { listeners.change.push(fn); return { dispose() {} }; },
onDidCloseTextDocument: (fn) => { listeners.close.push(fn); return { dispose() {} }; },
applyEdit: (we) => { appliedEdits.push(we); return Promise.resolve(true); },
},
window: {
createOutputChannel: () => ({ append() {}, dispose() {} }),
showWarningMessage: (m) => { statusMessages.push(m); },
setStatusBarMessage: (m) => { statusMessages.push(m); },
activeTextEditor: null,
},
languages: {
createDiagnosticCollection: (name) => {
const c = {
store: new Map(),
set(uri, diags) { this.store.set(uri.toString(), diags); },
dispose() {},
};
collections[name] = c;
return c;
},
registerDocumentFormattingEditProvider: (lang, p) => { providers.format = p; return { dispose() {} }; },
registerDocumentRangeFormattingEditProvider: (lang, p) => { providers.rangeFormat = p; return { dispose() {} }; },
registerDocumentHighlightProvider: (lang, p) => { providers.highlight = p; return { dispose() {} }; },
registerDefinitionProvider: (lang, p) => { providers.definition = p; return { dispose() {} }; },
},
commands: {
registerCommand: (name, fn) => { commands[name] = fn; return { dispose() {} }; },
},
};
const realResolve = Module._resolveFilename;
Module._resolveFilename = function (request, ...rest) {
if (request === 'vscode') return 'vscode';
return realResolve.call(this, request, ...rest);
};
require.cache.vscode = { id: 'vscode', filename: 'vscode', loaded: true, exports: vscodeStub };
// --- helpers ---------------------------------------------------------------
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
async function waitFor(pred, what, ms = 8000) {
const t0 = Date.now();
while (Date.now() - t0 < ms) {
if (pred()) return true;
await sleep(25);
}
throw new Error('timeout waiting for ' + what);
}
function applyEditsToText(text, edits) {
// Convert {line, character} ranges to offsets (fixtures are ASCII).
const lineStart = [0];
for (let i = 0; i < text.length; i++) if (text[i] === '\n') lineStart.push(i + 1);
const off = (p) => lineStart[p.line] + p.character;
const resolved = edits.map((e) => ({ a: off(e.range.start), b: off(e.range.end), t: e.newText }));
resolved.sort((x, y) => y.a - x.a);
for (const e of resolved) text = text.slice(0, e.a) + e.t + text.slice(e.b);
return text;
}
function setDocText(text) {
fakeDoc.text = text;
fakeDoc.version++;
listeners.change.forEach((fn) => fn({ document: fakeDoc }));
}
// --- the test --------------------------------------------------------------
async function main() {
const ext = require(path.join(extDir, 'extension.js'));
const context = { extensionPath: extDir, subscriptions: [] };
ext.activate(context);
// Activation opens the (unbalanced) preloaded document; diagnostics
// arrive from the real server.
await waitFor(() => (collections.klammertext
&& (collections.klammertext.store.get('file:///ext_test.kt') || []).length > 0),
'diagnostics');
const diags = collections.klammertext.store.get('file:///ext_test.kt');
check('activation + publishDiagnostics',
diags.length === 1 && /never closed/.test(diags[0].message),
JSON.stringify(diags.map((d) => d.message)));
// Format Document == every indent fixture's expected file.
for (const f of ['indent_list', 'indent_document', 'indent_table',
'indent_untouched', 'indent_defs', 'indent_escapes',
'indent_named_close']) {
const src = fs.readFileSync(path.join(fixDir, f + '.kt'), 'utf8');
const exp = fs.readFileSync(path.join(fixDir, f + '_expected.kt'), 'utf8');
setDocText(src);
const edits = await providers.format.provideDocumentFormattingEdits(fakeDoc);
check('format ' + f, applyEditsToText(src, edits) === exp);
}
// Balanced text clears the diagnostics.
setDocText('@i abc @\n');
await waitFor(() => (collections.klammertext.store.get('file:///ext_test.kt') || []).length === 0,
'diagnostics cleared');
check('diagnostics cleared on balanced text', true);
// documentHighlight: the delimiter pair.
const hl = await providers.highlight.provideDocumentHighlights(fakeDoc, new Position(0, 0));
check('documentHighlight pair', hl.length === 2, JSON.stringify(hl));
// jumpToMatch moves the cursor to the close.
vscodeStub.window.activeTextEditor = {
document: fakeDoc,
selection: new Selection(new Position(0, 0), new Position(0, 0)),
revealRange() {},
};
await commands['klammertext.jumpToMatch']();
const sel = vscodeStub.window.activeTextEditor.selection;
check('jumpToMatch cursor at close', sel.active.character === 7,
JSON.stringify(sel));
// alignTable round-trips through workspace/applyEdit.
const src = fs.readFileSync(path.join(fixDir, 'align_mixed.kt'), 'utf8');
const exp = fs.readFileSync(path.join(fixDir, 'align_mixed_expected.kt'), 'utf8');
setDocText(src);
const barOffset = src.indexOf('|');
const barLine = src.slice(0, barOffset).split('\n').length - 1;
const barCol = barOffset - (src.lastIndexOf('\n', barOffset - 1) + 1);
vscodeStub.window.activeTextEditor.selection =
new Selection(new Position(barLine, barCol), new Position(barLine, barCol));
appliedEdits = [];
await commands['klammertext.alignTable']();
await waitFor(() => appliedEdits.length > 0, 'applyEdit');
const edits = appliedEdits[0].edits.map((e) => ({ range: e.range, newText: e.newText }));
check('alignTable via applyEdit', applyEditsToText(src, edits) === exp);
ext.deactivate();
console.log('vscode_ext_test: %d passed, %d failed', passed, failed);
process.exit(failed ? 1 : 0);
}
main().catch((err) => {
console.error('vscode_ext_test: ' + err.stack);
process.exit(1);
});