Tree Sitter in Review Board: Applying Highlighting
Part three of a series on integrating Tree Sitter into Review Board: how we actually apply syntax highlighting to code.
Part 1: Introduction
Part 2: Shipping 167 Languages
Part 3: Applying Highlighting
Part 4: Translating Lua Patterns
Handling file content
Review Board primarily operates on files in a line-by-line basis. We split the original and modified versions into lists of lines, and then run our diff algorithm to find out which lines were added, which were removed, which were changed, etc.
Tree Sitter works a little differently. When Tree Sitter parses a file, it operates on a stream of bytes. Running queries against the parsed tree gives us a set of matching nodes. Each node in the tree has a start and end position, which contain a row (0-indexed line number) and column (0-indexed position within that line). Because Tree Sitter is operating entirely on a stream of bytes, that column number represents the number of bytes offset within the line, not the number of characters.
The distinction between characters and bytes is the bane of software developers everywhere.
To represent text, computers have to map each character to a number. The old ASCII encoding (prominently featured in
The Martian) encoded every letter of the English alphabet, upper- and lower-case, a few symbols, as well as a bunch of
non-printable “control characters” like NEW LINE and BELL within 7 bits of space. If all you have to use is 26
letters that’s fine, but we live in a global society, and even if you’re just in an English-speaking country, a lot of
names include accents or other diacritics (my own son’s name has broken several different systems throughout his school
with an “á” character).
Modern software, when it gets it right, knows that any string is not just a series of bytes, but a series of bytes with an associated encoding. ASCII is one such encoding, but there are a bunch out there. These days, except in rare circumstances, software deals with Unicode. The core idea of the Unicode standard is the definitions of code points. Each code point is a number (roughly between 1 and 221) which represents a character or piece of a character.
That means that there’s a unique number given for a simple English “a”, a Hebrew “א”, a Hangul “ㅑ”, and even historical
characters like Egyptian hieroglyphs. Code points have also been assigned to represent emoji, so Oxford 2015 Word of the
Year “Face with Tears of Joy” (😂) is represented internally on a computer as the number 128514, more commonly written
U+1F602.
There are many ways of turning that string of code points (numbers) into a stream of bytes. Code points are numbers up to 221, which fits into 3 bytes. If we just write out the code point directly using 4 byte chunks (since counting by 3s is awkward on a computer), it’s an encoding called UTF-32. Unfortunately that means if you have a document that only uses the 26 English letters with no special characters, the UTF-32 encoding takes 4 times the amount of storage space and internet bandwidth than the equivalent ASCII encoding.
These days most people use an encoding called UTF-8. In UTF-8, the first 128 characters are the same as ASCII, giving it built-in compatibility. If the first bit of that first byte is a 1 instead of a 0, that means that the character is actually a multi-byte character. Anything that doesn’t fit into ASCII gets stretched out into at least 2 bytes, and some characters can use 3 or 4. UTF-8 was invented by legendary programmers Ken Thompson (inventor of the UNIX operating system) and Rob Pike (who built the first windowing system for Unix, and more recently the Go programming language). UTF-8 is super clever in a bunch of ways, but because an individual character is not a fixed width, it’s also annoying to work with.
When Review Board needs to apply syntax highlighting, we have both representations:
Because we want to be good citizens, we need to be able to handle more than just ASCII. In this example, the é character
takes up a single position in the str representation, but two positions in the bytes.
The big lesson here is this: throughout the entire process, we can’t just assume that an index is an index. Any time we have a number that’s referring to a position somewhere in the text, we need to know: is this index a character offset or a byte offset?
The processing pipeline
Applying syntax highlighting is effectively a data processing pipeline. We want to start with our file content, and end
with HTML that we can send to the client. This means we need to not only add info for what color or style we want to
apply to any given slice of the text, but we also need to escape some characters (e.g. a < less-than operator in a C
file shouldn’t be misinterpreted by the browser as opening an HTML tag, so we need to replace it with <).
Each step does some processing and then hands the next a list of tuples. Note that two of these carry columns, and they do not mean the same thing:
#: (capture name, start row, start column, end row, end column).
#: Columns are BYTE offsets, exactly as Tree Sitter reports them.
HighlightCapture = tuple[str, int, int, int, int]
#: (capture name, start column, end column), within a single line.
#: Columns are CHARACTER offsets.
HighlightNode = tuple[str, int, int]
#: (HTML tag, character offset to insert it at).
HighlightEvent = tuple[str, int]
#: Indexes into the tuples above, for the sorts in steps 4 and 5.
START_COL, END_COL = 1, 2
EVENT_POSITION = 1Step 1: Find the highlight captures
When we run Tree Sitter queries (discussed in Part 2) against a parsed Tree, we get a set of captures.
highlights_scm = get_queries(language_name, 'highlights.scm')
ts_language = tree_sitter_language_pack.get_language(language_name)
query = tree_sitter.Query(ts_language, highlights_scm)
cursor = tree_sitter.QueryCursor(query)
captures: dict[str, list[tree_sitter.Node]] = cursor.captures(
tree.root_node, predicate_handler)The returned data from Tree Sitter is a mapping where the keys are the capture names, and the values are a list of nodes from the tree that matched.
{
'keyword': [Node, Node, ...],
'operator': [Node, ...],
...
}
Step 2: Convert the captures dict into an ordered sequence of captures
The captures we get from Tree Sitter are organized by their capture name. If we were looking for a specific thing that would be great, but this is kind of an awkward shape for the general task of applying highlighting. So the first step is to flatten it into a list.
Because we’re pulling in highlights queries from external sources, there are a bunch of highlight captures that we don’t care about. As we process the captures dict, we skip anything from the ignore list. This ignore list starts with an initial set of capture names, but can grow as we process the data and discover capture names from the upstream queries that don’t result in a highlight.
We don’t just have a whitelist because highlight capture names have an hierarchy. Some upstream queries will return
specific names like @variable.member or @variable.parameter where our highlighter is just looking for @variable.
As we find these, we’ll first check for a direct match in our known highlight classes, and if that doesn’t exist, we’ll
split on the ”.” character and look for the more generic term. If nothing is found, we’ll then extend the ignored
capture names set for the next time a file of that type is processed.
When we process this, we do it sorted with the shortest capture names first. More on this later.
names = set(captures.keys()) - HIGHLIGHT_IGNORE
captures_list: list[HighlightCapture] = []
for name in sorted(names, key=lambda name: (len(name), name)):
nodes = captures[name]
for node in nodes:
captures_list.append((
name,
node.start_point.row,
node.start_point.column,
node.end_point.row,
node.end_point.column,
))We’ve now converted our captures dict into a flat list of tuples storing the capture name and the start/end positions of the affected node.
Step 3: Split captures by line
Because we’re going to apply highlighting line by line, we now need to to organize the captures by line. There are a few things we need to watch out for:
- When Tree Sitter encounters a file that has syntax errors, it will try its best to recover and deliver a parse tree anyway. Sometimes this is done by adding error-recovery nodes which exist in a virtual space after the actual last line of the file.
- The bytes-encoded version of the file has a newline character at the end, which is sometimes consumed by a capture range.
- Some captures can span multiple lines (for example, a Python docstring will have a single
@stringcapture).
We’ll scan through now to split our single list out into a list per-line,
nodes_by_line: list[list[HighlightNode]] = [[] for _ in lines]
for name, start_row, start_col, end_row, end_col in captures_list:
if start_row >= len(lines):
# Error-recovery nodes can start past the last line.
continue
if end_row >= len(lines):
# A node that consumed the file's trailing newline ends at the start
# of a row past the last line. Clamp it to the end of that line.
end_row = len(lines) - 1
end_col = len(lines[end_row].encode())
# Deliberately new names: these are character offsets, not byte offsets.
start = _byte_to_char_pos(lines[start_row], start_col)
end = _byte_to_char_pos(lines[end_row], end_col)
if start_row == end_row:
nodes_by_line[start_row].append((name, start, end))
else:
# A capture can span lines -- a docstring is one string node -- so
# split it into one node per line.
nodes_by_line[start_row].append(
(name, start, len(lines[start_row])))
for row in range(start_row + 1, end_row):
nodes_by_line[row].append((name, 0, len(lines[row])))
nodes_by_line[end_row].append((name, 0, end))Step 4: Sort the captures within each line
We now need to make sure the captures are sorted so that we can apply them in order. We sort first by the start column, and second with the longest ranges first (so if we have two captures that begin on the same column, the “outer” one will be listed first).
Because of the sort we applied in step 1, if two highlight ranges exactly overlap, the shorter capture name will come
first (this works because Python’s sorted is stable).
for i, line_nodes in enumerate(nodes_by_line):
nodes_by_line[i] = sorted(
line_nodes,
key=lambda node: (node[START_COL], node[START_COL] - node[END_COL]))Step 5: Convert highlight nodes to events
We now split each highlight into two “events”, which correspond to the HTML tags we want to inject. Each of these has an
HTML tag (either <span class="..."> or </span>) and a position.
Highlight queries might add duplicate names for the same range (e.g @string.documentation and @string). Review Board
doesn’t explicitly highlight @string.documentation, so this gets turned into @string. As we create the events (and
discover this capture name mapping), we filter to make sure that we’re not creating HTML that looks like <span class="ts-string"><span class="ts-string">...</span></span>.
events_by_line: list[list[HighlightEvent]] = []
for nodes in nodes_by_line:
events: list[HighlightEvent] = []
added_spans: set[tuple[str, int, int]] = set()
for node_name, start, end in nodes:
if span_tag := _find_matching_highlight_tag(node_name):
span_key = (span_tag, start, end)
if span_key not in added_spans:
events.append((span_tag, start))
events.append(('</span>', end))
added_spans.add(span_key)
events_by_line.append(
sorted(events, key=lambda event: event[EVENT_POSITION]))Each event in events_by_line is now sorted by its position within the line. When two events have the same position,
the prior sort order that was determined by the sorts in steps 1 and 3 still prevails.
Step 6: Apply events to each line and encode
Now the final step: take each line of the file and insert the HTML tags at their desired locations. While we do this, we also encode any HTML-sensitive characters.
html_escape_chars = {
ord('&'): '&',
ord('<'): '<',
ord('>'): '>',
ord("'"): ''',
ord('"'): '"',
}
highlighted_lines = [''] * len(lines)
for i, (line, events) in enumerate(zip(lines, events_by_line)):
if not events:
# Simple case of a line with no events
highlighted_lines[i] = line.translate(html_escape_chars)
continue
parts: list[str] = []
last_pos = 0
for tag, pos in events:
# Add any escaped text between the previous event and this one
if pos > last_pos:
parts.append(line[last_pos:pos].translate(html_escape_chars))
last_pos = pos
parts.append(tag)
# Add any remaining text
parts.append(line[last_pos:].translate(html_escape_chars))
highlighted_lines[i] = ''.join(parts)Handling injections
Injections are one of the coolest features of Tree Sitter. They let us highlight languages-within-languages (for
example, JavaScript code inside of <script> tags in an HTML file).
We handle injections by doing a parallel scan. We do the highlights queries for the entire file, and then we do a separate injections query. For each injection we find, we can ask Tree Sitter to give us a parse tree for just that section of the file with a different language. Even better: the positions in the parse tree that it gives us are the node positions in the entire file rather than just the injected content.
So for each injected language, we construct a new parser, set included_ranges, and run a parse. We then run highlights
on that smaller parse tree, giving us another set of captures. We can then just chain all of the separate captures
together to pass into the highlighting pipeline. Once we have captures, the pipeline doesn’t care what language they’re
coming from.
What’s with all the sorting?
In several of the steps of the pipeline, we’re carefully managing the sort order of the highlighting. Some of this is obvious: we need to process things in the order that the appear in the file, top to bottom, left to right. The less obvious one is the sort by the length of the capture name that happened all the way back in step 2.
Tree Sitter queries don’t assign a unique name per symbol. Each query runs in parallel, so we may get the same range marked for highlighting with multiple different names.
Here’s a very simple concrete example:
print(None)The print token matches three separate highlight queries:
@variable@function.call@function.builtin
In every case I investigated from the queries, longer names indicate more specificity (especially when we get matches
like @string and @string.documentation). We want to make sure that if we have CSS that highlights function.builtin
differently from function.call, the more-specific name wins. That sort order we established at the very beginning of
the pipeline ensures that in the resulting HTML, when we have multiple highlight classes that apply to the same range,
the inner-most element is the most specific.