mirror of
https://github.com/carbon-language/carbon-lang.git
synced 2026-11-14 18:08:38 +00:00
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
247 lines
9.2 KiB
Python
Executable File
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())
|