Files
klammertext/doc/edit/sublime/Klammertext.py

472 lines
17 KiB
Python
Raw Normal View History

# 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 three copies that must agree. When you add or
# remove a literal klammer, mirror it in all three:
# * 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
# * the @NAME literal rule + literal_NAME context in Klammertext.sublime-syntax
# All three 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)