Python highlighter

Tokenizes Python with the standard library and emits safe, colored source HTML.

scripts/python_source_renderer.py · 254 lines · sha256 a761fdfc675c…

#!/usr/bin/env python3
"""Render Python source as safe, dependency-free syntax-highlighted HTML.

The public :func:`render_python_source` function returns an HTML fragment that
can be placed inside a ``<code>`` element.  Python's standard-library
``tokenize`` module supplies the token boundaries, while all source text is
escaped before it is emitted.
"""

import builtins
import html
import io
import keyword
import re
import token
import tokenize
from typing import List, Optional, Sequence, Tuple


PYTHON_SOURCE_CSS = """
.python-source {
  color: var(--syntax-name, var(--fn-syntax-name, #243247));
  tab-size: 4;
}
.python-source .syntax-keyword {
  color: var(--syntax-keyword, var(--fn-syntax-keyword, #8f3f71));
  font-weight: 650;
}
.python-source .syntax-builtin {
  color: var(--syntax-builtin, var(--fn-syntax-builtin, #315f8a));
}
.python-source .syntax-name {
  color: var(--syntax-name, var(--fn-syntax-name, #243247));
}
.python-source .syntax-string {
  color: var(--syntax-string, var(--fn-syntax-string, #386747));
}
.python-source .syntax-number {
  color: var(--syntax-number, var(--fn-syntax-number, #8c511d));
}
.python-source .syntax-comment {
  color: var(--syntax-comment, var(--fn-syntax-comment, #627063));
  font-style: italic;
}
.python-source .syntax-operator {
  color: var(--syntax-operator, var(--fn-syntax-operator, #6f4b7d));
}
.python-source .syntax-decorator {
  color: var(--syntax-decorator, var(--fn-syntax-decorator, #86561e));
  font-weight: 650;
}
.python-source .source-line {
  display: block;
  min-height: var(--fn-code-leading, 1em);
  position: relative;
}
.python-source.has-line-numbers .source-line {
  padding-left: var(--fn-code-gutter, 3.75rem);
}
.python-source .source-line-number {
  color: var(--fn-line-number, #6d756f);
  font-size: 0.82em;
  font-variant-numeric: tabular-nums;
  left: 0;
  position: absolute;
  text-align: right;
  text-decoration: none;
  width: var(--fn-line-number-width, 2.7rem);
}
.python-source .source-line-number::before { content: attr(data-line-number); }
.python-source .source-line-number:hover { color: currentColor; text-decoration: underline; }
""".strip()


_BUILTIN_NAMES = frozenset(dir(builtins))
_SAFE_PREFIX = re.compile(r"[^A-Za-z0-9_-]+")
_TokenRange = Tuple[int, int, str]


def _line_starts(source: str) -> List[int]:
    starts = [0]
    starts.extend(index + 1 for index, character in enumerate(source) if character == "\n")
    return starts


def _offset(starts: Sequence[int], position: Tuple[int, int], source_length: int) -> int:
    row, column = position
    line_index = row - 1
    if line_index < 0 or line_index >= len(starts):
        return source_length
    return min(starts[line_index] + column, source_length)


def _ordinary_class(token_type: int, value: str) -> Optional[str]:
    if token_type == token.NAME:
        if keyword.iskeyword(value):
            return "syntax-keyword"
        if value in _BUILTIN_NAMES:
            return "syntax-builtin"
        return "syntax-name"
    if token_type == token.STRING:
        return "syntax-string"
    if token_type == token.NUMBER:
        return "syntax-number"
    if token_type == tokenize.COMMENT:
        return "syntax-comment"
    if token_type == token.OP:
        return "syntax-operator"
    return None


