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