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:
@@ -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
262
tst/editor/ls_test.py
Normal 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()
|
||||
179
tst/editor/vim_feature_test.vim
Normal file
179
tst/editor/vim_feature_test.vim
Normal 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!
|
||||
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