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

@@ -1,45 +1,50 @@
#!/usr/bin/env python3
"""Drive the Sublime Text editor cores over fixture files, outside Sublime.
"""Drive the shared editor core over fixture files.
Usage: editor_driver.py SUBLIME_DIR (MODE INFILE OUTFILE)...
Usage: editor_driver.py SHARED_DIR (MODE INFILE OUTFILE)...
MODE is `indent` or `align`; each triple applies that tool to INFILE and
writes the result to OUTFILE. The Sublime plugin files import without the
`sublime` module (their try/except guard), so the pure cores run under plain
python3. Also asserts the built-in error path (no enclosing table).
MODE is `indent`, `align` (call the shared core's API), or `indent-cli`,
`align-cli` (run the same operation through the core's command-line
interface, as the Vim plugin does). Each triple applies that tool to INFILE
and writes the result to OUTFILE. Also asserts the built-in error paths
(no enclosing table; empty `check` output on balanced fixtures).
Called by editor_test.sh; exits nonzero on an internal error.
"""
import subprocess
import sys
def apply_indent(KI, s):
bols = [0] + [i + 1 for i, ch in enumerate(s)
if ch == '\n' and i + 1 < len(s)]
out = s
for a, b, new in sorted(KI.reindent_lines(s, bols), reverse=True):
out = out[:a] + new + out[b:]
return out
def apply_indent(KE, s):
return KE.indent_text(s)
def apply_align(KA, s):
def apply_align(KE, s):
caret = s.index('|') if '|' in s else 0
span = KA.enclosing_span(s, caret, KA.ALIGN_KLAMMERS)
if span is None:
return s
_name, cs, ce = span
edits, _msg = KA.compute_edits(s[cs:ce])
out = s
for a, b, new in sorted(edits, reverse=True):
out = out[:cs + a] + new + out[cs + b:]
return out
return KE.align_text(s, caret)[0]
def line_col_of_first_bar(s):
pos = s.index('|') if '|' in s else 0
line = s.count('\n', 0, pos) + 1
col = pos - (s.rfind('\n', 0, pos) + 1) + 1
return line, col
def run_cli(script, args, s):
r = subprocess.run([sys.executable, script] + args,
input=s.encode('utf-8'), stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
if r.returncode != 0:
sys.exit("editor_driver.py: CLI failed: %s" % r.stderr.decode())
return r.stdout.decode('utf-8')
def main():
sublime_dir = sys.argv[1]
sys.path.insert(0, sublime_dir)
import Klammertext_indent as KI
import Klammertext_align as KA
shared_dir = sys.argv[1]
sys.path.insert(0, shared_dir)
import klammertext_edit as KE
script = shared_dir + '/klammertext_edit.py'
args = sys.argv[2:]
for k in range(0, len(args), 3):
@@ -47,16 +52,27 @@ def main():
with open(infile) as f:
s = f.read()
if mode == 'indent':
out = apply_indent(KI, s)
out = apply_indent(KE, s)
elif mode == 'align':
out = apply_align(KA, s)
out = apply_align(KE, s)
elif mode == 'indent-cli':
out = run_cli(script, ['indent'], s)
elif mode == 'align-cli':
line, col = line_col_of_first_bar(s)
out = run_cli(script, ['align', str(line), str(col)], s)
else:
sys.exit("editor_driver.py: unknown mode: " + mode)
with open(outfile, 'w') as f:
f.write(out)
# Error path: no enclosing table klammer.
assert KA.enclosing_span("no table here\n", 3, KA.ALIGN_KLAMMERS) is None
# Error and diagnostics paths.
assert KE.enclosing_span("no table here\n", 3, KE.ALIGN_KLAMMERS) is None
assert KE.diagnostics("@i abc @ #[ x ]# ^@\n") == []
probs = KE.diagnostics("@i abc\n@ol x ul@\n")
assert any('never closed' in p['message'] for p in probs), probs
assert any('ul@' in p['message'] for p in probs), probs
assert run_cli(script, ['check'], "@i abc @\n") == ""
assert '1:1:' in run_cli(script, ['check'], "@i abc\n")
if __name__ == '__main__':

262
tst/editor/ls_test.py Normal file
View File

@@ -0,0 +1,262 @@
#!/usr/bin/env python3
"""Protocol test for the Klammertext language server (klammertext_ls.py).
Spawns the server as an LSP client would (JSON-RPC over stdio) and exercises
every capability against the editor fixtures:
* initialize / initialized handshake and the advertised capabilities
* didOpen + publishDiagnostics (clean fixture -> no diagnostics;
unbalanced text -> the expected errors)
* textDocument/formatting on every indent fixture == its expected file
* textDocument/rangeFormatting (a sub-range only)
* textDocument/documentHighlight + definition (the delimiter matcher)
* workspace/executeCommand klammertext.alignTable -> workspace/applyEdit,
applied result == every align fixture's expected file
* shutdown / exit (exit code 0)
Usage: ls_test.py SHARED_DIR FIXTURE_DIR
Prints one PASS/FAIL line per check; exits nonzero on any failure.
"""
import json
import subprocess
import sys
class Client:
"""A minimal scripted LSP client over a server subprocess."""
def __init__(self, server_path):
self.proc = subprocess.Popen(
[sys.executable, server_path],
stdin=subprocess.PIPE, stdout=subprocess.PIPE)
self.next_id = 1
self.notifications = [] # collected server notifications
self.server_requests = [] # collected server->client requests
def send(self, message):
body = json.dumps(message).encode('utf-8')
self.proc.stdin.write(b'Content-Length: %d\r\n\r\n' % len(body) + body)
self.proc.stdin.flush()
def read_message(self):
length = None
while True:
line = self.proc.stdout.readline()
if not line:
return None
line = line.strip()
if not line:
break
if line.lower().startswith(b'content-length:'):
length = int(line.split(b':', 1)[1])
return json.loads(self.proc.stdout.read(length).decode('utf-8'))
def request(self, method, params):
"""Send a request and pump messages until its response arrives."""
msg_id = self.next_id
self.next_id += 1
self.send({'jsonrpc': '2.0', 'id': msg_id,
'method': method, 'params': params})
while True:
msg = self.read_message()
assert msg is not None, 'server closed during ' + method
if msg.get('id') == msg_id and 'method' not in msg:
assert 'error' not in msg, 'error response: %r' % msg
return msg.get('result')
self._collect(msg)
def notify(self, method, params):
self.send({'jsonrpc': '2.0', 'method': method, 'params': params})
def _collect(self, msg):
if 'method' in msg and msg.get('id') is not None:
self.server_requests.append(msg)
# acknowledge server->client requests (e.g. applyEdit)
self.send({'jsonrpc': '2.0', 'id': msg['id'],
'result': {'applied': True}})
elif 'method' in msg:
self.notifications.append(msg)
def wait_notification(self, method):
"""Pump messages until a notification with METHOD arrives."""
while True:
for i, msg in enumerate(self.notifications):
if msg['method'] == method:
return self.notifications.pop(i)['params']
msg = self.read_message()
assert msg is not None, 'server closed waiting for ' + method
self._collect(msg)
def wait_server_request(self, method):
while True:
for i, msg in enumerate(self.server_requests):
if msg['method'] == method:
return self.server_requests.pop(i)['params']
msg = self.read_message()
assert msg is not None, 'server closed waiting for ' + method
self._collect(msg)
PASS = 0
FAIL = 0
def check(name, ok, detail=''):
global PASS, FAIL
if ok:
PASS += 1
print('PASS %s' % name)
else:
FAIL += 1
print('FAIL %s %s' % (name, detail))
def apply_edits(ls, text, edits):
"""Apply LSP TextEdits to TEXT (offsets via the server's own helpers)."""
resolved = [(ls.pos_to_offset(text, e['range']['start']),
ls.pos_to_offset(text, e['range']['end']),
e['newText']) for e in edits]
for a, b, new in sorted(resolved, reverse=True):
text = text[:a] + new + text[b:]
return text
def main():
shared_dir, fixture_dir = sys.argv[1], sys.argv[2]
sys.path.insert(0, shared_dir)
import klammertext_ls as ls
client = Client(shared_dir + '/klammertext_ls.py')
uri = 'file:///ls_test.kt'
# -- handshake --
result = client.request('initialize', {'capabilities': {}})
caps = result.get('capabilities', {})
check('initialize capabilities',
caps.get('documentFormattingProvider') is True
and caps.get('documentHighlightProvider') is True
and 'klammertext.alignTable'
in caps.get('executeCommandProvider', {}).get('commands', []))
client.notify('initialized', {})
# -- diagnostics: clean fixture, then unbalanced text --
text = open(fixture_dir + '/indent_list_expected.kt').read()
client.notify('textDocument/didOpen',
{'textDocument': {'uri': uri, 'languageId': 'klammertext',
'version': 1, 'text': text}})
diags = client.wait_notification('textDocument/publishDiagnostics')
check('clean fixture: no diagnostics', diags['diagnostics'] == [],
repr(diags['diagnostics']))
bad = '@i abc\n@ol x ul@\n'
client.notify('textDocument/didChange',
{'textDocument': {'uri': uri, 'version': 2},
'contentChanges': [{'text': bad}]})
diags = client.wait_notification('textDocument/publishDiagnostics')
msgs = [d['message'] for d in diags['diagnostics']]
check('unbalanced text: diagnostics',
any('@i' in m and 'never closed' in m for m in msgs)
and any('ul@' in m for m in msgs), repr(msgs))
# -- formatting == every indent fixture's expected file --
indent_fixtures = ['indent_list', 'indent_document', 'indent_table',
'indent_untouched', 'indent_defs', 'indent_escapes',
'indent_named_close']
for f in indent_fixtures:
src = open(fixture_dir + '/%s.kt' % f).read()
exp = open(fixture_dir + '/%s_expected.kt' % f).read()
client.notify('textDocument/didChange',
{'textDocument': {'uri': uri, 'version': 3},
'contentChanges': [{'text': src}]})
client.wait_notification('textDocument/publishDiagnostics')
edits = client.request('textDocument/formatting',
{'textDocument': {'uri': uri},
'options': {'tabSize': 2,
'insertSpaces': True}})
check('formatting %s' % f, apply_edits(ls, src, edits or []) == exp)
# -- rangeFormatting: only the requested lines change --
src = '@ol\nzero\n one\n@\n'
client.notify('textDocument/didChange',
{'textDocument': {'uri': uri, 'version': 4},
'contentChanges': [{'text': src}]})
client.wait_notification('textDocument/publishDiagnostics')
edits = client.request('textDocument/rangeFormatting',
{'textDocument': {'uri': uri},
'range': {'start': {'line': 2, 'character': 0},
'end': {'line': 2, 'character': 0}},
'options': {}})
check('rangeFormatting touches only its lines',
apply_edits(ls, src, edits or []) == '@ol\nzero\n one\n@\n')
# -- matcher: documentHighlight + definition --
src = '@i abc @\n'
client.notify('textDocument/didChange',
{'textDocument': {'uri': uri, 'version': 5},
'contentChanges': [{'text': src}]})
client.wait_notification('textDocument/publishDiagnostics')
hl = client.request('textDocument/documentHighlight',
{'textDocument': {'uri': uri},
'position': {'line': 0, 'character': 0}})
check('documentHighlight pair', hl is not None and len(hl) == 2, repr(hl))
defn = client.request('textDocument/definition',
{'textDocument': {'uri': uri},
'position': {'line': 0, 'character': 0}})
check('definition = the matching close',
defn is not None and defn['range']['start']['character'] == 7,
repr(defn))
off = client.request('textDocument/documentHighlight',
{'textDocument': {'uri': uri},
'position': {'line': 0, 'character': 4}})
check('documentHighlight off-delimiter is null', off is None, repr(off))
# -- alignTable via executeCommand -> applyEdit, on every align fixture --
align_fixtures = ['align_mixed', 'align_empty_cells', 'align_boundary',
'align_colspan', 'align_escapes']
for f in align_fixtures:
src = open(fixture_dir + '/%s.kt' % f).read()
exp = open(fixture_dir + '/%s_expected.kt' % f).read()
client.notify('textDocument/didChange',
{'textDocument': {'uri': uri, 'version': 6},
'contentChanges': [{'text': src}]})
client.wait_notification('textDocument/publishDiagnostics')
pos = ls.offset_to_pos(src, src.index('|'))
client.request('workspace/executeCommand',
{'command': 'klammertext.alignTable',
'arguments': [{'uri': uri, 'position': pos}]})
if src == exp: # already-aligned fixture: no applyEdit
continue
params = client.wait_server_request('workspace/applyEdit')
edits = params['edit']['changes'][uri]
check('alignTable %s' % f, apply_edits(ls, src, edits) == exp)
# align_too_wide must produce NO applyEdit (limit refusal) — verify via
# a message arriving with no pending applyEdit request.
src = open(fixture_dir + '/align_too_wide.kt').read()
client.notify('textDocument/didChange',
{'textDocument': {'uri': uri, 'version': 7},
'contentChanges': [{'text': src}]})
client.wait_notification('textDocument/publishDiagnostics')
client.notifications.clear() # drop showMessages from the loop above
pos = ls.offset_to_pos(src, src.index('|'))
client.request('workspace/executeCommand',
{'command': 'klammertext.alignTable',
'arguments': [{'uri': uri, 'position': pos}]})
msg = client.wait_notification('window/showMessage')
check('alignTable respects ROW_MAX (refusal message, no edit)',
'not aligning' in msg['message'] and not client.server_requests,
repr(msg))
# -- shutdown --
client.request('shutdown', None)
client.notify('exit', None)
code = client.proc.wait(timeout=10)
check('clean exit', code == 0, 'exit code %d' % code)
print('ls_test: %d passed, %d failed' % (PASS, FAIL))
sys.exit(1 if FAIL else 0)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,179 @@
" vim_feature_test.vim — comprehensive checks of the Klammertext Vim plugin,
" beyond the fixture runs in editor_test.sh (which cover the Reindent and
" Align commands' output bytes). Run headlessly:
"
" KT_FIX=<fixture dir> KT_OUT=<result file> \
" vim -N -n -u NONE -i NONE -es --not-a-term \
" --cmd 'set rtp^=<doc/edit/vim>' -c 'source vim_feature_test.vim'
"
" What is checked:
" * ftdetect: .kt maps to the klammertext filetype, overriding the stock
" runtime's .kt -> kotlin mapping
" * syntax: token classes at known positions (three @-tiers open/close,
" removal line/block/file with markers, verbatim @code interior,
" ^-escapes, abbreviated @name-arg)
" * commands: KlammertextJumpToMatch (open->close, close->open,
" multibyte columns), KlammertextCheck (location list)
" * with +python3: 'indentexpr' drives the = operator (gg=G equals the
" expected fixture), and the CursorMoved match highlighter sets
" KlammertextMatch / KlammertextMismatch matches
"
" Writes PASS/FAIL lines to $KT_OUT; exits nonzero if anything failed.
let s:results = []
function! s:Check(name, ok, ...) abort
let detail = (a:ok || !a:0) ? '' : ' -- ' . string(a:1)
call add(s:results, (a:ok ? 'PASS ' : 'FAIL ') . a:name . detail)
endfunction
function! s:Syn(l, c) abort
return synIDattr(synID(a:l, a:c, 1), 'name')
endfunction
filetype plugin on
if has('syntax')
syntax enable
endif
" --- ftdetect: .kt is klammertext (the Kotlin override) --------------------
execute 'edit! ' . fnameescape($KT_FIX . '/indent_list.kt')
call s:Check('ftdetect .kt -> klammertext (overrides kotlin)',
\ &filetype ==# 'klammertext', &filetype)
" --- syntax token classes ---------------------------------------------------
if has('syntax')
enew!
call setline(1, [
\ '@i abc @ x',
\ '@@def : x @@',
\ '@@@target x @@@',
\ '# removed line',
\ '#[ rem ]# after',
\ '^@ escaped',
\ '@code x @ y code@',
\ 'name@ x',
\ '@name-arg x',
\ '## tail',
\ 'still removed'])
set filetype=klammertext
call s:Check('syntax loaded', b:current_syntax ==# 'klammertext')
call s:Check('app open @i', s:Syn(1, 1) ==# 'klammertextAppOpen', s:Syn(1, 1))
call s:Check('app bare close', s:Syn(1, 8) ==# 'klammertextAppClose', s:Syn(1, 8))
call s:Check('plain text', s:Syn(1, 10) ==# '', s:Syn(1, 10))
call s:Check('def open @@def', s:Syn(2, 1) ==# 'klammertextDefOpen', s:Syn(2, 1))
call s:Check('def bare close', s:Syn(2, 11) ==# 'klammertextDefClose', s:Syn(2, 11))
call s:Check('sys open @@@target', s:Syn(3, 1) ==# 'klammertextSysOpen', s:Syn(3, 1))
call s:Check('sys bare close', s:Syn(3, 13) ==# 'klammertextSysClose', s:Syn(3, 13))
call s:Check('line marker #', s:Syn(4, 1) ==# 'klammertextMarkerLine', s:Syn(4, 1))
call s:Check('line removed text', s:Syn(4, 5) ==# 'klammertextRemovedLine', s:Syn(4, 5))
call s:Check('block marker #[', s:Syn(5, 1) ==# 'klammertextMarker', s:Syn(5, 1))
call s:Check('block removed text', s:Syn(5, 5) ==# 'klammertextRemovedBlock', s:Syn(5, 5))
call s:Check('block marker ]#', s:Syn(5, 8) ==# 'klammertextMarker', s:Syn(5, 8))
call s:Check('text after block', s:Syn(5, 12) ==# '', s:Syn(5, 12))
call s:Check('escape ^@ consumed', s:Syn(6, 1) ==# 'klammertextEscape'
\ && s:Syn(6, 2) ==# 'klammertextEscape', s:Syn(6, 2))
call s:Check('verbatim open', s:Syn(7, 1) ==# 'klammertextAppOpen', s:Syn(7, 1))
call s:Check('verbatim interior @', s:Syn(7, 9) ==# 'klammertextVerbatim', s:Syn(7, 9))
call s:Check('verbatim close', s:Syn(7, 17) ==# 'klammertextAppClose', s:Syn(7, 17))
call s:Check('named close name@', s:Syn(8, 1) ==# 'klammertextAppClose'
\ && s:Syn(8, 5) ==# 'klammertextAppClose', s:Syn(8, 1))
call s:Check('abbreviated @name-', s:Syn(9, 1) ==# 'klammertextAppOpen'
\ && s:Syn(9, 6) ==# '' && s:Syn(9, 7) ==# '', s:Syn(9, 6))
call s:Check('file marker ##', s:Syn(10, 1) ==# 'klammertextMarker', s:Syn(10, 1))
call s:Check('## removes to EOF', s:Syn(11, 3) ==# 'klammertextRemovedFile', s:Syn(11, 3))
else
call add(s:results, 'SKIP syntax checks (this Vim lacks +syntax)')
endif
" --- jump-to-match (the CLI shell-out path) ---------------------------------
enew!
call setline(1, ['@i abc @ x'])
set filetype=klammertext
call cursor(1, 1)
KlammertextJumpToMatch
call s:Check('jump open -> close', col('.') == 8, col('.'))
KlammertextJumpToMatch
call s:Check('jump close -> open', col('.') == 1, col('.'))
" multibyte: 2-byte umlauts before the delimiters
enew!
call setline(1, ['üü @i x @'])
set filetype=klammertext
call cursor(1, 6) " byte col 6 = the @ of @i (2 x 2-byte ü)
KlammertextJumpToMatch
call s:Check('jump with multibyte line',
\ (exists('*charcol') ? charcol('.') : col('.'))
\ == (exists('*charcol') ? 9 : 11),
\ [col('.'), getline('.')])
" --- KlammertextCheck: diagnostics into the location list -------------------
enew!
call setline(1, ['@i abc', '@ol x ul@'])
set filetype=klammertext
KlammertextCheck
let s:ll = getloclist(0)
call s:Check('check fills the location list', len(s:ll) == 2, s:ll)
call s:Check('check positions', len(s:ll) == 2
\ && s:ll[0].lnum == 1 && s:ll[0].col == 1
\ && s:ll[1].lnum == 2 && s:ll[1].col == 7, s:ll)
call s:Check('check messages', len(s:ll) == 2
\ && s:ll[0].text =~# 'never closed' && s:ll[1].text =~# 'ul@', s:ll)
lclose
enew!
call setline(1, ['@i balanced @'])
set filetype=klammertext
KlammertextCheck
call s:Check('check clean buffer -> empty list', len(getloclist(0)) == 0)
" --- +python3: indentexpr (= operator) and live match highlighting ---------
if has('python3')
for s:fix in ['indent_list', 'indent_document', 'indent_named_close']
execute 'edit! ' . fnameescape($KT_FIX . '/' . s:fix . '.kt')
call s:Check(s:fix . ': indentexpr is set',
\ &indentexpr ==# 'klammertext#IndentExpr()', &indentexpr)
normal! gg=G
call s:Check(s:fix . ': gg=G equals expected fixture',
\ getline(1, '$') ==# readfile($KT_FIX . '/' . s:fix . '_expected.kt'))
edit!
endfor
" live match highlighting via the CursorMoved autocmd
enew!
call setline(1, ['@i abc @ x'])
set filetype=klammertext
call cursor(1, 1)
doautocmd CursorMoved
let s:m = filter(getmatches(), 'v:val.group =~# "^Klammertext"')
call s:Check('highlight: matched pair boxed',
\ len(s:m) == 1 && s:m[0].group ==# 'KlammertextMatch'
\ && len(filter(keys(s:m[0]), 'v:val =~# "^pos"')) == 2, s:m)
call cursor(1, 5) " not on a delimiter
doautocmd CursorMoved
call s:Check('highlight: cleared off-delimiter',
\ empty(filter(getmatches(), 'v:val.group =~# "^Klammertext"')))
enew!
call setline(1, ['@ol x ul@'])
set filetype=klammertext
call cursor(1, 1)
doautocmd CursorMoved
let s:m = filter(getmatches(), 'v:val.group =~# "^Klammertext"')
call s:Check('highlight: mismatch in red group',
\ len(s:m) == 1 && s:m[0].group ==# 'KlammertextMismatch', s:m)
" default mappings exist (buffer-local, LocalLeader)
call s:Check('mappings defined',
\ !empty(maparg('<LocalLeader>j', 'n'))
\ && !empty(maparg('<LocalLeader>i', 'n'))
\ && !empty(maparg('<LocalLeader>a', 'n')))
else
call add(s:results, 'SKIP +python3 checks (this Vim lacks python3)')
endif
" --- write results and exit -------------------------------------------------
call writefile(s:results, $KT_OUT)
if len(filter(copy(s:results), 'v:val =~# "^FAIL"')) > 0
cquit!
endif
qa!

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);
});

View File

@@ -1,30 +1,39 @@
#!/bin/bash
#
# editor_test.sh — Regression tests for the Klammertext editor support
# (the Emacs mode's indentation/alignment units and their Sublime Text ports).
# (doc/edit/: the shared Python core, the language server, and the Emacs,
# Sublime Text, Vim, and VS Code integrations built on them).
#
# Why this lives in tst/: the editor code implements the LANGUAGE's structural
# layer — @-run length, bar-run dimension, ^-escapes, # removal, literal
# spans, nesting depth — independently of both the SKS and the Klammermachine.
# tst/ is the SKS-independent tier, and these suites check that the
# independent implementations of Klammertext's structure agree with the
# canonical formatting conventions AND with each other. The klammer names
# that appear in the fixtures (@ol, @table, @document, @code) are seeded
# configuration of the editor tools, not SKS dependencies; nothing here runs
# ktext or loads a klammer set.
# implementations of Klammertext's structure agree with the canonical
# formatting conventions AND with each other. The klammer names that appear
# in the fixtures (@ol, @table, @document, @code) are seeded configuration of
# the editor tools, not SKS dependencies; nothing here runs ktext or loads a
# klammer set.
#
# What is checked, for every fixture pair <name>.kt / <name>_expected.kt in
# tst/editor/:
# * the Sublime Python core's output equals the expected file
# * the shared core's output equals the expected file — through the Python
# API (what Sublime uses) AND through the core's CLI (what Vim uses)
# * idempotence: the tool applied to the expected file leaves it unchanged
# * when Emacs is installed: the Emacs unit's output equals the expected
# file, and equals the Python output byte for byte (the sync check across
# the SYNC-noted policy lists) — skipped gracefully otherwise
# * the three Sublime adapter plugins import outside Sublime and resolve
# to the shared core
# * the language server passes its protocol test (tst/editor/ls_test.py:
# handshake, diagnostics, formatting, matching, alignTable — the VS Code
# extension's whole surface)
# * when Emacs is installed: the Emacs units' output equals the expected
# file, and equals the shared core's output byte for byte (the sync
# check across the independent elisp implementation) — skipped otherwise
# * when a Vim with +eval is installed: the Vim plugin's commands produce
# the same bytes (exercising the CLI shell-out glue) — skipped otherwise
#
# The editor code is located automatically: doc/emacs + doc/sublime in the
# development tree, doc/edit/emacs + doc/edit/sublime in the distribution.
# The editor code lives in doc/edit/ (same layout in the development tree and
# the distribution).
#
# Usage: ./editor_test.sh Requires python3; Emacs is optional.
# Usage: ./editor_test.sh Requires python3; Emacs and Vim optional.
# Exit code: 0 if all tests pass, 1 otherwise.
PASS=0
@@ -37,16 +46,15 @@ reset=$'\033[0m'
HERE="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$HERE/.." && pwd)"
if [ -d "$ROOT/doc/emacs" ]; then
EMACS_DIR="$ROOT/doc/emacs"
SUBLIME_DIR="$ROOT/doc/sublime"
elif [ -d "$ROOT/doc/edit/emacs" ]; then
EMACS_DIR="$ROOT/doc/edit/emacs"
SUBLIME_DIR="$ROOT/doc/edit/sublime"
else
echo "editor_test.sh: cannot locate the editor support (doc/emacs or doc/edit/emacs)" >&2
EDIT_DIR="$ROOT/doc/edit"
if [ ! -d "$EDIT_DIR/emacs" ]; then
echo "editor_test.sh: cannot locate the editor support (doc/edit)" >&2
exit 1
fi
EMACS_DIR="$EDIT_DIR/emacs"
SUBLIME_DIR="$EDIT_DIR/sublime"
SHARED_DIR="$EDIT_DIR/shared"
VIM_DIR="$EDIT_DIR/vim"
FIX="$HERE/editor"
OUT="$(mktemp -d)"
trap 'rm -rf "$OUT"' EXIT
@@ -62,25 +70,62 @@ py_args=()
el_args=()
add_fixture() { # add_fixture MODE NAME
local mode="$1" name="$2"
py_args+=("$mode" "$FIX/$name.kt" "$OUT/$name.py.out")
py_args+=("$mode" "$FIX/${name}_expected.kt" "$OUT/$name.py.idem")
el_args+=("$mode" "$FIX/$name.kt" "$OUT/$name.el.out")
el_args+=("$mode" "$FIX/${name}_expected.kt" "$OUT/$name.el.idem")
py_args+=("$mode" "$FIX/$name.kt" "$OUT/$name.py.out")
py_args+=("$mode" "$FIX/${name}_expected.kt" "$OUT/$name.py.idem")
py_args+=("$mode-cli" "$FIX/$name.kt" "$OUT/$name.cli.out")
el_args+=("$mode" "$FIX/$name.kt" "$OUT/$name.el.out")
el_args+=("$mode" "$FIX/${name}_expected.kt" "$OUT/$name.el.idem")
}
for f in $INDENT_FIXTURES; do add_fixture indent "$f"; done
for f in $ALIGN_FIXTURES; do add_fixture align "$f"; done
# --- run the Sublime Python cores (required) -------------------------------
check() { # check NAME FILE_A FILE_B
if diff -q "$2" "$3" >/dev/null 2>&1; then
echo "${green}PASS${reset} $1"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} $1"
diff "$2" "$3" | head -10
FAIL=$((FAIL + 1))
fi
}
# --- the shared core: API and CLI (required) -------------------------------
if ! command -v python3 >/dev/null 2>&1; then
echo "editor_test.sh: python3 not found" >&2
exit 1
fi
if ! PYTHONDONTWRITEBYTECODE=1 python3 "$FIX/editor_driver.py" "$SUBLIME_DIR" "${py_args[@]}"; then
if ! PYTHONDONTWRITEBYTECODE=1 python3 "$FIX/editor_driver.py" "$SHARED_DIR" "${py_args[@]}"; then
echo "editor_test.sh: the Python driver failed" >&2
exit 1
fi
# --- run the Emacs units (optional) ----------------------------------------
# --- the Sublime adapters resolve to the shared core -----------------------
if PYTHONDONTWRITEBYTECODE=1 python3 -c "
import sys
sys.path.insert(0, '$SUBLIME_DIR')
import Klammertext, Klammertext_indent, Klammertext_align
assert Klammertext.KE.__file__.startswith('$SHARED_DIR')
" 2>"$OUT/sublime.log"; then
echo "${green}PASS${reset} sublime adapters import the shared core"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} sublime adapters import the shared core"
cat "$OUT/sublime.log"
FAIL=$((FAIL + 1))
fi
# --- the language server protocol test -------------------------------------
if PYTHONDONTWRITEBYTECODE=1 python3 "$FIX/ls_test.py" "$SHARED_DIR" "$FIX" >"$OUT/ls.log" 2>&1; then
echo "${green}PASS${reset} language server protocol ($(grep -c '^PASS' "$OUT/ls.log") checks)"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} language server protocol:"
cat "$OUT/ls.log"
FAIL=$((FAIL + 1))
fi
# --- the Emacs units (optional) --------------------------------------------
HAVE_EMACS=0
if command -v emacs >/dev/null 2>&1; then
if emacs --batch -L "$EMACS_DIR" \
@@ -93,29 +138,93 @@ if command -v emacs >/dev/null 2>&1; then
FAIL=$((FAIL + 1))
fi
else
echo "(Emacs not installed — the Emacs half is skipped; the Python cores still run)"
echo "(Emacs not installed — the Emacs half is skipped; the shared core still runs)"
fi
# --- the Vim plugin (optional) ---------------------------------------------
HAVE_VIM=0
VIM_BIN="$(command -v vim || true)"
if [ -n "$VIM_BIN" ] && "$VIM_BIN" --version 2>/dev/null | grep -q '+eval'; then
HAVE_VIM=1
run_vim() { # run_vim MODE INFILE OUTFILE
local cmd
if [ "$1" = indent ]; then
cmd='KlammertextReindent'
else
cmd='call search("|") | KlammertextAlign'
fi
"$VIM_BIN" -N -n -u NONE -i NONE -es --not-a-term \
--cmd "set rtp^=$VIM_DIR" \
-c 'filetype plugin on' \
-c "edit! $2" -c 'set ft=klammertext' \
-c "$cmd" -c "saveas! $3" -c 'qa!' </dev/null >/dev/null 2>&1
}
for f in $INDENT_FIXTURES; do
run_vim indent "$FIX/$f.kt" "$OUT/$f.vim.out"
run_vim indent "$FIX/${f}_expected.kt" "$OUT/$f.vim.idem"
done
for f in $ALIGN_FIXTURES; do
run_vim align "$FIX/$f.kt" "$OUT/$f.vim.out"
run_vim align "$FIX/${f}_expected.kt" "$OUT/$f.vim.idem"
done
# The comprehensive plugin checks: ftdetect (Kotlin override), syntax
# token classes, jump-to-match (incl. multibyte columns), the location-
# list check, and — with +python3 — indentexpr (gg=G) and the live
# match highlighter. Sections skip inside the driver per Vim feature.
if KT_FIX="$FIX" KT_OUT="$OUT/vim_feature.txt" \
"$VIM_BIN" -N -n -u NONE -i NONE -es --not-a-term \
--cmd "set rtp^=$VIM_DIR" \
-c "source $FIX/vim_feature_test.vim" </dev/null >/dev/null 2>&1; then
echo "${green}PASS${reset} vim plugin features ($(grep -c '^PASS' "$OUT/vim_feature.txt") checks$(
grep -q '^SKIP' "$OUT/vim_feature.txt" && printf '; %s' "$(grep -c '^SKIP' "$OUT/vim_feature.txt") section(s) skipped"))"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} vim plugin features:"
grep -v '^PASS' "$OUT/vim_feature.txt" 2>/dev/null || echo " (the Vim driver itself failed)"
FAIL=$((FAIL + 1))
fi
else
echo "(no Vim with +eval installed — the Vim half is skipped; the shared core still runs)"
fi
# --- the VS Code extension (optional: needs a Node runtime) ----------------
# node itself, or VS Code's Electron binary run as Node.
run_node() {
if command -v node >/dev/null 2>&1; then
node "$@"
elif [ -x /usr/share/code/code ]; then
ELECTRON_RUN_AS_NODE=1 /usr/share/code/code "$@"
else
return 127
fi
}
if command -v node >/dev/null 2>&1 || [ -x /usr/share/code/code ]; then
if run_node "$FIX/vscode_ext_test.js" "$EDIT_DIR/vscode" "$FIX" >"$OUT/vscode.log" 2>&1; then
echo "${green}PASS${reset} vscode extension ($(grep -c '^PASS' "$OUT/vscode.log") checks)"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} vscode extension:"
cat "$OUT/vscode.log"
FAIL=$((FAIL + 1))
fi
else
echo "(no Node runtime — the VS Code extension test is skipped; the language server is still tested)"
fi
# --- compare ---------------------------------------------------------------
check() { # check NAME FILE_A FILE_B
if diff -q "$2" "$3" >/dev/null 2>&1; then
echo "${green}PASS${reset} $1"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} $1"
diff "$2" "$3" | head -10
FAIL=$((FAIL + 1))
fi
}
for f in $INDENT_FIXTURES $ALIGN_FIXTURES; do
check "$f (python)" "$OUT/$f.py.out" "$FIX/${f}_expected.kt"
check "$f (python idempotent)" "$OUT/$f.py.idem" "$FIX/${f}_expected.kt"
check "$f (cli)" "$OUT/$f.cli.out" "$FIX/${f}_expected.kt"
if [ "$HAVE_EMACS" = 1 ]; then
check "$f (emacs)" "$OUT/$f.el.out" "$FIX/${f}_expected.kt"
check "$f (emacs idempotent)" "$OUT/$f.el.idem" "$FIX/${f}_expected.kt"
check "$f (emacs == python)" "$OUT/$f.el.out" "$OUT/$f.py.out"
fi
if [ "$HAVE_VIM" = 1 ]; then
check "$f (vim)" "$OUT/$f.vim.out" "$FIX/${f}_expected.kt"
check "$f (vim idempotent)" "$OUT/$f.vim.idem" "$FIX/${f}_expected.kt"
fi
done
echo