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,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)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Klammertext.sublime-syntax
|
||||
#
|
||||
# Sublime Text syntax highlighting for Klammertext (.kt and .k files).
|
||||
# A port of the Emacs major mode doc/emacs/klammertext-mode.el.
|
||||
# A port of the Emacs major mode doc/edit/emacs/klammertext-mode.el.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# What it highlights (mirrors the Emacs mode's eight token classes):
|
||||
@@ -32,14 +32,18 @@
|
||||
# '@code' rule and the 'literal_code' context below, replacing
|
||||
# code -> foo.
|
||||
#
|
||||
# SYNC: the literal-klammer set is duplicated in three places that
|
||||
# must agree (a .sublime-syntax file is static and cannot read the
|
||||
# Emacs defcustom). When you add or remove one, mirror it in all:
|
||||
# SYNC: the literal-klammer set's source of truth is
|
||||
# LITERAL_KLAMMERS in doc/edit/shared/klammertext_edit.py (the
|
||||
# shared core all the Python-side integrations import). A static
|
||||
# syntax file cannot read it, so when you add or remove one,
|
||||
# mirror it in the per-editor artifacts:
|
||||
# * klammertext-literal-klammers in
|
||||
# doc/emacs/klammertext-mode.el (the source of truth)
|
||||
# * LITERAL_KLAMMERS in Klammertext.py
|
||||
# doc/edit/emacs/klammertext-mode.el
|
||||
# * the @NAME rule + literal_NAME context here
|
||||
# All three are currently seeded with just 'code'.
|
||||
# * the @NAME verbatim region in doc/edit/vim/syntax/klammertext.vim
|
||||
# * the @NAME rule in
|
||||
# doc/edit/vscode/syntaxes/klammertext.tmLanguage.json
|
||||
# All are currently seeded with just 'code'.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# How open vs. close is decided (the same rule the Emacs scanner uses):
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
# Klammertext_align.py
|
||||
#
|
||||
# EXPERIMENTAL. Table alignment for Klammertext files — pads the cells of a
|
||||
# klammer's rows so the | separators line up vertically. Companion to
|
||||
# doc/emacs/klammertext-align.el (the same algorithm; keep the two in step).
|
||||
# This file is a separate unit: delete it (or move it out of the package
|
||||
# folder) to disable alignment entirely.
|
||||
# klammer's rows so the | separators line up vertically. Counterpart of
|
||||
# doc/edit/emacs/klammertext-align.el. This file is a separate unit: delete
|
||||
# it (or move it out of the package folder) to disable alignment entirely.
|
||||
#
|
||||
# Command name (for keymaps / the command palette): klammertext_align_table
|
||||
# Keybinding: Ctrl+Alt+A (in Default.sublime-keymap), scoped to Klammertext
|
||||
@@ -16,389 +15,45 @@
|
||||
# Row 2 | Text | Not as long ||
|
||||
# @
|
||||
#
|
||||
# Alignment is for SMALL data items (2026-07-27):
|
||||
# The algorithm, its rules (rows end with ||; a row with a cell over
|
||||
# CELL_MAX or spanning lines is untouched; beyond ROW_MAX columns nothing
|
||||
# changes; only depth-0 bars are separators; no whitespace ever inside a bar
|
||||
# run), and the policy lists (ALIGN_KLAMMERS, CELL_MAX, ROW_MAX) live in the
|
||||
# shared core, doc/edit/shared/klammertext_edit.py. This file is only the
|
||||
# Sublime command wrapper.
|
||||
#
|
||||
# * A row is one line ending with the row delimiter || (the customary
|
||||
# trailing delimiter; the parser strips one trailing top-level delimiter,
|
||||
# and it keeps every row uniform). The last row may omit the ||.
|
||||
# * A row is LEFT UNTOUCHED when any of its cells is longer than CELL_MAX
|
||||
# (30) characters, or when the row spans lines (a cell with a newline).
|
||||
# Untouched rows do not contribute to the column widths.
|
||||
# * If the aligned rows would exceed ROW_MAX (100) columns, nothing is
|
||||
# changed and the status bar says so — the general case of long rows has
|
||||
# no good answer, so the command declines rather than guessing.
|
||||
#
|
||||
# Cell padding is semantically free: the SKS strips cell content, and no
|
||||
# whitespace is ever inserted inside a bar run (that would turn a || row
|
||||
# separator into an empty | | cell — the load-bearing-whitespace trap).
|
||||
# Bars inside a nested klammer (e.g. @frac 1 | 2 @ in a cell) belong to that
|
||||
# klammer, not the table: only bars at nesting depth 0 within the table span
|
||||
# count, the same depth rule the Klammermachine itself applies to @cond.
|
||||
# Aligned rows adopt the leading whitespace of the first aligned row; run
|
||||
# the reindent command (Ctrl+Alt+I) first if the rows disagree.
|
||||
#
|
||||
# SYNC: ALIGN_KLAMMERS / CELL_MAX / ROW_MAX mirror the Emacs defcustoms
|
||||
# klammertext-align-klammers / -cell-max / -row-max in klammertext-align.el.
|
||||
# LITERAL_KLAMMERS is the same four-way synced list as everywhere else. The
|
||||
# scanning helpers are duplicated from Klammertext.py so this file stays a
|
||||
# deletable unit with no import coupling.
|
||||
# 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.
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
import sublime
|
||||
import sublime_plugin
|
||||
_IN_SUBLIME = True
|
||||
except ImportError: # standalone testing outside Sublime Text
|
||||
except ImportError: # standalone import outside Sublime Text
|
||||
_IN_SUBLIME = False
|
||||
|
||||
import bisect
|
||||
|
||||
ALIGN_KLAMMERS = set(["table"])
|
||||
CELL_MAX = 30
|
||||
ROW_MAX = 100
|
||||
LITERAL_KLAMMERS = set(["code"])
|
||||
|
||||
|
||||
# --- pure helpers (duplicated from Klammertext.py; see SYNC note above) -----
|
||||
|
||||
def name_char_p(ch):
|
||||
"""True if CH can be part of a klammer name (letter, digit or _)."""
|
||||
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."""
|
||||
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
|
||||
|
||||
|
||||
# --- finding the enclosing table span ---------------------------------------
|
||||
|
||||
def enclosing_span(s, pos, names):
|
||||
"""Innermost span of a klammer named in NAMES that contains POS.
|
||||
Return (name, content_start, content_end) with content_start just after
|
||||
the opening @name token and content_end at the start of the closing
|
||||
delimiter token, or None. Scans S from the start with a position stack,
|
||||
stepping over removed text, literal spans, escaped characters, and the
|
||||
abbreviated @name-arg form."""
|
||||
stack = [] # (name, open_token_start, content_start)
|
||||
n = len(s)
|
||||
i = 0
|
||||
while i < n:
|
||||
j = i
|
||||
while j < n and s[j] != '@' and s[j] != '#':
|
||||
j += 1
|
||||
if j >= n:
|
||||
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
|
||||
hit = j
|
||||
i = hit + 1
|
||||
if escaped_p(s, hit):
|
||||
continue
|
||||
nxt = s[hit + 1] if hit + 1 < n else None
|
||||
if s[hit] == '#':
|
||||
if nxt == '#':
|
||||
break # ## removes the rest of the buffer
|
||||
elif nxt == '[':
|
||||
i = block_end(s, hit + 2)
|
||||
elif nxt in ('+', '/', '-'):
|
||||
pass
|
||||
else:
|
||||
eol = s.find('\n', hit)
|
||||
i = n if eol == -1 else eol
|
||||
continue
|
||||
run_end = at_run_end(s, hit)
|
||||
run_len = run_end - hit
|
||||
after = s[run_end] if run_end < n else None
|
||||
if name_char_p(after):
|
||||
k = run_end
|
||||
while k < n and name_char_p(s[k]):
|
||||
k += 1
|
||||
name = s[run_end:k]
|
||||
i = k
|
||||
if run_len == 1 and name in LITERAL_KLAMMERS:
|
||||
idx = s.find(name + '@', k)
|
||||
if idx == -1:
|
||||
break
|
||||
i = idx + len(name) + 1
|
||||
elif run_len == 1 and k < n and s[k] == '-':
|
||||
pass # @name-arg : opens no span
|
||||
else:
|
||||
stack.append((name, hit, k))
|
||||
else:
|
||||
# a close: the token starts at the preceding name run, if any
|
||||
ns = hit
|
||||
while ns > 0 and name_char_p(s[ns - 1]):
|
||||
ns -= 1
|
||||
tok_start = ns if (ns < hit and (ns == 0 or s[ns - 1] != '@')) else hit
|
||||
if stack:
|
||||
name, open_start, content_start = stack.pop()
|
||||
if name in names and open_start <= pos <= run_end:
|
||||
return (name, content_start, tok_start)
|
||||
i = run_end
|
||||
return None
|
||||
import klammertext_edit
|
||||
return klammertext_edit
|
||||
|
||||
|
||||
# --- scanning the span content, line by line --------------------------------
|
||||
KE = _import_shared()
|
||||
|
||||
def scan_lines(content):
|
||||
"""Scan CONTENT (the text of a table span). Return a list of line
|
||||
records, one per line: dicts with start, end (offsets into CONTENT, end
|
||||
excludes the newline), start_depth, end_depth (klammer nesting relative
|
||||
to the span), bars (list of (pos, runlen) for unescaped depth-0 bar
|
||||
runs), blocked (inside removed/verbatim content), comment (a # removes
|
||||
the rest of the line)."""
|
||||
n = len(content)
|
||||
line_starts = [0]
|
||||
for idx, ch in enumerate(content):
|
||||
if ch == '\n':
|
||||
line_starts.append(idx + 1)
|
||||
nlines = len(line_starts)
|
||||
|
||||
def line_index(p):
|
||||
return bisect.bisect_right(line_starts, p) - 1
|
||||
|
||||
lines = [{'start': line_starts[k],
|
||||
'end': (line_starts[k + 1] - 1 if k + 1 < nlines else n),
|
||||
'bars': [], 'blocked': False, 'comment': False,
|
||||
'start_depth': None, 'end_depth': None}
|
||||
for k in range(nlines)]
|
||||
lines[0]['start_depth'] = 0
|
||||
|
||||
def block_range(a, b):
|
||||
"""Mark every line touched by [a, b) as blocked."""
|
||||
last = max(a, b - 1)
|
||||
for k in range(line_index(a), line_index(min(last, n - 1)) + 1):
|
||||
lines[k]['blocked'] = True
|
||||
|
||||
depth = 0
|
||||
i = 0
|
||||
while i < n:
|
||||
j = i
|
||||
while j < n and content[j] not in '@#|\n':
|
||||
j += 1
|
||||
if j >= n:
|
||||
break
|
||||
hit = j
|
||||
i = hit + 1
|
||||
c = content[hit]
|
||||
if c == '\n':
|
||||
k = line_index(hit)
|
||||
lines[k]['end_depth'] = depth
|
||||
if k + 1 < nlines:
|
||||
lines[k + 1]['start_depth'] = depth
|
||||
continue
|
||||
if escaped_p(content, hit):
|
||||
continue
|
||||
nxt = content[hit + 1] if hit + 1 < n else None
|
||||
if c == '#':
|
||||
if nxt == '#':
|
||||
block_range(hit, n)
|
||||
break
|
||||
elif nxt == '[':
|
||||
e = block_end(content, hit + 2)
|
||||
if line_index(max(hit, e - 1)) != line_index(hit):
|
||||
block_range(hit, e)
|
||||
i = e
|
||||
elif nxt in ('+', '/', '-'):
|
||||
pass
|
||||
else: # # to end of line
|
||||
lines[line_index(hit)]['comment'] = True
|
||||
eol = content.find('\n', hit)
|
||||
i = n if eol == -1 else eol
|
||||
continue
|
||||
if c == '|':
|
||||
if hit > 0 and content[hit - 1] == '|':
|
||||
continue # mid-run (after an escaped ^|)
|
||||
k = hit
|
||||
while k < n and content[k] == '|':
|
||||
k += 1
|
||||
if depth == 0:
|
||||
lines[line_index(hit)]['bars'].append((hit, k - hit))
|
||||
i = k
|
||||
continue
|
||||
# '@'
|
||||
run_end = at_run_end(content, hit)
|
||||
run_len = run_end - hit
|
||||
after = content[run_end] if run_end < n else None
|
||||
if name_char_p(after):
|
||||
k = run_end
|
||||
while k < n and name_char_p(content[k]):
|
||||
k += 1
|
||||
name = content[run_end:k]
|
||||
i = k
|
||||
if run_len == 1 and name in LITERAL_KLAMMERS:
|
||||
idx = content.find(name + '@', k)
|
||||
e = n if idx == -1 else idx + len(name) + 1
|
||||
if line_index(max(hit, e - 1)) != line_index(hit):
|
||||
block_range(hit, e)
|
||||
i = e
|
||||
elif run_len == 1 and k < n and content[k] == '-':
|
||||
pass
|
||||
else:
|
||||
depth += 1
|
||||
else:
|
||||
depth = max(0, depth - 1)
|
||||
i = run_end
|
||||
|
||||
for ln in lines:
|
||||
if ln['start_depth'] is None:
|
||||
ln['blocked'] = True
|
||||
if ln['end_depth'] is None:
|
||||
ln['end_depth'] = depth
|
||||
return lines
|
||||
|
||||
|
||||
# --- the alignment ----------------------------------------------------------
|
||||
|
||||
def compute_edits(content):
|
||||
"""Compute the alignment edits for CONTENT (a table span's text).
|
||||
Return (edits, message): edits is a list of (start, end, new_text)
|
||||
triples relative to CONTENT, ascending; message is a status string (a
|
||||
reason when edits is empty)."""
|
||||
lines = scan_lines(content)
|
||||
n = len(content)
|
||||
|
||||
# The last line holding actual content (a row there may omit its ||).
|
||||
last_content = None
|
||||
for k in range(len(lines) - 1, 0, -1):
|
||||
if content[lines[k]['start']:lines[k]['end']].strip():
|
||||
last_content = k
|
||||
break
|
||||
|
||||
rows = [] # (line record, cells, trailing_p)
|
||||
chain_ok = True # a row must START a row: the previous
|
||||
for k in range(1, len(lines)): # content line ended with || (or was the
|
||||
ln = lines[k] # opener / an option line / blank)
|
||||
text = content[ln['start']:ln['end']]
|
||||
stripped = text.strip()
|
||||
if not stripped:
|
||||
continue # blank line: chain unchanged
|
||||
if stripped.startswith(':') and not ln['bars']:
|
||||
continue # option line: chain unchanged
|
||||
row = _parse_row(content, ln, text, chain_ok, k == last_content)
|
||||
trailing = _trailing_rowsep(content, ln)
|
||||
chain_ok = trailing
|
||||
if row is not None:
|
||||
rows.append(row)
|
||||
|
||||
if not rows:
|
||||
return ([], "no alignable rows found")
|
||||
|
||||
widths = []
|
||||
for _ln, cells, _tr in rows:
|
||||
for c_idx, cell in enumerate(cells):
|
||||
if c_idx >= len(widths):
|
||||
widths.append(0)
|
||||
widths[c_idx] = max(widths[c_idx], len(cell))
|
||||
|
||||
indent = ' ' * _indent_width(content, rows[0][0])
|
||||
if ROW_MAX is not None:
|
||||
longest = 0
|
||||
for _ln, cells, trailing in rows:
|
||||
m = len(cells)
|
||||
w = (len(indent) + sum(widths[:m]) + 3 * (m - 1)
|
||||
+ (3 if trailing else 0))
|
||||
longest = max(longest, w)
|
||||
if longest > ROW_MAX:
|
||||
return ([], "aligned rows would be %d characters (limit %d); "
|
||||
"not aligning" % (longest, ROW_MAX))
|
||||
|
||||
edits = []
|
||||
for ln, cells, trailing in rows:
|
||||
parts = [cells[c].ljust(widths[c]) for c in range(len(cells) - 1)]
|
||||
last = cells[-1]
|
||||
if trailing:
|
||||
last = last.ljust(widths[len(cells) - 1])
|
||||
parts.append(last)
|
||||
new = indent + ' | '.join(parts) + (' ||' if trailing else '')
|
||||
if new != content[ln['start']:ln['end']]:
|
||||
edits.append((ln['start'], ln['end'], new))
|
||||
msg = ("aligned %d rows" % len(rows)) if edits else "already aligned"
|
||||
return (edits, msg)
|
||||
|
||||
|
||||
def _indent_width(content, ln):
|
||||
i = ln['start']
|
||||
while i < ln['end'] and content[i] in ' \t':
|
||||
i += 1
|
||||
return i - ln['start']
|
||||
|
||||
|
||||
def _trailing_rowsep(content, ln):
|
||||
"""True when the line's LAST depth-0 bar run is a || sitting at the end of
|
||||
the line (only whitespace after it)."""
|
||||
if not ln['bars']:
|
||||
return False
|
||||
pos, runlen = ln['bars'][-1]
|
||||
return (runlen == 2
|
||||
and content[pos + 2:ln['end']].strip() == '')
|
||||
|
||||
|
||||
def _parse_row(content, ln, text, chain_ok, is_last_content):
|
||||
"""If the line is an alignable row, return (ln, cells, trailing_p);
|
||||
else None. See the file header for the rules."""
|
||||
if ln['blocked'] or ln['comment'] or not chain_ok:
|
||||
return None
|
||||
if ln['start_depth'] != 0 or ln['end_depth'] != 0:
|
||||
return None
|
||||
if not ln['bars']:
|
||||
return None
|
||||
trailing = _trailing_rowsep(content, ln)
|
||||
singles = ln['bars'][:-1] if trailing else ln['bars']
|
||||
for _pos, runlen in singles:
|
||||
if runlen != 1:
|
||||
return None # a mid-line || (or |||): not one row
|
||||
if not trailing and not is_last_content:
|
||||
return None # row continues onto the next line
|
||||
cell_start = ln['start'] + _indent_width(content, ln)
|
||||
cell_end = ln['bars'][-1][0] if trailing else ln['end']
|
||||
bounds = [cell_start] + [p for p, _r in singles] + [cell_end]
|
||||
cells = []
|
||||
for b_idx in range(len(bounds) - 1):
|
||||
a = bounds[b_idx] + (1 if b_idx > 0 else 0) # skip the | itself
|
||||
cell = content[a:bounds[b_idx + 1]].strip()
|
||||
if len(cell) > CELL_MAX:
|
||||
return None
|
||||
cells.append(cell)
|
||||
return (ln, cells, trailing)
|
||||
|
||||
|
||||
# --- the command ------------------------------------------------------------
|
||||
|
||||
if _IN_SUBLIME:
|
||||
|
||||
@@ -410,14 +65,15 @@ if _IN_SUBLIME:
|
||||
view = self.view
|
||||
s = view.substr(sublime.Region(0, view.size()))
|
||||
pos = view.sel()[0].b if len(view.sel()) else 0
|
||||
span = enclosing_span(s, pos, ALIGN_KLAMMERS)
|
||||
span = KE.enclosing_span(s, pos, KE.ALIGN_KLAMMERS)
|
||||
if span is None:
|
||||
sublime.status_message(
|
||||
"Klammertext: the caret is not inside a table klammer (%s)"
|
||||
% ", ".join("@" + name for name in sorted(ALIGN_KLAMMERS)))
|
||||
% ", ".join("@" + name
|
||||
for name in sorted(KE.ALIGN_KLAMMERS)))
|
||||
return
|
||||
_name, cs, ce = span
|
||||
edits, msg = compute_edits(s[cs:ce])
|
||||
edits, msg = KE.compute_edits(s[cs:ce])
|
||||
for a, b, new in sorted(edits, reverse=True):
|
||||
view.replace(edit, sublime.Region(cs + a, cs + b), new)
|
||||
sublime.status_message("Klammertext: " + msg)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Klammertext_indent.py
|
||||
#
|
||||
# EXPERIMENTAL. Reindentation for Klammertext files — the Sublime Text port
|
||||
# of doc/emacs/klammertext-indent.el. This file is a separate unit: delete it
|
||||
# (or move it out of the package folder) to disable indentation entirely; the
|
||||
# rest of the Klammertext package is unaffected.
|
||||
# EXPERIMENTAL. Reindentation for Klammertext files — the Sublime Text
|
||||
# counterpart of doc/edit/emacs/klammertext-indent.el. This file is a
|
||||
# separate unit: delete it (or move it out of the package folder) to disable
|
||||
# indentation entirely; the rest of the Klammertext package is unaffected.
|
||||
#
|
||||
# Command name (for keymaps / the command palette): klammertext_reindent
|
||||
# Keybinding: Ctrl+Alt+I (in Default.sublime-keymap), scoped to Klammertext
|
||||
@@ -11,245 +11,45 @@
|
||||
# line when there is just a caret. Nothing reformats automatically (no
|
||||
# on-Enter auto-indent), because whitespace is content in Klammertext.
|
||||
#
|
||||
# The convention (2026-07-27):
|
||||
# The algorithm, the indentation convention, and the policy lists
|
||||
# (TRANSPARENT_KLAMMERS, CODE_KLAMMERS, INDENT_OFFSET, ...) live in the
|
||||
# shared core, doc/edit/shared/klammertext_edit.py — the single Python
|
||||
# implementation used by the Sublime, Vim, and VS Code integrations and by
|
||||
# the language server. This file is only the Sublime command wrapper.
|
||||
#
|
||||
# @ol <- opener at its context's content column
|
||||
# Item one <- content: opener column + 2
|
||||
# | Item two <- bar run at the OPENER's column ("| " is two
|
||||
# @ol characters, so item text aligns with "Item one")
|
||||
# Embedded item one
|
||||
# | Embedded item two
|
||||
# @ <- close at its opener's column
|
||||
# | Item four
|
||||
# @
|
||||
#
|
||||
# Formal rule: a line indents to offset x (effective depth); a line that
|
||||
# BEGINS with a bar run (|, ||, ...) or a closing delimiter (a bare @-run or
|
||||
# a named close) indents one level less, i.e. to its owner's opening column.
|
||||
# The bar-run rule is dimension-independent: | (list items), || (table rows)
|
||||
# and any longer run all drop to the opener's column. Effective depth counts
|
||||
# every enclosing span uniformly -- applications (@), definitions (@@), and
|
||||
# system commands (@@@) -- with these exceptions:
|
||||
#
|
||||
# * Klammers in TRANSPARENT_KLAMMERS (seeded with "document") contribute no
|
||||
# level, so ordinary paragraphs of a document sit at the left margin.
|
||||
# * Lines inside a literal klammer's verbatim content (@code ... code@) and
|
||||
# inside the argument span of a klammer in CODE_KLAMMERS (seeded with
|
||||
# "eval" -- inline Python is indentation-sensitive!) are NEVER touched.
|
||||
# Removed regions (#[ ... ]#, everything after ##) are likewise left
|
||||
# alone.
|
||||
#
|
||||
# Known limitation (shared with the Emacs scanner): a raw @ inside a ^'...'^
|
||||
# literal region would confuse the depth scan.
|
||||
#
|
||||
# SYNC: the policy lists below must agree with the Emacs side:
|
||||
# * LITERAL_KLAMMERS with klammertext-literal-klammers (also duplicated in
|
||||
# Klammertext.py and Klammertext.sublime-syntax; all seeded "code")
|
||||
# * TRANSPARENT_KLAMMERS with klammertext-transparent-klammers ("document")
|
||||
# * CODE_KLAMMERS with klammertext-code-klammers ("eval")
|
||||
# * INDENT_OFFSET with klammertext-indent-offset (2)
|
||||
# The scanning helpers (name_char_p, escaped_p, block_end, at_run_end) are
|
||||
# duplicated from Klammertext.py so this file stays a deletable unit with no
|
||||
# import coupling.
|
||||
# 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.
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
import sublime
|
||||
import sublime_plugin
|
||||
_IN_SUBLIME = True
|
||||
except ImportError: # standalone testing outside Sublime Text
|
||||
except ImportError: # standalone import outside Sublime Text
|
||||
_IN_SUBLIME = False
|
||||
|
||||
INDENT_OFFSET = 2
|
||||
LITERAL_KLAMMERS = set(["code"])
|
||||
TRANSPARENT_KLAMMERS = set(["document"])
|
||||
CODE_KLAMMERS = set(["eval"])
|
||||
|
||||
|
||||
# --- pure helpers (duplicated from Klammertext.py; see SYNC note above) -----
|
||||
|
||||
def name_char_p(ch):
|
||||
"""True if CH can be part of a klammer name (letter, digit or _)."""
|
||||
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."""
|
||||
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
|
||||
|
||||
|
||||
# --- the depth scanner (port of klammertext-indent--state-at) ---------------
|
||||
|
||||
def state_at(s, pos):
|
||||
"""Scan s[0:POS] (POS a line beginning). Return (stack, opaque): STACK is
|
||||
the list of names of the klammer applications, @@ definitions and @@@
|
||||
commands open at POS, outermost first; OPAQUE is True when POS lies inside
|
||||
content that indentation must not touch (removed text, a literal klammer's
|
||||
verbatim span, or a code klammer's argument span)."""
|
||||
stack = []
|
||||
n = len(s)
|
||||
i = 0
|
||||
while i < pos:
|
||||
j = i
|
||||
while j < pos and s[j] != '@' and s[j] != '#':
|
||||
j += 1
|
||||
if j >= pos:
|
||||
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
|
||||
hit = j
|
||||
i = hit + 1
|
||||
if escaped_p(s, hit): # ^@ / ^# : plain text
|
||||
continue
|
||||
nxt = s[hit + 1] if hit + 1 < n else None
|
||||
if s[hit] == '#':
|
||||
if nxt == '#': # ## removes to end of buffer
|
||||
return (stack, True)
|
||||
elif nxt == '[': # #[ ... ]# (nestable)
|
||||
end = block_end(s, hit + 2)
|
||||
if pos < end:
|
||||
return (stack, True)
|
||||
i = end
|
||||
elif nxt in ('+', '/', '-'): # whitespace operators
|
||||
pass
|
||||
else: # # to end of line
|
||||
eol = s.find('\n', hit)
|
||||
i = n if eol == -1 else eol
|
||||
continue
|
||||
# an @-run
|
||||
run_end = at_run_end(s, hit)
|
||||
run_len = run_end - hit
|
||||
after = s[run_end] if run_end < n else None
|
||||
if name_char_p(after):
|
||||
# @name / @@name / @@@name : an opener (or, for a literal
|
||||
# klammer, a verbatim span to step over).
|
||||
k = run_end
|
||||
while k < n and name_char_p(s[k]):
|
||||
k += 1
|
||||
name = s[run_end:k]
|
||||
i = k
|
||||
if run_len == 1 and name in LITERAL_KLAMMERS:
|
||||
# Verbatim interior: find the closing NAME@ by name.
|
||||
idx = s.find(name + '@', k)
|
||||
if idx == -1: # never closed
|
||||
return (stack, True)
|
||||
close_end = idx + len(name) + 1
|
||||
if pos < close_end:
|
||||
return (stack, True)
|
||||
i = close_end
|
||||
elif run_len == 1 and k < n and s[k] == '-':
|
||||
pass # @name-arg : opens no span
|
||||
else:
|
||||
stack.append(name)
|
||||
else:
|
||||
# a bare @-run, or the run of a named close: a close.
|
||||
if stack:
|
||||
stack.pop()
|
||||
i = run_end
|
||||
opaque = any(name in CODE_KLAMMERS for name in stack)
|
||||
return (stack, opaque)
|
||||
import klammertext_edit
|
||||
return klammertext_edit
|
||||
|
||||
|
||||
def depth(stack):
|
||||
"""Number of indentation levels STACK contributes.
|
||||
Transparent klammers contribute none."""
|
||||
return sum(1 for name in stack if name not in TRANSPARENT_KLAMMERS)
|
||||
KE = _import_shared()
|
||||
|
||||
|
||||
def dedent_line_p(s, bol):
|
||||
"""True when the line starting at BOL begins with a token that sits at its
|
||||
owner's opening column: a bar run (|, ||, ...), a bare close run (@, @@,
|
||||
@@@), or a named close (name@, name@@, name@@@). A line beginning with an
|
||||
opener (@name, @@name, @@@name) is content-level."""
|
||||
n = len(s)
|
||||
i = bol
|
||||
while i < n and s[i] in ' \t':
|
||||
i += 1
|
||||
if i >= n:
|
||||
return False
|
||||
c = s[i]
|
||||
if c == '|':
|
||||
return True
|
||||
if c == '@':
|
||||
run_end = at_run_end(s, i)
|
||||
return not name_char_p(s[run_end] if run_end < n else None)
|
||||
if name_char_p(c):
|
||||
# A named close: name chars followed by an @-run (an unescaped @ can
|
||||
# only be a delimiter).
|
||||
k = i
|
||||
while k < n and name_char_p(s[k]):
|
||||
k += 1
|
||||
return k < n and s[k] == '@'
|
||||
return False
|
||||
|
||||
|
||||
def target_column(s, bol):
|
||||
"""Column for the line starting at BOL, or None for lines that must not
|
||||
be touched (verbatim, code, or removed content)."""
|
||||
stack, opaque = state_at(s, bol)
|
||||
if opaque:
|
||||
return None
|
||||
if dedent_line_p(s, bol) and stack:
|
||||
stack = stack[:-1]
|
||||
return INDENT_OFFSET * depth(stack)
|
||||
|
||||
|
||||
def reindent_lines(s, bols):
|
||||
"""Compute the edits that reindent the lines whose beginnings are BOLS.
|
||||
Return a list of (start, end, replacement) triples over S, in ascending
|
||||
order, replacing each line's leading whitespace; untouchable lines and
|
||||
already-correct lines produce no edit. Pure function -- also used by the
|
||||
standalone tests."""
|
||||
n = len(s)
|
||||
edits = []
|
||||
for bol in bols:
|
||||
tgt = target_column(s, bol)
|
||||
if tgt is None:
|
||||
continue
|
||||
i = bol
|
||||
while i < n and s[i] in ' \t':
|
||||
i += 1
|
||||
if s[bol:i] != ' ' * tgt:
|
||||
edits.append((bol, i, ' ' * tgt))
|
||||
return edits
|
||||
|
||||
|
||||
# --- the command ------------------------------------------------------------
|
||||
|
||||
if _IN_SUBLIME:
|
||||
|
||||
class KlammertextReindentCommand(sublime_plugin.TextCommand):
|
||||
@@ -269,7 +69,7 @@ if _IN_SUBLIME:
|
||||
bols.append(line.a)
|
||||
# Compute all edits from the original text, then apply from the
|
||||
# bottom up so earlier offsets stay valid.
|
||||
for a, b, new in sorted(reindent_lines(s, bols), reverse=True):
|
||||
for a, b, new in sorted(KE.reindent_lines(s, bols), reverse=True):
|
||||
view.replace(edit, sublime.Region(a, b), new)
|
||||
|
||||
def is_enabled(self):
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
# Klammertext for Sublime Text
|
||||
|
||||
A Sublime Text port of the Emacs major mode for Klammertext
|
||||
(`doc/emacs/klammertext-mode.el`). It brings syntax highlighting, delimiter
|
||||
matching, and comment toggling to `.kt` and `.k` files. Behavior mirrors the
|
||||
Emacs mode closely; where the two intentionally differ, the file headers say so.
|
||||
A Sublime Text package for Klammertext: syntax highlighting, delimiter
|
||||
matching, comment toggling, reindentation, and table alignment for `.kt` and
|
||||
`.k` files. The structural features run on the **shared editor core**
|
||||
(`klammertext_edit.py`) — the single Python implementation used by the Vim
|
||||
and VS Code integrations and the Klammertext language server — so all the
|
||||
editors behave identically; the plugin files here are Sublime command
|
||||
wrappers. Behavior mirrors the Emacs mode closely (an independent elisp
|
||||
implementation, held equal by the Klammertext test suite); where the two
|
||||
intentionally differ, the file headers say so.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Klammertext.sublime-syntax` | Syntax highlighting. Colors the text-removal constructs (`#`, `##`, `#[...]#`) and the three `@`-tiers — application `@`, definition `@@`, system `@@@` — each as an opening vs. a close, plus `^`-escapes and verbatim `@code ... code@` spans. |
|
||||
| `Klammertext.py` | Plugin with two features that share one context-sensitive matcher: jump between an opening and its close, and live highlighting of the matching delimiter as the caret moves (mismatched or unbalanced delimiters flag in red). |
|
||||
| `Klammertext.py` | Jump between an opening and its close, and live highlighting of the matching delimiter as the caret moves (mismatched or unbalanced delimiters flag in red). Both reuse the shared core's context-sensitive matcher. |
|
||||
| `Klammertext_indent.py` | **Experimental.** Reindentation per the Klammertext convention (see below). A separate unit: delete this one file to disable indentation; nothing else is affected. |
|
||||
| `Klammertext_align.py` | **Experimental.** Table alignment (see below). Also a separate, deletable unit. |
|
||||
| `klammertext_edit.py` | The shared editor core the three plugins import. Ships in the editing zip; when you install from the Klammertext repository instead, copy it in from `doc/edit/shared/` (or leave the package inside the repository tree, where the plugins find `../shared/` themselves). |
|
||||
| `Default.sublime-keymap` | Binds jump-to-match to **Ctrl+M**, reindent to **Ctrl+Alt+I**, and table alignment to **Ctrl+Alt+A**, scoped to Klammertext files. |
|
||||
| `Comments.tmPreferences` | Comment toggling: **Ctrl+/** inserts `# ` (line removal), **Ctrl+Shift+/** wraps in `#[ ... ]#` (block removal). |
|
||||
| `Breakers` / `Celeste` / `Mariana` / `Monokai` / `Sixteen` `.sublime-color-scheme` | Color overrides for Sublime's five built-in schemes — one hue system, full intensity on the dark schemes, scaled down on the light ones. Additive: they recolor only the Klammertext delimiters and leave the rest of each scheme unchanged. |
|
||||
@@ -32,7 +38,10 @@ directory:
|
||||
The quickest way to find it: **Preferences → Browse Packages…** opens the
|
||||
`Packages` directory. Create the `Klammertext` folder there and copy the files
|
||||
in. Sublime loads them live — no restart — and applies the syntax to `.kt` and
|
||||
`.k` files automatically.
|
||||
`.k` files automatically. The package must include `klammertext_edit.py`
|
||||
(see the file table above): the editing zip ships it in place; from a
|
||||
repository checkout, copy `doc/edit/shared/klammertext_edit.py` into the
|
||||
folder alongside the plugin files.
|
||||
|
||||
Use a dedicated folder (not `Packages/User/`) so the bundled keymap does not
|
||||
merge into your personal one. If you want highlighting only, the
|
||||
@@ -60,7 +69,7 @@ prefer `super+m` can change it in `Default.sublime-keymap`.
|
||||
## Indentation (experimental)
|
||||
|
||||
`Klammertext_indent.py` ports the Emacs mode's indentation
|
||||
(`doc/emacs/klammertext-indent.el`): **Ctrl+Alt+I** reindents the line(s)
|
||||
(`doc/edit/emacs/klammertext-indent.el`): **Ctrl+Alt+I** reindents the line(s)
|
||||
touched by the selection to reflect the klammer nesting, two spaces per level:
|
||||
|
||||
```
|
||||
@@ -82,8 +91,9 @@ belong to. All three `@`-tiers indent uniformly. Exceptions: `@document`
|
||||
contributes no level (a document's paragraphs stay at the left margin); lines
|
||||
inside verbatim `@code` content, inside `@eval` argument spans (inline Python
|
||||
is indentation-sensitive), and inside removed regions are never touched. The
|
||||
policy lists (`TRANSPARENT_KLAMMERS`, `CODE_KLAMMERS`, `INDENT_OFFSET`) are at
|
||||
the top of `Klammertext_indent.py`, kept in sync with the Emacs defcustoms.
|
||||
policy lists (`TRANSPARENT_KLAMMERS`, `CODE_KLAMMERS`, `INDENT_OFFSET`) are
|
||||
in the shared core (`klammertext_edit.py`), kept in sync with the Emacs
|
||||
defcustoms.
|
||||
|
||||
Sublime's own Reindent (Edit → Line → Reindent) is driven by single-line
|
||||
regex patterns that cannot express Klammertext nesting, so this is a plugin
|
||||
@@ -96,7 +106,7 @@ keep the command available from plugins).
|
||||
## Table alignment (experimental)
|
||||
|
||||
`Klammertext_align.py` ports the Emacs mode's table alignment
|
||||
(`doc/emacs/klammertext-align.el`): **Ctrl+Alt+A** with the caret anywhere
|
||||
(`doc/edit/emacs/klammertext-align.el`): **Ctrl+Alt+A** with the caret anywhere
|
||||
inside a `@table` span pads the cells of its rows so the `|` separators line
|
||||
up:
|
||||
|
||||
@@ -118,8 +128,8 @@ bar run (which would turn a `||` row separator into an empty `| |` cell),
|
||||
and bars inside a nested klammer in a cell (`@frac 1 | 2 @`) belong to that
|
||||
klammer, not the table. Aligned rows adopt the leading whitespace of the
|
||||
first aligned row — run Ctrl+Alt+I first if the rows disagree. The limits
|
||||
sit at the top of `Klammertext_align.py`, mirrored from the Emacs
|
||||
defcustoms.
|
||||
(`ALIGN_KLAMMERS`, `CELL_MAX`, `ROW_MAX`) are in the shared core
|
||||
(`klammertext_edit.py`), mirrored from the Emacs defcustoms.
|
||||
|
||||
## Colors
|
||||
|
||||
@@ -143,25 +153,30 @@ exact values are in each file's header comment.
|
||||
|
||||
## Keeping literal klammers in sync
|
||||
|
||||
Klammers whose content is verbatim (`@code ... code@`) are listed in four
|
||||
places that must agree — a Sublime syntax/plugin cannot read the Emacs
|
||||
defcustom, so the list is duplicated:
|
||||
Klammers whose content is verbatim (`@code ... code@`) are listed in the
|
||||
shared core — `LITERAL_KLAMMERS` in `klammertext_edit.py`, the source of
|
||||
truth — and restated in the static per-editor artifacts, which cannot read
|
||||
Python:
|
||||
|
||||
- `klammertext-literal-klammers` in `doc/emacs/klammertext-mode.el` (the source of truth)
|
||||
- `LITERAL_KLAMMERS` in `Klammertext.py`
|
||||
- `LITERAL_KLAMMERS` in `Klammertext_indent.py`
|
||||
- the `@code` rule and `literal_code` context in `Klammertext.sublime-syntax`
|
||||
- `klammertext-literal-klammers` in `doc/edit/emacs/klammertext-mode.el`
|
||||
(the independent elisp implementation)
|
||||
- the `@code` region in `doc/edit/vim/syntax/klammertext.vim` and the rule
|
||||
in `doc/edit/vscode/syntaxes/klammertext.tmLanguage.json`
|
||||
|
||||
All four are seeded with just `code`. When you add or remove a literal
|
||||
klammer, change all four.
|
||||
All are seeded with just `code`. When you add or remove a literal klammer,
|
||||
change them together.
|
||||
|
||||
## Not included
|
||||
|
||||
Whole-file semantic validation — persistent error underlines when the cursor is
|
||||
elsewhere, klammer-name completion, go-to-definition — is not part of this
|
||||
package. That would need a language server (used through the Sublime LSP
|
||||
package), a separate program, and is unrelated to the highlighting and matching
|
||||
provided here.
|
||||
Whole-file diagnostics (persistent error underlines when the cursor is
|
||||
elsewhere) are not part of this package, but they exist: the **Klammertext
|
||||
language server** (`doc/edit/shared/klammertext_ls.py`, the same program the
|
||||
VS Code extension uses) serves them to Sublime through the community LSP
|
||||
package. Install "LSP" from Package Control and add a client with
|
||||
`command: ["python3", "/path/to/klammertext_ls.py"]` for the
|
||||
`text.klammertext` selector. Everything in this package works the same
|
||||
with or without it.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
Reference in New Issue
Block a user