Files
carbon-lang/utils/textmate/render_sample.py
T
Chandler Carruth fccb349af4 Improve the TextMate grammar scopes and precision (again) (#7914)
This is a re-do of #7888

Outside a call, the grammar scoped a name as a type or a variable by its
capitalization. A theme could not distinguish a type from a value with
the same spelling, or a parameter, a member, or an implemented interface
from any other name. Pointer member access `->` was scoped as a result
arrow, which scoped `Lookup` in `regions->Lookup(x)` as a type.

`entity.name.type` now applies only in a type position (after `:`, `->`,
`as`, `adapt`, or `require`, or in an `impl`) and to the name a nominal
declaration declares. A qualifier, in a declared name like `fn N.Base(T:
type).F()` or in a type, is `entity.name.scope-resolution` nested in
`entity.name.type`. As in the parser, a name is a qualifier when its
parameter lists are followed by `.`. Parameters, `let` bindings, choice
alternatives, members, implemented interfaces, primitive types,
`import`, and the strings naming a builtin or a library have their own
scopes, and brackets use the C++ grammar's scopes. A `->` written
directly after its operand is member access; no result arrow in the
repository is written that way.

The sample palette renders each scope that depends on context
differently, and `tmlanguage.py` now supports nested captures.

In 17 of the 19 themes measured, the VS Code built-ins plus One Dark
Pro, Night Owl, Tokyo Night, and Nord, 13% to 27% of the characters in
hand-written Carbon change color or style. A theme loses a color where
`.` is now punctuation, where word operators like `as` are no longer
control-flow keywords, or where a name outside a type position is no
longer a type.

Assisted-by: Claude Code
2026-10-06 23:21:43 +00:00

247 lines
9.2 KiB
Python
Executable File

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# ///
"""Renders Carbon sources as highlighted SVG.
This regenerates the renderings next to the TextMate samples, so they show what
the grammar in this repository actually produces rather than whatever an editor
looked like when someone last took a screenshot by hand.
The SVG holds the source as text rather than as outlines, so whatever displays
it lays the text out and draws the glyphs; nothing here rasterizes.
"""
__copyright__ = """
Part of the Carbon Language project, under the Apache License v2.0 with LLVM
Exceptions. See /LICENSE for license information.
SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
"""
import argparse
import html
import itertools
import sys
from pathlib import Path
from typing import Iterator, Optional
import tmlanguage
# The grammar is part of the VS Code extension, which must all be contained
# under the extension's root directory.
_GRAMMAR_PATH = (
Path(__file__).resolve().parents[1] / "vscode" / "carbon.tmLanguage.json"
)
# A theme built for this grammar rather than taken from an editor. Each family
# of related scopes gets one color, and bold, italic, and underline distinguish
# the scopes within a family. Every scope the grammar picks from context renders
# differently, so a change to what it scopes is visible in the rendering. Scopes
# that follow from the spelling alone, such as a bracket's shape, can share a
# rendering, since the text already shows them. A scope with no entry of its own
# takes its family's. The colors are Catppuccin Mocha's.
_COLORS = {
"comment": "#7f849c",
"punctuation.definition.comment": "#7f849c",
"keyword.other.directive": "#7f849c",
"storage": "#cba6f7",
"constant.language": "#cba6f7",
"keyword.control": "#f38ba8",
"keyword.operator": "#94e2d5",
"keyword.other": "#94e2d5",
"punctuation": "#74c7ec",
"entity.name.type": "#f9e2af",
"support.type": "#f9e2af",
"support.class": "#f9e2af",
"entity.other.inherited-class": "#f9e2af",
"entity.name.namespace": "#f5e0dc",
"entity.name.scope-resolution": "#f5e0dc",
"entity.name.tag": "#f5e0dc",
"entity.name.function": "#89b4fa",
"support.function": "#89b4fa",
"variable": "#cdd6f4",
# Members of something else: a field or other member read through `.` or
# `->`, and a choice's alternatives.
"variable.other.property": "#eba0ac",
"variable.other.enummember": "#eba0ac",
"string": "#a6e3a1",
"constant.character.escape": "#f5c2e7",
"constant.numeric": "#fab387",
# Inline C++ is highlighted by the C++ grammar, so here it gets the color of
# unscoped text.
"meta.embedded": "#a6adc8",
}
# A theme resolves the font style separately from the color, so a scope takes
# its color from its family and its style from its own entry. An empty style
# clears one an enclosing scope set, which keeps an escape non-italic inside an
# italic single-quoted string.
_STYLES = {
"comment": "italic",
"punctuation.definition.comment": "",
"keyword.other.directive": "bold",
"storage.modifier": "italic",
"constant.language": "bold",
"keyword.other": "italic",
"keyword.operator.type.pointer": "bold",
"punctuation.terminator": "bold",
"punctuation.definition.string": "italic",
"punctuation.definition.raw-identifier": "bold italic",
"support.type": "italic",
"support.class": "bold",
"entity.name.type.class": "bold italic",
"entity.other.inherited-class": "underline",
"entity.name.namespace.library": "bold",
"entity.name.scope-resolution": "bold italic",
"entity.name.tag": "italic",
"entity.name.function.definition": "bold",
"entity.name.function.member": "italic",
"support.function": "bold italic",
"variable.parameter": "italic",
"variable.language": "bold",
"variable.other.constant": "bold italic",
"variable.other.property": "italic",
"variable.other.enummember": "bold",
"string.quoted.single": "italic",
"string.quoted.triple": "bold",
"constant.character.escape": "",
"constant.numeric.hex": "italic",
"constant.numeric.binary": "bold",
"constant.numeric.octal": "bold italic",
"meta.embedded": "italic",
}
# The four font styles a theme can name, as SVG presentation attributes.
_STYLE_ATTRS = {
"italic": ' font-style="italic"',
"bold": ' font-weight="bold"',
"underline": ' text-decoration="underline"',
"strikethrough": ' text-decoration="line-through"',
}
_BACKGROUND = "#1e1e2e"
# Unscoped text. It is dimmer than any scope's color, so text the grammar misses
# stands out.
_FOREGROUND = "#a6adc8"
_GUTTER = "#6c7086"
# Whatever monospace font the viewer has: nothing can be fetched, because
# GitHub serves SVG under `default-src 'none'` and a web font would be blocked.
# The text simply flows, so the font's own metrics lay each line out.
_FONT = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
_FONT_SIZE = 14
# Only used to size the canvas. Monospace advances cluster near 0.6em, so a
# font a little wider than this just runs closer to the right edge.
_CHAR_WIDTH = 8.5
_LINE_HEIGHT = 19
# How far above the bottom of its line the baseline sits, leaving room for the
# descenders of `g` and `y` at this line height.
_DESCENT = 5
_PAD = 12
def _select(table: dict[str, str], scopes: list[str], default: str) -> str:
"""Resolves a scope stack against one table, innermost scope first.
Within a scope the longest matching prefix wins, so a selector such as
`keyword.other.directive` beats `keyword.other`. A scope the table names at
no prefix falls back to its enclosing scope, as a theme does.
"""
for scope in reversed(scopes):
while scope:
if scope in table:
return table[scope]
scope = scope.rpartition(".")[0]
return default
def _span_for(scopes: list[str]) -> str:
"""Resolves a scope stack to the attributes its `tspan` carries."""
attrs = "".join(
_STYLE_ATTRS[word]
for word in _select(_STYLES, scopes, "").split()
if word in _STYLE_ATTRS
)
return f'fill="{_select(_COLORS, scopes, _FOREGROUND)}"{attrs}'
def _runs(spans: list[str]) -> Iterator[tuple[int, int, str]]:
"""Merges a per-character attribute list into `(start, end, attrs)` runs."""
end = 0
for attrs, run in itertools.groupby(spans):
start, end = end, end + len(list(run))
yield start, end, attrs
def render(grammar: tmlanguage.Grammar, source: str) -> str:
"""Renders a Carbon source as a standalone SVG document."""
lines = tmlanguage.split_lines(source)
plain = f'fill="{_FOREGROUND}"'
styled = [[plain] * len(line) for line in lines]
for token in tmlanguage.tokenize(grammar, source):
attrs = _span_for(token.scopes)
row = styled[token.line]
# A token runs one past the line, over the newline it was tokenized
# with, and there is no column there to style.
for column in range(token.start, min(token.end, len(row))):
row[column] = attrs
# Trailing blank lines would only pad the bottom of the image.
while lines and not lines[-1].strip():
lines.pop()
styled.pop()
digits = len(str(len(lines))) if lines else 1
longest = max((len(line) for line in lines), default=0)
width = round(_PAD * 2 + (digits + 1 + longest) * _CHAR_WIDTH)
height = _PAD * 2 + len(lines) * _LINE_HEIGHT
out = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}"'
f' height="{height}" viewBox="0 0 {width} {height}">',
f'<rect width="100%" height="100%" fill="{_BACKGROUND}"/>',
# Indentation has to survive two eras of the spec. SVG 1.1 renderers
# read `xml:space`, and honor it here on the group. Browsers follow
# SVG 2, where whitespace is a CSS property and neither form is
# inherited into text from an ancestor, so each `text` repeats it.
f'<g xml:space="preserve" font-family="{_FONT}"'
f' font-size="{_FONT_SIZE}">',
]
for linenum, linetext in enumerate(lines):
baseline = _PAD + (linenum + 1) * _LINE_HEIGHT - _DESCENT
spans = [
f'<tspan fill="{_GUTTER}">{str(linenum + 1).rjust(digits)} </tspan>'
] + [
f"<tspan {attrs}>"
f"{html.escape(linetext[start:end], quote=False)}</tspan>"
for start, end, attrs in _runs(styled[linenum])
]
out.append(
f'<text style="white-space:pre" x="{_PAD}" y="{baseline}">'
f"{''.join(spans)}</text>"
)
out.append("</g></svg>")
return "\n".join(out) + "\n"
def main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("sources", nargs="+", type=Path)
parser.add_argument("--grammar", type=Path, default=_GRAMMAR_PATH)
args = parser.parse_args(argv)
grammar = tmlanguage.Grammar.load(args.grammar)
for source_path in args.sources:
out_path = source_path.with_suffix(".svg")
out_path.write_text(
render(grammar, source_path.read_text(encoding="utf-8")),
encoding="utf-8",
)
print(f"wrote {out_path}")
return 0
if __name__ == "__main__":
sys.exit(main())