# Klammertext.py # # Sublime Text plugin for klammer APPLICATION (@) delimiters. Two features, # both ports of doc/emacs/klammertext-mode.el, both reusing one matcher: # # 1. Jump between an opening and its close — the Sublime equivalent of the # Emacs mode's `klammertext-jump-to-match' (bound C-c C-j). Command name # klammertext_jump_to_match; keybinding in Default.sublime-keymap. # # 2. Live highlighting of the matching delimiter as the caret sits on one — # the equivalent of the Emacs mode's show-paren support. Implemented as a # ViewEventListener (see KlammertextMatchHighlighter at the bottom); no # language server is involved. A mismatched named close or an unbalanced # delimiter is highlighted in red with a status-bar message, mirroring the # Emacs mode's klammertext-mismatch-face + minibuffer report. # # This is the companion to Klammertext.sublime-syntax. The syntax file only # colors tokens; a tokenizer cannot match context-dependent delimiters, so the # jump is implemented here as a TextCommand. The keybinding lives in the # companion Default.sublime-keymap. # # Command name (for keymaps / the command palette): klammertext_jump_to_match # # --------------------------------------------------------------------------- # What it does (a direct port of the elisp matcher): # * On an opening @name, move to its closing @ or name@. # * On a close (bare @ or name@), move to the opening @name. # * Triggers when the caret is ON the @ or immediately AFTER it (the same # on-or-just-after rule the Emacs command uses). # * Only single-@ APPLICATION delimiters match. @@/@@@ runs, removed text # (#, ##, #[...]#), escaped ^@, and literal-klammer spans (@code ... code@) # are stepped over, exactly as in the Emacs mode. The abbreviated # @name-arg form opens no span. # * Works at every caret when there are multiple selections. # # Literal klammers (identical to C-c C-j): a @code ... code@ span is opaque. # The general depth scan still steps over such a span WHOLESALE when matching # some OTHER klammer, so verbatim @ inside it never miscount. A literal # klammer's OWN delimiters are matched BY NAME rather than by depth (see # app_match): @code jumps to the next code@, and code@ to the nearest preceding # @code — correct even when the content holds unbalanced @, e.g. @code x @ y # code@. LITERAL_KLAMMERS lists these names; keep it in sync with the '@code' # handling in Klammertext.sublime-syntax. # # LITERAL_KLAMMERS must stay in sync with the literal klammers recognized in # Klammertext.sublime-syntax (seeded there as @code). The Emacs mode keeps this # list in the `klammertext-literal-klammers' defcustom; a plugin has no access # to it, so it is duplicated here. import sublime import sublime_plugin # Klammer names whose content is a literal argument (verbatim interior). # # SYNC: this list is one of four copies that must agree. When you add or # remove a literal klammer, mirror it in all four: # * klammertext-literal-klammers in doc/emacs/klammertext-mode.el (the source # of truth; a Sublime syntax/plugin cannot read that Emacs defcustom) # * LITERAL_KLAMMERS here # * LITERAL_KLAMMERS in Klammertext_indent.py (a deletable unit, so it does # not import from this file) # * the @NAME literal rule + literal_NAME context in Klammertext.sublime-syntax # All four are currently seeded with just "code". LITERAL_KLAMMERS = set(["code"]) # --- pure helpers (operate on the whole buffer as a string) ---------------- 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 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 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: # find next @ or # at or after i (emacs re-search-forward "[@#]") 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 = hit + 1 while k < n and name_char_p(s[k]): k += 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 close = name + '@' idx = s.find(close, k) i = n if idx == -1 else idx + len(close) 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.""" n = len(s) i = open_pos + 1 while i < n and name_char_p(s[i]): # past the opening name i += 1 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 = pos + 1 while k < n and name_char_p(s[k]): k += 1 after = s[k] if k < n else None if after == '-': return None return (pos, 'open') return (pos, 'close') # --- name / mismatch helpers (for the live highlighter) -------------------- 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 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@) # --- matching dispatch: literal klammers by name, others by depth ---------- 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. A literal klammer's @NAME open and NAME@ close are matched by name, not by depth counting, because its content is verbatim.""" 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 = s.find(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 if before != '@' 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) # --- the command ----------------------------------------------------------- class KlammertextJumpToMatchCommand(sublime_plugin.TextCommand): """Jump between a klammer application's opening and closing delimiter. Sublime equivalent of the Emacs mode's C-c C-j.""" def run(self, edit): view = self.view s = view.substr(sublime.Region(0, view.size())) new_regions = [] moved = False message = None for region in view.sel(): p = region.b info = app_delim_info(s, p) if info is None and p > 0: info = app_delim_info(s, p - 1) if info is None: new_regions.append(region) message = "point is not on a klammer application delimiter (@)" continue dpos, kind = info match = app_match(s, dpos, kind) if match is None: new_regions.append(region) message = ("no matching delimiter for this %s klammer" % ("opening" if kind == 'open' else "closing")) continue new_regions.append(sublime.Region(match, match)) moved = True view.sel().clear() for r in new_regions: view.sel().add(r) if moved: view.show(view.sel()[0].b) elif message: sublime.status_message("Klammertext: " + message) def is_enabled(self): # Only meaningful in Klammertext buffers. return self.view.match_selector(0, "text.klammertext") # --- live matched-delimiter highlighting (show-paren equivalent) ----------- class KlammertextMatchHighlighter(sublime_plugin.ViewEventListener): """Highlight the matching klammer application delimiter as the caret sits on one. The Sublime equivalent of the Emacs mode's show-paren support — driven by cursor movement, reusing the same context-sensitive matcher. A matched pair is boxed (region.bluish); a mismatch or unbalanced delimiter is boxed in red (region.redish) with a status-bar message. Both the token under the caret and its match are boxed; the Emacs mode highlights only the single @ character, but boxing the whole @name / name@ reads better here. To highlight only the far delimiter, drop the first region in _update().""" MATCH_KEY = 'klammertext_paren_match' MISMATCH_KEY = 'klammertext_paren_mismatch' @classmethod def is_applicable(cls, settings): return str(settings.get('syntax', '')).endswith('Klammertext.sublime-syntax') def __init__(self, view): super().__init__(view) self._change_count = -1 self._text = '' def _buffer(self): # Re-read the buffer only when it has actually changed, so plain cursor # movement over a large file does not re-copy the whole document. cc = self.view.change_count() if cc != self._change_count: self._text = self.view.substr(sublime.Region(0, self.view.size())) self._change_count = cc return self._text def on_selection_modified_async(self): self._update() def on_activated_async(self): self._update() def _clear(self): self.view.erase_regions(self.MATCH_KEY) self.view.erase_regions(self.MISMATCH_KEY) def _update(self): view = self.view sel = view.sel() if len(sel) == 0: self._clear() return p = sel[0].b s = self._buffer() info = app_delim_info(s, p) if info is None and p > 0: info = app_delim_info(s, p - 1) if info is None: self._clear() return 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) regions = [sublime.Region(*token_region(s, dpos, kind))] if match is not None: other_kind = 'close' if kind == 'open' else 'open' regions.append(sublime.Region(*token_region(s, match, other_kind))) flags = sublime.DRAW_NO_FILL if mism: view.erase_regions(self.MATCH_KEY) view.add_regions(self.MISMATCH_KEY, regions, 'region.redish', '', flags) if match is None: if kind == 'open': msg = "opening @%s has no matching close" % open_name(s, open_pos) else: msg = "closing delimiter has no matching open" else: msg = ("closing %s@ does not match opening @%s" % (close_name(s, close_pos) or '?', open_name(s, open_pos))) sublime.status_message("Klammertext: " + msg) else: view.erase_regions(self.MISMATCH_KEY) view.add_regions(self.MATCH_KEY, regions, 'region.bluish', '', flags)