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,7 +1,8 @@
# Klammertext.py
#
# Sublime Text plugin for klammer APPLICATION (@) delimiters. Two features,
# both ports of doc/emacs/klammertext-mode.el, both reusing one matcher:
# both counterparts of doc/edit/emacs/klammertext-mode.el, both reusing the
# one context-sensitive matcher in the shared core:
#
# 1. Jump between an opening and its close — the Sublime equivalent of the
# Emacs mode's `klammertext-jump-to-match' (bound C-c C-j). Command name
@@ -9,465 +10,172 @@
#
# 2. Live highlighting of the matching delimiter as the caret sits on one —
# the equivalent of the Emacs mode's show-paren support. Implemented as a
# ViewEventListener (see KlammertextMatchHighlighter at the bottom); no
# language server is involved. A mismatched named close or an unbalanced
# delimiter is highlighted in red with a status-bar message, mirroring the
# Emacs mode's klammertext-mismatch-face + minibuffer report.
# ViewEventListener (see KlammertextMatchHighlighter at the bottom). A
# mismatched named close or an unbalanced delimiter is highlighted in red
# with a status-bar message, mirroring the Emacs mode's
# klammertext-mismatch-face + minibuffer report.
#
# This is the companion to Klammertext.sublime-syntax. The syntax file only
# colors tokens; a tokenizer cannot match context-dependent delimiters, so the
# jump is implemented here as a TextCommand. The keybinding lives in the
# companion Default.sublime-keymap.
#
# Command name (for keymaps / the command palette): klammertext_jump_to_match
# The matcher itself — on-or-just-after caret rule, literal klammers matched
# BY NAME with opaque content (@code <-> code@), everything else by depth,
# @@/@@@ runs and removed text stepped over — lives in the shared core,
# doc/edit/shared/klammertext_edit.py, together with the LITERAL_KLAMMERS
# policy list. This file is only the Sublime wrapper.
#
# ---------------------------------------------------------------------------
# What it does (a direct port of the elisp matcher):
# * On an opening @name, move to its closing @ or name@.
# * On a close (bare @ or name@), move to the opening @name.
# * Triggers when the caret is ON the @ or immediately AFTER it (the same
# on-or-just-after rule the Emacs command uses).
# * Only single-@ APPLICATION delimiters match. @@/@@@ runs, removed text
# (#, ##, #[...]#), escaped ^@, and literal-klammer spans (@code ... code@)
# are stepped over, exactly as in the Emacs mode. The abbreviated
# @name-arg form opens no span.
# * Works at every caret when there are multiple selections.
# The shared core is located next to this file (a vendored copy — the
# installed-package layout produced by doc/make_editing_zip.sh), or in
# ../shared (the repository layout), or under $KLAMMERTEXT_HOME.
#
# Literal klammers (identical to C-c C-j): a @code ... code@ span is opaque.
# The general depth scan still steps over such a span WHOLESALE when matching
# some OTHER klammer, so verbatim @ inside it never miscount. A literal
# klammer's OWN delimiters are matched BY NAME rather than by depth (see
# app_match): @code jumps to the next code@, and code@ to the nearest preceding
# @code — correct even when the content holds unbalanced @, e.g. @code x @ y
# code@. LITERAL_KLAMMERS lists these names; keep it in sync with the '@code'
# handling in Klammertext.sublime-syntax.
#
# LITERAL_KLAMMERS must stay in sync with the literal klammers recognized in
# Klammertext.sublime-syntax (seeded there as @code). The Emacs mode keeps this
# list in the `klammertext-literal-klammers' defcustom; a plugin has no access
# to it, so it is duplicated here.
# SYNC: the literal-klammer set in the shared core must agree with the @code
# rule + literal_code context in Klammertext.sublime-syntax (a static syntax
# file cannot read Python; both are seeded with just 'code').
import sublime
import sublime_plugin
import os
import sys
# Klammer names whose content is a literal argument (verbatim interior).
#
# SYNC: this list is one of four copies that must agree. When you add or
# remove a literal klammer, mirror it in all four:
# * klammertext-literal-klammers in doc/emacs/klammertext-mode.el (the source
# of truth; a Sublime syntax/plugin cannot read that Emacs defcustom)
# * LITERAL_KLAMMERS here
# * LITERAL_KLAMMERS in Klammertext_indent.py (a deletable unit, so it does
# not import from this file)
# * the @NAME literal rule + literal_NAME context in Klammertext.sublime-syntax
# All four are currently seeded with just "code".
LITERAL_KLAMMERS = set(["code"])
try:
import sublime
import sublime_plugin
_IN_SUBLIME = True
except ImportError: # standalone import outside Sublime Text
_IN_SUBLIME = False
# --- pure helpers (operate on the whole buffer as a string) ----------------
def name_char_p(ch):
"""True if CH can be part of a klammer name (letter, digit or _).
A hyphen is NOT a name char: @name-arg1 ends the name at the first hyphen."""
if ch is None:
return False
return (('a' <= ch <= 'z') or ('A' <= ch <= 'Z')
or ('0' <= ch <= '9') or ch == '_')
def _import_shared():
here = os.path.dirname(os.path.abspath(__file__))
candidates = [here, os.path.join(os.path.dirname(here), 'shared')]
kh = os.environ.get('KLAMMERTEXT_HOME')
if kh:
candidates.append(os.path.join(kh, 'doc', 'edit', 'shared'))
for d in candidates:
if os.path.isfile(os.path.join(d, 'klammertext_edit.py')):
if d not in sys.path:
sys.path.insert(0, d)
break
import klammertext_edit
return klammertext_edit
def escaped_p(s, pos):
"""True if the char at POS is escaped by an odd run of ^ before it.
In Klammertext ^# and ^@ are literal, so such a char is not a delimiter."""
n = 0
i = pos - 1
while i >= 0 and s[i] == '^':
n += 1
i -= 1
return (n % 2) == 1
KE = _import_shared()
def block_end(s, frm):
"""Index just after the ]# that closes a #[ block opened at FROM (the index
just after the opening #[). Counts nested #[ ... ]#; len(s) if unclosed."""
depth = 1
i = frm
n = len(s)
while depth > 0:
a = s.find('#[', i)
b = s.find(']#', i)
if a == -1 and b == -1:
return n
if b == -1 or (a != -1 and a < b):
depth += 1
i = a + 2
else:
depth -= 1
i = b + 2
return i
if _IN_SUBLIME:
class KlammertextJumpToMatchCommand(sublime_plugin.TextCommand):
"""Jump between a klammer application's opening and closing delimiter.
Sublime equivalent of the Emacs mode's C-c C-j."""
def at_run_end(s, pos):
"""Index just after the run of @ that begins at POS."""
p = pos
n = len(s)
while p < n and s[p] == '@':
p += 1
return p
def run(self, edit):
view = self.view
s = view.substr(sublime.Region(0, view.size()))
new_regions = []
moved = False
message = None
for region in view.sel():
m = KE.match_at(s, region.b)
if m is None:
new_regions.append(region)
message = ("point is not on a klammer application "
"delimiter (@)")
continue
if m['match'] is None:
new_regions.append(region)
message = ("no matching delimiter for this %s klammer"
% ("opening" if m['kind'] == 'open'
else "closing"))
continue
new_regions.append(sublime.Region(m['match'], m['match']))
moved = True
def next_app_delim(s, i, limit):
"""From index I, find the next single-@ application delimiter before LIMIT.
Step over @@/@@@ runs, removed text, literal spans, escaped ^@, and the
abbreviated @name-arg form. Return (pos, kind, next_i) with kind 'open' or
'close' and next_i the index to resume from, or None when none is found."""
n = len(s)
if limit is None:
limit = n
while i < limit:
# find next @ or # at or after i (emacs re-search-forward "[@#]")
j = i
while j < limit and s[j] != '@' and s[j] != '#':
j += 1
if j >= limit:
return None
hit = j
i = hit + 1 # default: advance past the hit
if escaped_p(s, hit): # ^@ / ^# : keep going
continue
nxt = s[hit + 1] if hit + 1 < n else None
if s[hit] == '#': # removal: step over it
if nxt == '#':
i = n
elif nxt == '[':
i = block_end(s, hit + 2)
elif nxt in ('+', '/', '-'):
i = hit + 1
else: # to end of line
eol = s.find('\n', hit)
i = n if eol == -1 else eol
continue
# s[hit] == '@'
if nxt == '@': # @@ / @@@ : step over the run
i = at_run_end(s, hit)
continue
if name_char_p(nxt): # @name : opening?
k = hit + 1
while k < n and name_char_p(s[k]):
k += 1
name = s[hit + 1:k]
after = s[k] if k < n else None
if name in LITERAL_KLAMMERS: # literal span: skip to its close
close = name + '@'
idx = s.find(close, k)
i = n if idx == -1 else idx + len(close)
continue
elif after == '-': # @name-arg : opens no span
i = k
continue
view.sel().clear()
for r in new_regions:
view.sel().add(r)
if moved:
view.show(view.sel()[0].b)
elif message:
sublime.status_message("Klammertext: " + message)
def is_enabled(self):
# Only meaningful in Klammertext buffers.
return self.view.match_selector(0, "text.klammertext")
# --- live matched-delimiter highlighting (show-paren equivalent) -------
class KlammertextMatchHighlighter(sublime_plugin.ViewEventListener):
"""Highlight the matching klammer application delimiter as the caret
sits on one. The Sublime equivalent of the Emacs mode's show-paren
support — driven by cursor movement, reusing the shared matcher.
A matched pair is boxed (region.bluish); a mismatch or unbalanced
delimiter is boxed in red (region.redish) with a status-bar message.
Both the token under the caret and its match are boxed; the Emacs mode
highlights only the single @ character, but boxing the whole
@name / name@ reads better here. To highlight only the far delimiter,
drop the first region in _update()."""
MATCH_KEY = 'klammertext_paren_match'
MISMATCH_KEY = 'klammertext_paren_mismatch'
@classmethod
def is_applicable(cls, settings):
return str(settings.get('syntax', '')).endswith(
'Klammertext.sublime-syntax')
def __init__(self, view):
super().__init__(view)
self._change_count = -1
self._text = ''
def _buffer(self):
# Re-read the buffer only when it has actually changed, so plain
# cursor movement over a large file does not re-copy the document.
cc = self.view.change_count()
if cc != self._change_count:
self._text = self.view.substr(
sublime.Region(0, self.view.size()))
self._change_count = cc
return self._text
def on_selection_modified_async(self):
self._update()
def on_activated_async(self):
self._update()
def _clear(self):
self.view.erase_regions(self.MATCH_KEY)
self.view.erase_regions(self.MISMATCH_KEY)
def _update(self):
view = self.view
sel = view.sel()
if len(sel) == 0:
self._clear()
return
s = self._buffer()
m = KE.match_at(s, sel[0].b)
if m is None:
self._clear()
return
regions = [sublime.Region(*m['token'])]
if m['match_token'] is not None:
regions.append(sublime.Region(*m['match_token']))
flags = sublime.DRAW_NO_FILL
if m['mismatch']:
view.erase_regions(self.MATCH_KEY)
view.add_regions(self.MISMATCH_KEY, regions,
'region.redish', '', flags)
if m['message']:
sublime.status_message("Klammertext: " + m['message'])
else:
return (hit, 'open', k)
else: # name@ / bare @ : closing
return (hit, 'close', hit + 1)
return None
def match_forward(s, open_pos):
"""OPEN_POS is the @ of an opening application. Return the matching close @
index, or None if unbalanced."""
n = len(s)
i = open_pos + 1
while i < n and name_char_p(s[i]): # past the opening name
i += 1
depth = 1
while depth > 0:
d = next_app_delim(s, i, None)
if d is None:
return None
pos, kind, nxt = d
i = nxt
if kind == 'open':
depth += 1
else:
depth -= 1
if depth == 0:
return pos
return None
def match_backward(s, close_pos):
"""CLOSE_POS is the @ of a closing application. Return the matching open @
index, or None if unbalanced. Scans forward from 0 with a stack."""
stack = []
i = 0
limit = close_pos + 1
while True:
d = next_app_delim(s, i, limit)
if d is None:
return None
pos, kind, nxt = d
i = nxt
if kind == 'open':
stack.append(pos)
else:
open_pos = stack.pop() if stack else None
if pos == close_pos:
return open_pos
def app_delim_info(s, pos):
"""If the char at POS is a single-@ application delimiter, return
(pos, kind) with kind 'open' or 'close'; else None. The abbreviated
@name-arg form (which opens no span) returns None."""
n = len(s)
if not (0 <= pos < n):
return None
if s[pos] != '@':
return None
if pos > 0 and s[pos - 1] == '@':
return None
if pos + 1 < n and s[pos + 1] == '@':
return None
if escaped_p(s, pos):
return None
nxt = s[pos + 1] if pos + 1 < n else None
if name_char_p(nxt):
k = pos + 1
while k < n and name_char_p(s[k]):
k += 1
after = s[k] if k < n else None
if after == '-':
return None
return (pos, 'open')
return (pos, 'close')
# --- name / mismatch helpers (for the live highlighter) --------------------
def _name_forward(s, pos):
"""Index just past the run of name chars starting at POS."""
n = len(s)
k = pos
while k < n and name_char_p(s[k]):
k += 1
return k
def open_name(s, open_pos):
"""Name of the opening @name whose @ is at OPEN_POS."""
return s[open_pos + 1:_name_forward(s, open_pos + 1)]
def close_name(s, close_pos):
"""Name of a named close NAME@ whose @ is at CLOSE_POS, or None for a bare @
(including the compact @name@ form, whose name belongs to the opening)."""
ns = close_pos
while ns > 0 and name_char_p(s[ns - 1]):
ns -= 1
if ns < close_pos and (ns == 0 or s[ns - 1] != '@'):
return s[ns:close_pos]
return None
def paren_mismatch(s, open_pos, close_pos):
"""True if the pair is unbalanced (either side None) or the named close
disagrees with the opening name."""
if open_pos is None or close_pos is None:
return True
cname = close_name(s, close_pos)
return cname is not None and cname != open_name(s, open_pos)
def token_region(s, pos, kind):
"""(start, end) of the whole delimiter token whose @ is at POS.
Opening: @ plus its name. Named close: the name plus @. Bare @: just @."""
if kind == 'open':
return (pos, _name_forward(s, pos + 1))
ns = pos
while ns > 0 and name_char_p(s[ns - 1]):
ns -= 1
if ns < pos and (ns == 0 or s[ns - 1] != '@'):
return (ns, pos + 1) # named close NAME@
return (pos, pos + 1) # bare @ (or @name@)
# --- matching dispatch: literal klammers by name, others by depth ----------
def literal_delim_name(s, pos, kind):
"""If the application delimiter at POS (kind 'open'/'close') belongs to a
literal klammer (name in LITERAL_KLAMMERS), return its name; else None.
A literal klammer's @NAME open and NAME@ close are matched by name, not by
depth counting, because its content is verbatim."""
name = open_name(s, pos) if kind == 'open' else close_name(s, pos)
if name and name in LITERAL_KLAMMERS:
return name
return None
def literal_match_forward(s, open_pos, name):
"""Index of the @ of the NAME@ that closes the literal @NAME at OPEN_POS, or
None. The content is opaque, so search for the literal close string."""
start = open_pos + 1 + len(name)
idx = s.find(name + '@', start)
return idx + len(name) if idx != -1 else None
def literal_match_backward(s, close_pos, name):
"""Index of the @ of the @NAME that opens the literal NAME@ whose @ is at
CLOSE_POS, or None. Literal spans do not nest, so the nearest preceding
real @NAME is the opener (not @@NAME, and not escaped)."""
open_str = '@' + name
end = close_pos
while True:
idx = s.rfind(open_str, 0, end)
if idx == -1:
return None
before = s[idx - 1] if idx > 0 else None
if before != '@' and not escaped_p(s, idx):
return idx
end = idx
def app_match(s, pos, kind):
"""Matching application delimiter for the delimiter at POS of KIND
('open'/'close'), or None. A literal klammer matches by name (@NAME <->
NAME@) with content opaque; other klammers match by depth."""
lit = literal_delim_name(s, pos, kind)
if lit is not None:
return (literal_match_forward(s, pos, lit) if kind == 'open'
else literal_match_backward(s, pos, lit))
return match_forward(s, pos) if kind == 'open' else match_backward(s, pos)
# --- the command -----------------------------------------------------------
class KlammertextJumpToMatchCommand(sublime_plugin.TextCommand):
"""Jump between a klammer application's opening and closing delimiter.
Sublime equivalent of the Emacs mode's C-c C-j."""
def run(self, edit):
view = self.view
s = view.substr(sublime.Region(0, view.size()))
new_regions = []
moved = False
message = None
for region in view.sel():
p = region.b
info = app_delim_info(s, p)
if info is None and p > 0:
info = app_delim_info(s, p - 1)
if info is None:
new_regions.append(region)
message = "point is not on a klammer application delimiter (@)"
continue
dpos, kind = info
match = app_match(s, dpos, kind)
if match is None:
new_regions.append(region)
message = ("no matching delimiter for this %s klammer"
% ("opening" if kind == 'open' else "closing"))
continue
new_regions.append(sublime.Region(match, match))
moved = True
view.sel().clear()
for r in new_regions:
view.sel().add(r)
if moved:
view.show(view.sel()[0].b)
elif message:
sublime.status_message("Klammertext: " + message)
def is_enabled(self):
# Only meaningful in Klammertext buffers.
return self.view.match_selector(0, "text.klammertext")
# --- live matched-delimiter highlighting (show-paren equivalent) -----------
class KlammertextMatchHighlighter(sublime_plugin.ViewEventListener):
"""Highlight the matching klammer application delimiter as the caret sits
on one. The Sublime equivalent of the Emacs mode's show-paren support —
driven by cursor movement, reusing the same context-sensitive matcher.
A matched pair is boxed (region.bluish); a mismatch or unbalanced delimiter
is boxed in red (region.redish) with a status-bar message. Both the token
under the caret and its match are boxed; the Emacs mode highlights only the
single @ character, but boxing the whole @name / name@ reads better here.
To highlight only the far delimiter, drop the first region in _update()."""
MATCH_KEY = 'klammertext_paren_match'
MISMATCH_KEY = 'klammertext_paren_mismatch'
@classmethod
def is_applicable(cls, settings):
return str(settings.get('syntax', '')).endswith('Klammertext.sublime-syntax')
def __init__(self, view):
super().__init__(view)
self._change_count = -1
self._text = ''
def _buffer(self):
# Re-read the buffer only when it has actually changed, so plain cursor
# movement over a large file does not re-copy the whole document.
cc = self.view.change_count()
if cc != self._change_count:
self._text = self.view.substr(sublime.Region(0, self.view.size()))
self._change_count = cc
return self._text
def on_selection_modified_async(self):
self._update()
def on_activated_async(self):
self._update()
def _clear(self):
self.view.erase_regions(self.MATCH_KEY)
self.view.erase_regions(self.MISMATCH_KEY)
def _update(self):
view = self.view
sel = view.sel()
if len(sel) == 0:
self._clear()
return
p = sel[0].b
s = self._buffer()
info = app_delim_info(s, p)
if info is None and p > 0:
info = app_delim_info(s, p - 1)
if info is None:
self._clear()
return
dpos, kind = info
match = app_match(s, dpos, kind)
open_pos = dpos if kind == 'open' else match
close_pos = match if kind == 'open' else dpos
mism = paren_mismatch(s, open_pos, close_pos)
regions = [sublime.Region(*token_region(s, dpos, kind))]
if match is not None:
other_kind = 'close' if kind == 'open' else 'open'
regions.append(sublime.Region(*token_region(s, match, other_kind)))
flags = sublime.DRAW_NO_FILL
if mism:
view.erase_regions(self.MATCH_KEY)
view.add_regions(self.MISMATCH_KEY, regions, 'region.redish', '', flags)
if match is None:
if kind == 'open':
msg = "opening @%s has no matching close" % open_name(s, open_pos)
else:
msg = "closing delimiter has no matching open"
else:
msg = ("closing %s@ does not match opening @%s"
% (close_name(s, close_pos) or '?', open_name(s, open_pos)))
sublime.status_message("Klammertext: " + msg)
else:
view.erase_regions(self.MISMATCH_KEY)
view.add_regions(self.MATCH_KEY, regions, 'region.bluish', '', flags)
view.erase_regions(self.MISMATCH_KEY)
view.add_regions(self.MATCH_KEY, regions,
'region.bluish', '', flags)