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)

View File

@@ -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):

View File

@@ -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)

View File

@@ -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):

View File

@@ -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