//! 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(&regions, 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("&amp;"),
            '<' => escaped.push_str("&lt;"),
            '>' => escaped.push_str("&gt;"),
            _ => 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("&lt;script&gt;"), "{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("&lt;b&gt;words&lt;/b&gt;"), "{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");
    }
}
