# Klammertext_indent.py # # EXPERIMENTAL. Reindentation for Klammertext files — the Sublime Text port # of doc/emacs/klammertext-indent.el. This file is a separate unit: delete it # (or move it out of the package folder) to disable indentation entirely; the # rest of the Klammertext package is unaffected. # # Command name (for keymaps / the command palette): klammertext_reindent # Keybinding: Ctrl+Alt+I (in Default.sublime-keymap), scoped to Klammertext # files. It reindents the line(s) touched by the selection — the current # line when there is just a caret. Nothing reformats automatically (no # on-Enter auto-indent), because whitespace is content in Klammertext. # # The convention (2026-07-27): # # @ol <- opener at its context's content column # Item one <- content: opener column + 2 # | Item two <- bar run at the OPENER's column ("| " is two # @ol characters, so item text aligns with "Item one") # Embedded item one # | Embedded item two # @ <- close at its opener's column # | Item four # @ # # Formal rule: a line indents to offset x (effective depth); a line that # BEGINS with a bar run (|, ||, ...) or a closing delimiter (a bare @-run or # a named close) indents one level less, i.e. to its owner's opening column. # The bar-run rule is dimension-independent: | (list items), || (table rows) # and any longer run all drop to the opener's column. Effective depth counts # every enclosing span uniformly -- applications (@), definitions (@@), and # system commands (@@@) -- with these exceptions: # # * Klammers in TRANSPARENT_KLAMMERS (seeded with "document") contribute no # level, so ordinary paragraphs of a document sit at the left margin. # * Lines inside a literal klammer's verbatim content (@code ... code@) and # inside the argument span of a klammer in CODE_KLAMMERS (seeded with # "eval" -- inline Python is indentation-sensitive!) are NEVER touched. # Removed regions (#[ ... ]#, everything after ##) are likewise left # alone. # # Known limitation (shared with the Emacs scanner): a raw @ inside a ^'...'^ # literal region would confuse the depth scan. # # SYNC: the policy lists below must agree with the Emacs side: # * LITERAL_KLAMMERS with klammertext-literal-klammers (also duplicated in # Klammertext.py and Klammertext.sublime-syntax; all seeded "code") # * TRANSPARENT_KLAMMERS with klammertext-transparent-klammers ("document") # * CODE_KLAMMERS with klammertext-code-klammers ("eval") # * INDENT_OFFSET with klammertext-indent-offset (2) # The scanning helpers (name_char_p, escaped_p, block_end, at_run_end) 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 INDENT_OFFSET = 2 LITERAL_KLAMMERS = set(["code"]) TRANSPARENT_KLAMMERS = set(["document"]) CODE_KLAMMERS = set(["eval"]) # --- 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 # --- the depth scanner (port of klammertext-indent--state-at) --------------- 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 = 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: # Verbatim interior: find the closing NAME@ by name. idx = s.find(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 = i while k < n and name_char_p(s[k]): k += 1 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 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 -- also used by the standalone tests.""" 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 # --- the command ------------------------------------------------------------ if _IN_SUBLIME: class KlammertextReindentCommand(sublime_plugin.TextCommand): """Reindent the line(s) touched by the selection per the Klammertext convention. Sublime equivalent of TAB in the Emacs mode (which has no Sublime analogue: TAB there always inserts). Bound to Ctrl+Alt+I.""" def run(self, edit): view = self.view s = view.substr(sublime.Region(0, view.size())) bols = [] seen = set() for region in view.sel(): for line in view.lines(region): if line.a not in seen: seen.add(line.a) bols.append(line.a) # Compute all edits from the original text, then apply from the # bottom up so earlier offsets stay valid. for a, b, new in sorted(reindent_lines(s, bols), reverse=True): view.replace(edit, sublime.Region(a, b), new) def is_enabled(self): return self.view.match_selector(0, "text.klammertext")