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,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)
|
||||
|
||||
Reference in New Issue
Block a user