# 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")