def _token_ranges(source: str) -> List[_TokenRange]:
    starts = _line_starts(source)
    ranges: List[_TokenRange] = []
    at_statement_start = True
    in_decorator_reference = False

    for item in tokenize.generate_tokens(io.StringIO(source).readline):
        token_type, value, start, end, _ = item
        css_class: Optional[str]

        if token_type in (token.INDENT, token.DEDENT):
            at_statement_start = True
            continue
        if token_type in (token.NEWLINE, tokenize.NL):
            if token_type == token.NEWLINE:
                at_statement_start = True
            in_decorator_reference = False
            continue

        is_decorator_at = token_type == token.OP and value == "@" and at_statement_start
        if is_decorator_at:
            css_class = "syntax-decorator"
            in_decorator_reference = True
        elif in_decorator_reference and (
            token_type == token.NAME or (token_type == token.OP and value == ".")
        ):
            css_class = "syntax-decorator"
        else:
            if in_decorator_reference:
                in_decorator_reference = False
            css_class = _ordinary_class(token_type, value)

        if css_class:
            range_start = _offset(starts, start, len(source))
            range_end = _offset(starts, end, len(source))
            if range_end > range_start:
                ranges.append((range_start, range_end, css_class))

        if token_type not in (
            token.ENCODING,
            token.ENDMARKER,
            token.INDENT,
            token.DEDENT,
            tokenize.COMMENT,
        ):
            at_statement_start = False

    return ranges


def _render_interval(source: str, start: int, end: int, ranges: Sequence[_TokenRange]) -> str:
    rendered: List[str] = []
    cursor = start
    for range_start, range_end, css_class in ranges:
        if range_end <= start:
            continue
        if range_start >= end:
            break
        token_start = max(range_start, start)
        token_end = min(range_end, end)
        if token_start > cursor:
            rendered.append(html.escape(source[cursor:token_start], quote=True))
        if token_end > token_start:
            rendered.append(
                f'<span class="{css_class}">'
                f"{html.escape(source[token_start:token_end], quote=True)}</span>"
            )
            cursor = token_end
    if cursor < end:
        rendered.append(html.escape(source[cursor:end], quote=True))
    return "".join(rendered)


def _safe_prefix(value: str) -> str:
    prefix = _SAFE_PREFIX.sub("-", value).strip("-")
    return prefix or "python-source"


def _render_numbered(source: str, ranges: Sequence[_TokenRange], anchor_prefix: str) -> str:
    prefix = _safe_prefix(anchor_prefix)
    rendered: List[str] = []
    line_start = 0
    line_number = 1

    for newline_index in (
        [index for index, character in enumerate(source) if character == "\n"] + [len(source)]
    ):
        line_end = newline_index
        newline = ""
        if newline_index < len(source):
            newline = "\n"
            if line_end > line_start and source[line_end - 1] == "\r":
                line_end -= 1
                newline = "\r\n"
        if line_start == len(source) and not newline:
            break
        identifier = f"{prefix}-L{line_number}"
        rendered.append(
            f'<span class="source-line" id="{identifier}">'
            f'<a class="source-line-number" href="#{identifier}" '
            f'data-line-number="{line_number}" aria-label="Line {line_number}" '
            'tabindex="-1"></a>'
            f"{_render_interval(source, line_start, line_end, ranges)}</span>{newline}"
        )
        line_start = newline_index + 1
        line_number += 1

    return "".join(rendered)


def render_python_source(
    source: str,
    *,
    line_numbers: bool = False,
    anchor_prefix: str = "python-source",
    compact: bool = False,
) -> str:
    """Return a safe syntax-highlighted HTML fragment for *source*.

    Whitespace and newlines from the input are emitted exactly.  When
    ``line_numbers`` is true, every physical source line receives a stable,
    accessible anchor.  Compact mode leaves ordinary identifiers and operators
    unwrapped because the code element already supplies their base color.  This
    keeps large public source pages small without changing their text. Malformed
    or incomplete Python falls back to fully escaped, unhighlighted source
    instead of failing the surrounding render.
    """

    try:
        ranges = _token_ranges(source)
    except (tokenize.TokenError, IndentationError, SyntaxError):
        ranges = []

    if compact:
        ranges = [
            value
            for value in ranges
            if value[2] not in {"syntax-name", "syntax-operator"}
        ]

    if line_numbers:
        return _render_numbered(source, ranges, anchor_prefix)
    return _render_interval(source, 0, len(source), ranges)