Initial commit: Klammertext source distribution
Curated source subset assembled by klammertext-dev's doc/make_dist.sh: the Klammermachine (mac), the Standard Klammer Set (sks), the commands (com), editor plugins and install guides (doc), a test subset (tst), and lib/bin placeholders. Builds with 'make -C com'. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
471
doc/edit/sublime/Klammertext.py
Normal file
471
doc/edit/sublime/Klammertext.py
Normal file
@@ -0,0 +1,471 @@
|
||||
# Klammertext.py
|
||||
#
|
||||
# Sublime Text plugin for klammer APPLICATION (@) delimiters. Two features,
|
||||
# both ports of doc/emacs/klammertext-mode.el, both reusing one matcher:
|
||||
#
|
||||
# 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
|
||||
# klammertext_jump_to_match; keybinding in Default.sublime-keymap.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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.
|
||||
#
|
||||
# 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.
|
||||
|
||||
import sublime
|
||||
import sublime_plugin
|
||||
|
||||
# Klammer names whose content is a literal argument (verbatim interior).
|
||||
#
|
||||
# SYNC: this list is one of three copies that must agree. When you add or
|
||||
# remove a literal klammer, mirror it in all three:
|
||||
# * 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
|
||||
# * the @NAME literal rule + literal_NAME context in Klammertext.sublime-syntax
|
||||
# All three are currently seeded with just "code".
|
||||
LITERAL_KLAMMERS = set(["code"])
|
||||
|
||||
|
||||
# --- 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 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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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 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
|
||||
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)
|
||||
Reference in New Issue
Block a user