427 lines
15 KiB
Python
427 lines
15 KiB
Python
# 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")
|