Wren Linux

wren-linux / tools / site_hl.py

# tools/site_hl.py: colours a file of the source browser as vim does on a dark background,
# for tools/site.py: bash (by its name, or its #! line) and Python. Any other file, and one
# this can't read, comes back plain.
#
# Safe the way tools/site_md.py is: the text is cut into pieces, each escaped (html.escape),
# and the only markup is <span class="X"> with X one of six fixed names (c comment, s string,
# k keyword, v variable, n number, p #! line or decorator); the style sheet colours them.
# Wrong colours are possible (bash can't be fully read without running it); markup isn't.
import html
import io
import keyword
import re
import tokenize

e = html.escape

BASH_WORDS = ("if then else elif fi for while until do done case esac in function select time "
              "return local export readonly declare typeset set shift exit break continue trap "
              "source eval exec unset").split()
BASH = re.compile(r"""
    (?P<c>(?:(?<=^)|(?<=[\s;|&(]))\#[^\n]*)
  | (?P<h><<-?[ \t]*(?P<hq>['"]?)(?P<hw>[A-Za-z_][A-Za-z0-9_]*)(?P=hq))
  | (?P<s>'[^']*'|\$'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")
  | (?P<v>\$\{[^}\n]*\}|\$[A-Za-z_][A-Za-z0-9_]*|\$[0-9@*\#?$!-])
  | (?P<k>(?<![\w./-])(?:%s)(?![\w./-]))
  | (?P<n>(?<![\w.-])[0-9]+(?![\w.-]))
""" % "|".join(BASH_WORDS), re.X | re.M)


VAR = re.compile(r"\$\{[^}\n]*\}|\$[A-Za-z_][A-Za-z0-9_]*|\$[0-9@*#?$!-]")


def dquoted(s):
    """A "string" as vim colours it: the string, its $variables in their own colour."""
    out, i = [], 0
    for m in VAR.finditer(s):
        out += [("s", s[i:m.start()]), ("v", m.group(0))]
        i = m.end()
    return out + [("s", s[i:])]


def bash(text):
    """[(class or None, text)] for a bash file; heredocs as strings."""
    out, i, pending = [], 0, []
    while i < len(text):
        if text[i] == "\n" and pending:
            # the heredocs of the line just ended: their bodies, up to each one's word
            out.append((None, "\n"))
            i += 1
            for word, strip in pending:
                end = re.compile(r"^%s%s$" % ("\t*" if strip else "", re.escape(word)), re.M).search(text, i)
                stop = end.start() if end else len(text)
                out.append(("s", text[i:stop]))
                i = stop
                if end:
                    out.append(("k", text[end.start():end.end()]))
                    i = end.end()
            pending = []
            continue
        m = BASH.search(text, i)
        # (only up to the next newline: a pending heredoc must see it)
        nl = text.find("\n", i)
        if m is None or (pending and nl != -1 and m.start() > nl):
            stop = nl if pending and nl != -1 else len(text)
            out.append((None, text[i:stop]))
            i = stop
            continue
        out.append((None, text[i:m.start()]))
        kind = m.lastgroup if m.lastgroup not in ("hq", "hw") else "h"
        if m.group("h"):
            out.append(("p", m.group("h")))
            pending.append((m.group("hw"), m.group("h").startswith("<<-")))
        elif m.group("c") and m.start() == 0 and m.group("c").startswith("#!"):
            out.append(("p", m.group("c")))
        elif m.group("s") and m.group("s").startswith('"'):
            out += dquoted(m.group("s"))
        else:
            out.append((kind, m.group(0)))
        i = m.end()
    return out


def python(text):
    """[(class or None, text)] for a Python file, as Python's own tokenize reads it."""
    lines = text.splitlines(keepends=True)
    starts = [0]
    for l in lines:
        starts.append(starts[-1] + len(l))
    out, pos, after_at = [], 0, False
    for tok in tokenize.generate_tokens(io.StringIO(text).readline):
        a = starts[tok.start[0] - 1] + tok.start[1] if tok.start[0] - 1 < len(starts) else len(text)
        b = starts[tok.end[0] - 1] + tok.end[1] if tok.end[0] - 1 < len(starts) else len(text)
        if a < pos:
            continue
        name = tokenize.tok_name[tok.type]
        cls = None
        if name == "COMMENT":
            cls = "p" if a == 0 and tok.string.startswith("#!") else "c"
        elif name in ("STRING", "FSTRING_START", "FSTRING_MIDDLE", "FSTRING_END"):
            cls = "s"
        elif name == "NUMBER":
            cls = "n"
        elif name == "NAME":
            cls = "p" if after_at else ("k" if keyword.iskeyword(tok.string) else None)
        after_at = name == "OP" and tok.string == "@"
        out.append((None, text[pos:a]))
        out.append((cls, text[a:b]))
        pos = b
    out.append((None, text[pos:]))
    return out


def language(name, text):
    first = text.split("\n", 1)[0]
    if re.search(r"\.(sh|bash)$", name) or re.match(r"#!.*\b(ba)?sh\b", first):
        return bash
    if name.endswith(".py") or re.match(r"#!.*\bpython", first):
        return python
    return None


def lines(name, text):
    """The file's lines as escaped HTML, coloured if its language is one of these. They join
    with the text's own newlines back into exactly the text (escaped)."""
    pieces = None
    lang = language(name, text)
    if lang:
        try:
            pieces = lang(text)
            if "".join(t for _, t in pieces) != text:
                pieces = None  # (a reader that lost or added a character: plain, then)
        except (tokenize.TokenError, IndentationError, SyntaxError, RecursionError, ValueError):
            pieces = None
    if pieces is None:
        pieces = [(None, text)]
    out, cur = [], []
    for cls, t in pieces:
        for k, part in enumerate(t.split("\n")):
            if k:
                out.append("".join(cur))
                cur = []
            if part:
                # (a piece empty here would only be an empty span)
                cur.append('<span class="%s">%s</span>' % (cls, e(part)) if cls else e(part))
    out.append("".join(cur))
    if out and out[-1] == "" and text.endswith("\n"):
        out.pop()
    return out