#!/usr/bin/env python3 # klammertext_edit.py # # The SHARED implementation of Klammertext's editor-support algorithms: # everything about the language's *structural* layer — @-run tiers, bar-run # dimension, ^-escapes, # removal, literal spans, nesting depth — that more # than one editor integration needs. One algorithm, one file: # # * scanner helpers name_char_p, escaped_p, block_end, at_run_end # * delimiter matching app_delim_info, app_match, ... (jump / highlight) # * indentation reindent_lines, target_column, ... # * table alignment enclosing_span, compute_edits, ... # * diagnostics diagnostics(): unclosed / mismatched delimiters # * a CLI indent | align | match | check over stdin/stdout # # Consumers: # * doc/edit/sublime/ the Sublime Text plugins import this module # directly (Sublime's plugin host is Python) # * doc/edit/vim/ the Vim plugin shells out to the CLI # * doc/edit/shared/klammertext_ls.py # the language server (used by VS Code, and by any # LSP client: Neovim, Emacs eglot, Sublime LSP) # * tst/editor_test.sh the regression suite drives the fixtures through # this module and through the CLI # * doc/edit/emacs/ NOT a consumer: the Emacs mode is an independent # elisp implementation of the same algorithms, # held equal to this file by the byte-equality # checks in tst/editor_test.sh # # This file is the SOURCE OF TRUTH for the policy lists (LITERAL_KLAMMERS, # TRANSPARENT_KLAMMERS, CODE_KLAMMERS, ALIGN_KLAMMERS) and the numeric limits # (INDENT_OFFSET, CELL_MAX, ROW_MAX). It replaces the former per-editor # copies in Klammertext.py / Klammertext_indent.py / Klammertext_align.py. # SYNC: the following per-editor artifacts cannot import Python and must be # kept in step by hand: # * the Emacs defcustoms (klammertext-literal-klammers, -transparent-, # -code-, -align-klammers, -indent-offset, -align-cell-max, -align-row-max) # in doc/edit/emacs/klammertext-mode.el / -indent.el / -align.el # * the '@code'/'@c' rules + literal_code/literal_c contexts in # doc/edit/sublime/Klammertext.sublime-syntax # * the '@code'/'@c' verbatim regions in doc/edit/vim/syntax/klammertext.vim # * the '@code'/'@c' rules in doc/edit/vscode/syntaxes/klammertext.tmLanguage.json # # Installation note: editors locate this file either next to their own plugin # files (a vendored copy, placed there by doc/make_editing_zip.sh), as # ../shared/klammertext_edit.py relative to the plugin directory (the layout # of this repository), or under $KLAMMERTEXT_HOME/doc/edit/shared/. # # Python floor: 3.8 (the Sublime Text 4 plugin host) — no 3.9+ syntax here. # # Known limitation (inherited by every consumer): a raw @ or # inside a # ^'...'^ literal region confuses the scanners; use ^@ / ^# there instead. import sys # --- policy ---------------------------------------------------------------- # Klammer names whose content is a literal argument (verbatim interior, # closed by a named NAME@ delimiter). LITERAL_KLAMMERS = set(["code", "c"]) # Klammers that contribute no indentation level (a @document's paragraphs # stay at the left margin). TRANSPARENT_KLAMMERS = set(["document"]) # Klammers whose argument span holds code (inline Python is # indentation-sensitive): lines inside are never reindented. CODE_KLAMMERS = set(["eval"]) # Klammers whose rows the table-alignment command aligns. ALIGN_KLAMMERS = set(["table"]) # Spaces per nesting level. INDENT_OFFSET = 2 # Alignment limits: a row with a cell longer than CELL_MAX characters (or # spanning lines) is left untouched; if the aligned rows would exceed ROW_MAX # columns, nothing is changed. CELL_MAX = 30 ROW_MAX = 100 # --- scanner helpers ------------------------------------------------------- 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 find_literal_close(s, name, start): """Index of the exact close token NAME@ at or after START, or -1. The engine's close is a whole katom, so NAME@ preceded by a name character is content, not a close -- "basic@" does not close @c, and "barcode@" does not close @code. Single-letter literal names (@c) make this guard essential rather than theoretical.""" close = name + '@' idx = s.find(close, start) while idx > 0 and name_char_p(s[idx - 1]): idx = s.find(close, idx + 1) return idx 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 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 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 # --- delimiter matching (klammer APPLICATIONS, the single-@ tier) ---------- # # Matching is context-dependent (the same @ is both open and close, decided # by its neighbors), so no editor's built-in bracket matching can express it; # every editor integration routes its jump-to-match and live match # highlighting through these functions (Emacs excepted; see the header). # A literal klammer's own delimiters are matched BY NAME (@code <-> code@, # content opaque); all others match by depth. 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: 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 = _name_forward(s, hit + 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 idx = find_literal_close(s, name, k) i = n if idx == -1 else idx + len(name) + 1 continue elif after == '-': # @name-arg : opens no span i = k continue 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.""" i = _name_forward(s, open_pos + 1) # past the opening name 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 = _name_forward(s, pos + 1) after = s[k] if k < n else None if after == '-': return None return (pos, 'open') return (pos, 'close') 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@) 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.""" 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 = find_literal_close(s, 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 # The name must end where the token ends: "@c" found inside # "@caption" is not an opener of @c. follower = s[idx + len(open_str)] if idx + len(open_str) < len(s) else None if before != '@' and not name_char_p(follower) 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) def match_at(s, pos): """The full matching story for a caret at POS (used by jump-to-match and the live highlighters). The delimiter is taken at POS or, failing that, just before POS (the on-or-just-after rule). Return None when POS is not at an application delimiter; else a dict: {'pos', 'kind', 'token': (start, end), 'match': int or None, 'match_token': (start, end) or None, 'mismatch': bool, 'message': str or None}""" info = app_delim_info(s, pos) if info is None and pos > 0: info = app_delim_info(s, pos - 1) if info is None: return None 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) message = None if mism: if match is None: if kind == 'open': message = ("opening @%s has no matching close" % open_name(s, open_pos)) else: message = "closing delimiter has no matching open" else: message = ("closing %s@ does not match opening @%s" % (close_name(s, close_pos) or '?', open_name(s, open_pos))) other_kind = 'close' if kind == 'open' else 'open' return {'pos': dpos, 'kind': kind, 'token': token_region(s, dpos, kind), 'match': match, 'match_token': (token_region(s, match, other_kind) if match is not None else None), 'mismatch': mism, 'message': message} # --- indentation ----------------------------------------------------------- # # The convention (2026-07-27): a line indents to INDENT_OFFSET x (effective # depth); a line that BEGINS with a bar run (|, ||, ...) or a closing # delimiter sits at its opener's column. Effective depth counts every # enclosing span uniformly — applications (@), definitions (@@), system # commands (@@@) — except that TRANSPARENT_KLAMMERS contribute no level, and # lines inside literal/verbatim content, CODE_KLAMMERS argument spans, or # removed regions are never touched. Reindentation is explicit-only in every # editor: whitespace is content in Klammertext, so nothing reformats as a # side effect of typing. 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: 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 = _name_forward(s, run_end) 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 = find_literal_close(s, 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) 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) 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 = _name_forward(s, i) 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 line_beginnings(s): """Offsets of every line beginning in S.""" return [0] + [i + 1 for i, ch in enumerate(s) if ch == '\n' and i + 1 < len(s)] 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.""" 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 # --- table alignment ------------------------------------------------------- # # Pads the cells of the enclosing @table's rows so the depth-0 | separators # line up. Alignment is for SMALL data items: a row with a cell longer than # CELL_MAX or spanning lines is untouched (and contributes no width); if the # aligned rows would exceed ROW_MAX columns nothing changes. No whitespace # is ever inserted inside a bar run (|| is a row separator; | | is an empty # cell — the load-bearing-whitespace trap). Bars inside a nested klammer # belong to that klammer: only depth-0 bars count, the same rule the # Klammermachine applies to @cond. 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 = _name_forward(s, run_end) name = s[run_end:k] i = k if run_len == 1 and name in LITERAL_KLAMMERS: idx = find_literal_close(s, 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 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).""" import bisect 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 = _name_forward(content, run_end) name = content[run_end:k] i = k if run_len == 1 and name in LITERAL_KLAMMERS: idx = find_literal_close(content, 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 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) # 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.""" 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) # --- diagnostics ----------------------------------------------------------- # # A whole-buffer balance check over all three @-tiers, for editor problem # panels (LSP publishDiagnostics, Vim quickfix). The scan is the uniform # one the indentation uses — every @-run is an opener when a name follows it # and a close otherwise — extended with positions and names so problems can # be reported where they are: # # * a closing delimiter with no opening to match # * a named close whose name disagrees with its opening # * a close whose @-run length differs from its opening's (@name ... @@) # * an opening never closed (reported at the opening, at end of scan) # * a literal klammer never closed (@code without code@) # * an unclosed #[ removal block # # Content removed by ## is not scanned (it is not part of the document). def diagnostics(s): """Scan S and return a list of problems, each a dict: {'start': int, 'end': int, 'message': str, 'severity': 'error'|'warning'}. Positions are character offsets into S (token start/end).""" probs = [] stack = [] # (name, run_len, tok_start, tok_end) 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 == '#': # rest of file removed: stop scanning break elif nxt == '[': end = block_end(s, hit + 2) if end >= n and not s.endswith(']#'): probs.append({'start': hit, 'end': hit + 2, 'message': "#[ has no closing ]#", 'severity': 'warning'}) i = end elif nxt in ('+', '/', '-'): pass else: 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): k = _name_forward(s, run_end) name = s[run_end:k] i = k if run_len == 1 and name in LITERAL_KLAMMERS: idx = find_literal_close(s, name, k) if idx == -1: probs.append({'start': hit, 'end': k, 'message': ("literal klammer @%s has no " "closing %s@" % (name, name)), 'severity': 'error'}) break # everything after is verbatim 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, run_len, hit, k)) else: # a close; find the token (a preceding name run makes it named) ns = hit while ns > 0 and name_char_p(s[ns - 1]): ns -= 1 named = ns < hit and (ns == 0 or s[ns - 1] != '@') cname = s[ns:hit] if named else None tok_start = ns if named else hit if not stack: probs.append({'start': tok_start, 'end': run_end, 'message': ("closing delimiter %s has no " "matching opening" % s[tok_start:run_end]), 'severity': 'error'}) else: oname, orun, ostart, oend = stack.pop() if cname is not None and cname != oname: probs.append({'start': tok_start, 'end': run_end, 'message': ("closing %s does not match " "opening %s" % (s[tok_start:run_end], s[ostart:oend])), 'severity': 'error'}) elif orun != run_len: probs.append({'start': tok_start, 'end': run_end, 'message': ("closing %s does not match " "opening %s (%d-@ close for " "a %d-@ opening)" % (s[tok_start:run_end], s[ostart:oend], run_len, orun)), 'severity': 'error'}) i = run_end for name, run_len, ostart, oend in stack: probs.append({'start': ostart, 'end': oend, 'message': "opening %s is never closed" % s[ostart:oend], 'severity': 'error'}) probs.sort(key=lambda p: p['start']) return probs # --- whole-buffer conveniences (used by the CLI, the LSP server, tests) ---- def apply_edits(s, edits): """Apply (start, end, new) EDITS (ascending, non-overlapping) to S.""" out = [] last = 0 for a, b, new in edits: out.append(s[last:a]) out.append(new) last = b out.append(s[last:]) return ''.join(out) def indent_text(s, first=None, last=None): """Reindent S (whole buffer, or 1-based inclusive line range FIRST..LAST). Return the new text.""" bols = line_beginnings(s) if first is not None: lo = max(first, 1) hi = len(bols) if last is None else min(last, len(bols)) bols = bols[lo - 1:hi] return apply_edits(s, reindent_lines(s, bols)) def align_text(s, pos): """Align the table klammer enclosing POS. Return (new_text, message).""" span = enclosing_span(s, pos, ALIGN_KLAMMERS) if span is None: return (s, "not inside a table klammer (%s)" % ", ".join("@" + name for name in sorted(ALIGN_KLAMMERS))) _name, cs, ce = span edits, msg = compute_edits(s[cs:ce]) shifted = [(cs + a, cs + b, new) for a, b, new in edits] return (apply_edits(s, shifted), msg) def offset_of(s, line, col): """Character offset of 1-based LINE, 1-based character column COL.""" bols = line_beginnings(s) line = max(1, min(line, len(bols))) bol = bols[line - 1] eol = s.find('\n', bol) if eol == -1: eol = len(s) return min(bol + max(col - 1, 0), eol) def line_col(s, offset): """(1-based line, 1-based character column) of character OFFSET.""" offset = max(0, min(offset, len(s))) line = s.count('\n', 0, offset) + 1 bol = s.rfind('\n', 0, offset) + 1 return (line, offset - bol + 1) # --- CLI ------------------------------------------------------------------- # # The shell-out interface for editors that are neither Python-hosted nor LSP # clients (the Vim plugin). Reads the buffer on stdin (UTF-8), writes the # transformed buffer on stdout; status messages go to stderr. # # klammertext_edit.py indent [FIRST[-LAST]] reindent all / a line range # klammertext_edit.py align LINE COL align the enclosing table # klammertext_edit.py match LINE COL print "match L C" | "none MSG" # (with "mismatch" when the # pair disagrees); no buffer # klammertext_edit.py check print "L:C: message" lines; # no buffer output # # LINE and COL are 1-based; COL counts characters. Exit code: 0 (including # "nothing to do"), 2 on a usage error. def _cli(argv): def usage(): sys.stderr.write( "usage: klammertext_edit.py indent [FIRST[-LAST]] |" " align LINE COL | match LINE COL | check\n") return 2 if len(argv) < 1: return usage() mode = argv[0] s = sys.stdin.read() if mode == 'indent': first = last = None if len(argv) > 1: rng = argv[1] try: if '-' in rng: a, b = rng.split('-', 1) first, last = int(a), int(b) else: first = last = int(rng) except ValueError: return usage() sys.stdout.write(indent_text(s, first, last)) return 0 if mode == 'align': if len(argv) != 3: return usage() try: pos = offset_of(s, int(argv[1]), int(argv[2])) except ValueError: return usage() out, msg = align_text(s, pos) sys.stdout.write(out) sys.stderr.write(msg + "\n") return 0 if mode == 'match': if len(argv) != 3: return usage() try: pos = offset_of(s, int(argv[1]), int(argv[2])) except ValueError: return usage() m = match_at(s, pos) if m is None: print("none point is not on a klammer application delimiter (@)") elif m['match'] is None: print("none " + (m['message'] or "no matching delimiter")) else: line, col = line_col(s, m['match']) kind = "mismatch" if m['mismatch'] else "match" msg = (" " + m['message']) if m['mismatch'] and m['message'] else "" print("%s %d %d%s" % (kind, line, col, msg)) return 0 if mode == 'check': for p in diagnostics(s): line, col = line_col(s, p['start']) print("%d:%d: %s" % (line, col, p['message'])) return 0 return usage() if __name__ == '__main__': sys.exit(_cli(sys.argv[1:]))