//! Colouring source code, on the server.
//!
//! A file's page shows the language it is written in the way an editor
//! would, and does it here rather than in the browser: the forge ships no
//! JavaScript, and a page that only renders is a page that works however it
//! is reached.
//!
//! [`render`] is, with [`crate::markdown::render`], one of the two ways a
//! page embeds content it did not write itself - and like that one, what it
//! returns is safe to embed: the source is escaped as it is coloured, and
//! the colours arrive as inline styles, so no stylesheet of ours has to
//! know what a keyword looks like.
use std::path::Path;
use std::sync::LazyLock;
use syntect::easy::HighlightLines;
use syntect::highlighting::ThemeSet;
use syntect::html::{IncludeBackground, styled_line_to_highlighted_html};
use syntect::parsing::SyntaxSet;
use syntect::util::LinesWithEndings;
/// Past this many bytes a file is shown as it is rather than coloured:
/// colouring walks every line, and a page has a reader waiting for it.
pub const COLOUR_LIMIT: usize = 256 * 1024;
/// The syntaxes and themes git's own cousins ship: the full default set,
/// loaded once and kept for the life of the process.
static SYNTAXES: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
static THEMES: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
/// The theme the colours come from: one drawn for a light page, since the
/// colour is all that is taken from it - the page keeps its own background.
const THEME: &str = "InspiredGitHub";
/// The scope of a syntax that says nothing about a file - the one `txt` and
/// every unknown extension fall through to. There is nothing in that to
/// colour.
const PLAIN: &str = "text.plain";
/// `content` as HTML, with the language it is written in coloured.
///
/// The file name says the language, by its extension. A file whose language
/// is not known, one that is plain text, or one past [`COLOUR_LIMIT`] comes
/// back as the same text escaped - there is nothing to say about it that
/// monospace would not.
///
/// The span over the call is what a trace shows of the colouring itself:
/// which file, how many bytes, and how long the parsing took - the one
/// part of a file's page that is CPU rather than a `git` subprocess.
#[tracing::instrument(level = "debug", skip(content), fields(bytes = content.len()))]
pub fn render(content: &str, name: &str) -> String {
let extension = Path::new(name)
.extension()
.and_then(|extension| extension.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
let plain = SYNTAXES
.find_syntax_by_extension(&extension)
.filter(|syntax| syntax.scope.to_string() != PLAIN);
let Some(syntax) = plain.filter(|_| content.len() <= COLOUR_LIMIT) else {
return escape(content);
};
let Some(theme) = THEMES.themes.get(THEME) else {
return escape(content);
};
let mut highlighter = HighlightLines::new(syntax, theme);
let mut coloured = String::with_capacity(content.len() * 2);
for line in LinesWithEndings::from(content) {
// A line the highlighter cannot parse is shown uncoloured rather
// than not shown at all.
let coloured_line = highlighter
.highlight_line(line, &SYNTAXES)
.ok()
.and_then(|regions| {
styled_line_to_highlighted_html(®ions, IncludeBackground::No).ok()
});
match coloured_line {
Some(coloured_line) => coloured.push_str(&coloured_line),
None => coloured.push_str(&escape(line)),
}
}
coloured
}
/// The characters a browser would read as markup, spelled so that it cannot.
fn escape(text: &str) -> String {
let mut escaped = String::with_capacity(text.len());
for character in text.chars() {
match character {
'&' => escaped.push_str("&"),
'<' => escaped.push_str("<"),
'>' => escaped.push_str(">"),
_ => escaped.push(character),
}
}
escaped
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_known_language_is_coloured() {
let coloured = render("pub fn lib() -> u32 {\n 42\n}\n", "lib.rs");
assert!(coloured.contains("<span style=\""), "{coloured}");
// The words are still there, spread among the spans.
assert!(coloured.contains("lib"), "{coloured}");
}
#[test]
fn source_cannot_become_markup() {
// A repository's files are not the server's to trust, any more than
// its readmes are.
let coloured = render("let x = \"<script>alert(1)</script>\";\n", "evil.rs");
assert!(!coloured.contains("<script"), "{coloured}");
assert!(coloured.contains("<script>"), "{coloured}");
}
#[test]
fn an_unknown_language_is_plain_escaped_text() {
let plain = render("<b>words</b>\n", "words.zzz");
assert!(!plain.contains("<span"), "{plain}");
assert!(plain.contains("<b>words</b>"), "{plain}");
}
#[test]
fn plain_text_has_nothing_to_colour() {
let plain = render("just some notes\n", "notes.txt");
assert!(!plain.contains("<span"), "{plain}");
assert!(plain.contains("just some notes"), "{plain}");
}
#[test]
fn a_file_past_the_limit_is_shown_rather_than_coloured() {
let huge = format!("{}\nfn main() {{}}\n", "x".repeat(COLOUR_LIMIT));
let shown = render(&huge, "huge.rs");
assert!(!shown.contains("<span"), "nothing should be coloured");
assert!(shown.contains("fn main() {}"), "but nothing is lost either");
}
}