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