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:
225
tst/editor/vscode_ext_test.js
Normal file
225
tst/editor/vscode_ext_test.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user