https://haz.ee/technical/rehype-tree-sitter/
Better code highlighting on the web: rehype-tree-sitter
Saturday, July 22nd, 2023 Back to posts
During the migration of my blog from handwritten HTML to a fancier
web framework, I noticed that there weren't many code block
highlighting options to choose from. In terms of Astro, there are
only two plugins that are capable of syntax highlighting: Shiki and
PrismJS. Shiki uses TextMate grammars. I'm not the hugest fan of
regex-based syntax highlighting engines and I've spent way too much
time creating custom grammars for new languages before, only to be
met with a buggy mess in the end. (Skill issue?) Prism also uses
regex rules to do its highlighting, so that's also not an option.
In my opinion, tree-sitter is currently the best option for
highlighting source code. There's no contest. Not only is it
performant, but the DSL is much easier to wrap your head around. I
knew right away that this was the tool I wanted to use to power the
pretty little colors on my website. The real question was figuring
out how.
After searching around, I discovered that Astro uses unified as its
underlying engine for working with HTML and Markdown/MDX. To get what
I want, I'd have to create a plugin that fits somewhere in-between
these conversion steps and produces the correct output.
Iteration One -- Command Line Interface
In the beginning, I had a hard time deciding on which part of the
HTML/Markdown processing chain I wanted to inject my logic into.
rehype is responsible for transforming and working with HTML syntax
trees (which they call hast). remark is the same, but for Markdown
(mast). In the end I decided upon rehype, because to get an MVP out,
I wanted to use the tree-sitters built-in HTML highlight
functionality.
To create a rehype plugin, I first needed to create a node package
that will export the function responsible for transforming the
relevant syntax tree.
import { visit } from "unist-util-visit";
import { rehype } from "rehype";
const isCodeBlockElement = (node) => node.tagName === "code";
export function rehypeTreeSitter(options) {
return function (tree) {
visit(tree, isCodeBlockElement, (node, index, parent) => {
console.log(tree);
});
};
}
Before messing around with Astro and real data, I created a Jest test
to make sure my code was correct. This also allows for faster
iteration speed, since we only execute code we care about.
import { rehypeTreeSitter } from "./index.js";
import { rehype } from "rehype";
test("basic math", () => {
expect(2 + 2).toBe(4);
});
Now that I had some tooling in place, I could begin coding. I knew
that if I executed tree-sitter highlight -H code.js, it'd spit some
HTML out like below.
| 1 |
function
sum(
a,
b)
{
|
| 2 |
return
a
+
b
;
|
| 3 |
} |
Barring my initial concerns with the per-element styling, this looked
workable! In my mind, I simply had to re-use whatever facilities
unified has for parsing HTML into hast, and replace the code block in
the original hast with the tree-sitter version. This ended up looking
like this:
const exampleScopeMap = {
"language-javascript": "source.js",
"language-sh": "source.bash",
"language-xml": "source.xml",
};
function doesNodeHaveChildTable(node) {
for (const child of node.children) {
if (child.tagName === "table") return true;
}
return false;
}
export function rehypeTreeSitter(options) {
return function (tree) {
visit(tree, isCodeBlockElement, (node, index, parent) => {
if (Object.keys(node.properties).length === 0) return;
const code = node.children[0].value;
const language = node.properties.className[0];
if (!(language in exampleScopeMap)) return;
const temporarySourceFile = temporaryFile();
writeFileSync(temporarySourceFile, code);
try {
const result = execSync(
`tree-sitter highlight --scope ${exampleScopeMap[language]} -H ${temporarySourceFile}`
).toString();
const resultTree = rehype().parse(result);
const resultBody = resultTree.children[1].children[2];
if (!doesNodeHaveChildTable(resultBody)) {
console.error(`No 'highlights.scm' found for ${language}`);
return;
}
const codeTable = resultTree.children[1].children[2].children[1];
parent.children[0].children = [codeTable];
} catch (error) {
console.error(error);
}
unlinkSync(temporarySourceFile);
});
};
}
Here's a high-level breakdown of the process.
1. Create a temporary file to store the source code in
2. Execute tree-sitter highlight --scope -H to get the HTML
3. Reparse the HTML into hast (with some safety checks)
4. Set the parent's child to contain the code table
This code works, and for a couple of hours of work, produces stellar
results.
tree-sitter built-in HTML highlighter
However, there were a couple bugs that I encountered that made this
approach unacceptable.
1. Whatever was converting the final hast into HTML inserted as many
newlines into the pre element as there were lines of code in the
original source block. This took a while to diagnose, and even
longer to fix. ^1
2. The colors were determined by an external tool (the tree-sitter
configuration). This made it difficult to customize and control.
I also wanted to have CSS change the color scheme based on the
user's system appearance.
3. The produced code utilized a table to get the lines of code to
match up with their line number. While this did work, I had
suspicions that it wasn't the correct approach for supporting
line numbers.
Wanting more flexibility, I decided it was time to dig deeper into
the underlying code. Having studied how that exact command line
invocation worked, I now knew that tree-sitter ships libraries that
give you more control over the process. Notably,
tree-sitter-highlight which gives you an iterator over your code
annotating spans with their highlight, and tree-sitter-loader, which
handles compilation behind the scenes when supplying grammars.
Iteration Two -- Native Rust Node Plugin
The big question (again) is how. How can I execute some Rust code
from within the Node process? The answer is the Node-API. The
Node-API lets you create C/C++ add-ons, which you can import into
your JavaScript code and call like normal. Knowing this, I began
working on the Rust code first.
Here's the test program I used for verifying everything worked before
attempting to port it to the Node-API.
fn main() -> Result<(), Box> {
let language_root_path = std::env::var("TREE_SITTER_LANGUAGE_ROOT")?;
let mut loader = tree_sitter_loader::Loader::new()?;
loader.find_all_languages(&tree_sitter_loader::Config {
parser_directories: {
let mut vec = Vec::new();
vec.push(PathBuf::from(language_root_path));
vec
},
..Default::default()
})?;
let mut process_arg_iter = std::env::args().skip(1);
let root_scope = format!(
"source.{}",
process_arg_iter.next().ok_or(Error::NoScopeProvided)?
);
let source = std::io::read_to_string(std::io::stdin())?;
let (language, language_configuration) = loader
.language_configuration_for_scope(&*root_scope)?
.ok_or(Error::MissingLanguageConfiguration)?;
let highlight_config = language_configuration
.highlight_config(language)?
.ok_or(Error::MissingHighlightConfiguration)?
.clone();
let mut highlighter = Highlighter::new();
let mut highlight_iter =
highlighter.highlight(highlight_config, source.as_bytes(), None, |_| None)?;
while let Some(Ok(event)) = highlight_iter.next() {
match event {
tree_sitter_highlight::HighlightEvent::Source { start, end } => {
print!("{}", &source[start..end])
}
tree_sitter_highlight::HighlightEvent::HighlightStart(_) |
tree_sitter_highlight::HighlightEvent::HighlightEnd => {}
}
}
println!("");
Ok(())
}
The real meat of the function is near the end. Using while let I'm
able to extract the HighlightEvent's from that particular session and
act accordingly. I don't translate any of the highlight calls, but
being able to recreate the text from the indices is a good start. Now
that I can react to each 'chunk' of data produced by tree-sitter,
it's time to port this to Node.
node-bindgen is an excellent package for this. It allows me to simply
annotate a function with a decorator (proc-macro) and use the
resulting binary in my node project. Below is all the code I needed
to change.
#[node_bindgen]
fn driver(
language_root_path: String,
root_scope: String,
source: String,
callback: F,
) -> Result<(), NjError> {
// ...
}
I could then require my dylib and call driver. Awesome!
You might be wondering what callback is doing there. The general idea
behind this approach is that Node hands control over the highlighting
process to Rust. Rust is responsible for executing the code that
parses the incoming text, but the business logic is determined by the
callback. In rehype-tree-sitter's case, it needs to react to the
event and modify the hast tree accordingly. Let's jump back to the
plugin.
export default function rehypeTreeSitter(options) {
if (options === undefined) throw new Error("Need to provide `options.treeSitterGrammarRoot`");
if (options.treeSitterGrammarRoot === undefined)
throw new Error("Need to provide `options.treeSitterGrammarRoot`");
return function (tree) {
visit(tree, isCodeBlockElement, (node, index, parent) => {
if (Object.keys(node.properties).length === 0) return;
const code = node.children[0].value;
const language = node.properties.className[0];
if (!(language in (options.scopeMap || exampleScopeMap))) return;
node.children = [];
const highlightStack = [];
core.driver(
options.treeSitterGrammarRoot,
(options.scopeMap || exampleScopeMap)[language],
code,
(event) => {
if (event.source !== undefined) {
const sourceChunk = stringByteSlice(
code,
Number(event.source.start),
Number(event.source.end)
);
if (highlightStack.length === 0) {
node.children.push({ type: "text", value: sourceChunk });
} else {
node.children.push(h("span", { class: highlightStack.join(" ") }, sourceChunk));
}
} else if (event.highlightStart !== undefined) {
highlightStack.push(event.highlightStart.highlightName);
} else if (event === "HighlightEnd") {
highlightStack.pop();
}
}
);
});
};
}
There's a bunch of extra functionality that wasn't here before.
1. options.treeSitterGrammarRoot: This lets the consumer specify
which directory tree-sitter-loader should load and compile its
grammars from.
2. options.scopeMap: This option lets consumers specify a mapping
between the language class markdown parsers insert into the code
element and the tree-sitter scope. This is how we communicate to
tree-sitter which grammar to use.
3. highlightStack keeps track of the highlight classes. This is
helpful for denoting tokens that belong to more than one class.
4. stringByteSlice: Originally, I used source.splice(), but that
broke on UTF-8 code. This was because JavaScript strings are
encoded in UTF-16, which means that the byte indices tree-sitter
gives us are incompatible with the code point nature of splice.
Aside from some packaging work^2, this was all that I needed to get
comprehensive syntax highlighting suitable for static site
generators. I'm not quite sure (but curious) about server-side
rendered components. In theory, if whatever is producing the HTML has
access to the tree-sitter grammars, then it should work there as
well. The code snippets you've been reading have been produced by
this plugin. Try changing your desktop color scheme!
How to use rehype-tree-sitter in your Astro project
To use rehype-tree-sitter, install it through npm:
npm install --save rehype-tree-sitter
Once you have the plugin installed, add it to your Astro config.
import { defineConfig } from "astro/config";
import rehypeTreeSitter from "rehype-tree-sitter";
// https://astro.build/config
export default defineConfig({
markdown: {
syntaxHighlight: false,
rehypePlugins: [
[
rehypeTreeSitter,
{
treeSitterGrammarRoot: "/Users/haze/tsg",
scopeMap: {
"language-javascript": "source.js",
"language-sh": "source.bash",
"language-xml": "source.xml",
"language-rust": "source.rust",
"language-html": "text.html.basic",
},
},
],
],
},
});
Things I learned when publishing my first npm package
1. Don't delete your package if you push garbage. I did this and had
to wait a day to re-publish. (I got a 403 Unauthorized when
attempting to publish the fixed variant.)
2. I was able to get away with a cjs style require in a ESM project.
I had to use createImport which the docs suck for.
3. Using Rust code from Node is way easier than I thought.
4. This technique is powerful. I can control the CSS for every
token, with extreme granularity, for every language. For some
languages (like Hare or HTML) the syntax is pretty simple, and
the theme can be relatively simple as well. For languages like
Rust, it helps to have more colors distinguishing things like
lifetimes and loop labels.
Special Thanks
I'd like to extend a warm thank you to veritas and Danny. Their help
was instrumental to my success. They assisted me with creating a node
package, publishing it to npm, debugging, and testing. I'd also like
to thank my wonderful girlfriend Katherine for proofreading.
Footnotes
1. I hacked around this in a couple ways. One way was disabling the
usage of rehype-raw internally. You could dig even deeper into
hast-util-to-html and edit the text callback to stop from
inserting newlines, but this is clearly not the solution, and
will break other things. -
2. I had to figure out how to execute some commands to compile the
Rust library. I learned about the beauties and dangers of
postinstall npm scripts. -