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