use std::sync::OnceLock;
use syntect::html::{ClassStyle, ClassedHTMLGenerator};
use syntect::parsing::{SyntaxDefinition, SyntaxSet};
use syntect::util::LinesWithEndings;
pub const CLASS_PREFIX: &str = "plates-";
pub const HIGHLIGHTED_CLASS: &str = "plates-highlighted";
const CLASS_STYLE: ClassStyle = ClassStyle::SpacedPrefixed {
prefix: CLASS_PREFIX,
};
pub struct Syntaxes {
set: SyntaxSet,
warnings: Vec<String>,
}
impl Syntaxes {
pub fn bundled() -> &'static Self {
static BUNDLED: OnceLock<Syntaxes> = OnceLock::new();
BUNDLED.get_or_init(|| Syntaxes {
set: two_face::syntax::extra_newlines(),
warnings: Vec::new(),
})
}
pub fn with_custom<'a, I>(definitions: I) -> Self
where
I: IntoIterator<Item = (&'a str, &'a str)>,
{
let mut builder = two_face::syntax::extra_newlines().into_builder();
let mut warnings = Vec::new();
for (label, text) in definitions {
match SyntaxDefinition::load_from_str(text, true, Some(label)) {
Ok(parsed) => {
builder.add(parsed);
}
Err(e) => warnings.push(format!(
"syntax definition {label:?} could not be parsed ({e}) — code in that \
language is published unhighlighted"
)),
}
}
Syntaxes {
set: builder.build(),
warnings,
}
}
pub fn warnings(&self) -> &[String] {
&self.warnings
}
pub fn knows(&self, token: &str) -> bool {
self.set.find_syntax_by_token(token).is_some()
}
}
const OPEN: &str = "<pre><code class=\"language-";
const CLOSE: &str = "</code></pre>";
pub fn highlight_code_blocks(html: &str, syntaxes: &Syntaxes) -> String {
let mut out = String::with_capacity(html.len());
let mut rest = html;
while let Some(at) = rest.find(OPEN) {
out.push_str(&rest[..at]);
rest = &rest[at..];
let Some(block) = split_block(rest) else {
break;
};
match highlight_one(block.class_value, block.inner, syntaxes) {
Some(spans) => out.push_str(&format!(
"<pre><code class=\"language-{} {HIGHLIGHTED_CLASS}\"{}>{spans}</code></pre>",
block.class_value, block.extras
)),
None => out.push_str(&rest[..block.len]),
}
rest = &rest[block.len..];
}
out.push_str(rest);
out
}
struct Block<'a> {
class_value: &'a str,
extras: &'a str,
inner: &'a str,
len: usize,
}
fn split_block(s: &str) -> Option<Block<'_>> {
let after_open = s.strip_prefix(OPEN)?;
let quote = after_open.find('"')?;
let class_value = &after_open[..quote];
let after_attr = &after_open[quote + 1..];
let gt = after_attr.find('>')?;
let extras = &after_attr[..gt];
let inner_and_rest = &after_attr[gt + 1..];
let end = inner_and_rest.find(CLOSE)?;
Some(Block {
class_value,
extras,
inner: &inner_and_rest[..end],
len: OPEN.len() + quote + 1 + gt + 1 + end + CLOSE.len(),
})
}
fn highlight_one(class_value: &str, inner: &str, syntaxes: &Syntaxes) -> Option<String> {
if inner.contains('<') {
return None;
}
let token = class_value.split_whitespace().next()?;
let syntax = syntaxes.set.find_syntax_by_token(token)?;
let source = unescape(inner);
let mut hl = ClassedHTMLGenerator::new_with_class_style(syntax, &syntaxes.set, CLASS_STYLE);
for line in LinesWithEndings::from(&source) {
hl.parse_html_for_line_which_includes_newline(line).ok()?;
}
Some(hl.finalize())
}
fn unescape(s: &str) -> String {
if !s.contains('&') {
return s.to_string();
}
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(at) = rest.find('&') {
out.push_str(&rest[..at]);
let tail = &rest[at..];
let decoded = [
("<", '<'),
(">", '>'),
(""", '"'),
("'", '\''),
("'", '\''),
("&", '&'),
]
.into_iter()
.find(|(entity, _)| tail.starts_with(entity));
match decoded {
Some((entity, ch)) => {
out.push(ch);
rest = &tail[entity.len()..];
}
None => {
out.push('&');
rest = &tail[1..];
}
}
}
out.push_str(rest);
out
}
#[cfg(test)]
mod tests {
use super::*;
const WAT: &str = "\
name: Wat
file_extensions: [wat]
scope: source.wat
contexts:
main:
- match: ';;.*$'
scope: comment.line.wat
- match: '\\b(module|func)\\b'
scope: keyword.control.wat
";
fn bundled(html: &str) -> String {
highlight_code_blocks(html, Syntaxes::bundled())
}
#[test]
fn colours_a_language_it_knows() {
let out = bundled("<pre><code class=\"language-rust\">let x = 1;\n</code></pre>");
assert!(out.contains(HIGHLIGHTED_CLASS), "marked as highlighted");
assert!(out.contains("plates-storage"), "`let` is storage: {out}");
assert!(
out.contains("class=\"language-rust "),
"kept the language class twig wrote: {out}"
);
}
#[test]
fn knows_the_languages_this_organisation_writes() {
for token in ["zig", "swift", "toml", "typescript", "rust"] {
assert!(Syntaxes::bundled().knows(token), "no grammar for {token}");
}
}
#[test]
fn leaves_a_language_it_does_not_know_byte_for_byte() {
let input = "<pre><code class=\"language-not-a-language\">plain\n</code></pre>";
assert_eq!(bundled(input), input);
}
#[test]
fn leaves_an_untagged_fence_alone() {
let input = "<pre><code>plain\n</code></pre>";
assert_eq!(bundled(input), input);
}
#[test]
fn declines_a_block_that_is_already_markup() {
let input = "<pre><code class=\"language-rust\"><span>let</span> x\n</code></pre>";
assert_eq!(bundled(input), input);
}
#[test]
fn hands_back_attributes_it_did_not_write() {
let out =
bundled("<pre><code class=\"language-rust\" data-line=\"3\">let x = 1;\n</code></pre>");
assert!(out.contains("data-line=\"3\""), "{out}");
}
#[test]
fn carries_the_rest_of_the_page_through() {
let out = bundled(
"<p>before</p>\n<pre><code class=\"language-rust\">let x = 1;\n</code></pre>\n\
<p>between</p>\n<pre><code class=\"language-toml\">k = 1\n</code></pre>\n<p>after</p>",
);
for marker in ["<p>before</p>", "<p>between</p>", "<p>after</p>"] {
assert!(out.contains(marker), "lost {marker}: {out}");
}
assert_eq!(out.matches(HIGHLIGHTED_CLASS).count(), 2, "both blocks");
}
#[test]
fn a_truncated_block_is_published_not_eaten() {
let input = "<p>before</p><pre><code class=\"language-rust\">unterminated";
assert_eq!(bundled(input), input);
}
#[test]
fn unescapes_what_twig_escaped() {
assert_eq!(
unescape("a <b> "c" & d"),
"a <b> \"c\" & d"
);
assert_eq!(unescape("no entities"), "no entities");
assert_eq!(unescape("bare & ampersand"), "bare & ampersand");
}
#[test]
fn does_not_decode_an_entity_it_just_decoded() {
assert_eq!(unescape("&lt;"), "<");
}
#[test]
fn a_custom_grammar_colours_a_language_the_bundle_lacks() {
assert!(
!Syntaxes::bundled().knows("wat"),
"premise: nothing bundled claims `wat`"
);
let syntaxes = Syntaxes::with_custom([("wat.sublime-syntax", WAT)]);
assert!(syntaxes.warnings().is_empty(), "{:?}", syntaxes.warnings());
let out = highlight_code_blocks(
"<pre><code class=\"language-wat\">(module) ;; note\n</code></pre>",
&syntaxes,
);
assert!(out.contains("plates-keyword"), "`module`: {out}");
assert!(out.contains("plates-comment"), "`;;`: {out}");
}
#[test]
fn a_broken_grammar_is_reported_and_skipped() {
let syntaxes = Syntaxes::with_custom([("broken.sublime-syntax", "this: is: not: one")]);
assert_eq!(syntaxes.warnings().len(), 1);
assert!(
syntaxes.warnings()[0].contains("broken.sublime-syntax"),
"names the file: {:?}",
syntaxes.warnings()
);
let out = highlight_code_blocks(
"<pre><code class=\"language-rust\">let x = 1;\n</code></pre>",
&syntaxes,
);
assert!(out.contains(HIGHLIGHTED_CLASS), "rust still works: {out}");
}
}