Files
klammertext/doc/edit/sublime/Klammertext_align.py

427 lines
15 KiB
Python
Raw Normal View History

# 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.
#
# Command name (for keymaps / the command palette): klammertext_align_table
# Keybinding: Ctrl+Alt+A (in Default.sublime-keymap), scoped to Klammertext
# files. With the caret anywhere inside a @table span, the command aligns
# that table's rows:
#
# @table
# First item | Second | A third item that's longer ||
# Row 2 | Text | Not as long ||
# @
#
# Alignment is for SMALL data items (2026-07-27):
#
# * 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.
try:
import sublime
import sublime_plugin
_IN_SUBLIME = True
except ImportError: # standalone testing 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:
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
# --- scanning the span content, line by line --------------------------------
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:
class KlammertextAlignTableCommand(sublime_plugin.TextCommand):
"""Align the columns of the table klammer containing the caret.
Bound to Ctrl+Alt+A."""
def run(self, edit):
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)
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)))
return
_name, cs, ce = span
edits, msg = 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)
def is_enabled(self):
return self.view.match_selector(0, "text.klammertext")