8.4 KiB · text History 487bb12
//! Syntax highlighting via syntect, flattened to colored spans per line.
//!
//! The code area is drawn as SVG text, so this hands back concrete colors
//! rather than CSS classes; one renderer then serves the preview, the embed,
//! and both export formats.
use std::sync::LazyLock;

use syntect::easy::HighlightLines;
use syntect::highlighting::{Color, FontStyle, Theme, ThemeSet};
use syntect::parsing::{SyntaxReference, SyntaxSet};
use syntect::util::LinesWithEndings;

use super::palette::{Palette, Rgb};

static SYNTAXES: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
static THEMES: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);

pub const TAB_WIDTH: usize = 4;

/// A selectable highlight theme. `solarized` is the default and resolves to
/// its light or dark variant from the chrome palette, so a deck switching
/// schemes flips the code area along with the window.
#[derive(Clone, Copy, Debug, serde::Serialize)]
pub struct HlTheme {
    pub id: &'static str,
    pub name: &'static str,
    #[serde(skip)]
    syntect: Option<&'static str>,
}

pub static HL_THEMES: &[HlTheme] = &[
    HlTheme { id: "solarized", name: "Solarized (follows palette)", syntect: None },
    HlTheme { id: "solarized-dark", name: "Solarized dark", syntect: Some("Solarized (dark)") },
    HlTheme { id: "solarized-light", name: "Solarized light", syntect: Some("Solarized (light)") },
    HlTheme { id: "base16-ocean-dark", name: "Base16 Ocean dark", syntect: Some("base16-ocean.dark") },
    HlTheme { id: "base16-ocean-light", name: "Base16 Ocean light", syntect: Some("base16-ocean.light") },
    HlTheme { id: "base16-eighties", name: "Base16 Eighties", syntect: Some("base16-eighties.dark") },
    HlTheme { id: "base16-mocha", name: "Base16 Mocha", syntect: Some("base16-mocha.dark") },
    HlTheme { id: "inspired-github", name: "InspiredGitHub", syntect: Some("InspiredGitHub") },
];

pub const DEFAULT_HL: &str = "solarized";

pub fn hl_theme(id: &str) -> Option<&'static HlTheme> {
    HL_THEMES.iter().find(|t| t.id == id)
}

fn resolve_theme(id: &str, palette_dark: bool) -> &'static Theme {
    let name = hl_theme(id)
        .and_then(|t| t.syntect)
        .unwrap_or(if palette_dark { "Solarized (dark)" } else { "Solarized (light)" });
    &THEMES.themes[name]
}

/// A language choice: syntect's syntax name is the stored id.
#[derive(Clone, Debug, serde::Serialize)]
pub struct Language {
    pub id: String,
    pub name: String,
}

/// Languages offered in the picker, alphabetised, hidden syntaxes dropped.
pub fn languages() -> &'static [Language] {
    static LANGS: LazyLock<Vec<Language>> = LazyLock::new(|| {
        let mut v: Vec<Language> = SYNTAXES
            .syntaxes()
            .iter()
            .filter(|s| !s.hidden && !s.file_extensions.is_empty())
            .map(|s| Language { id: s.name.clone(), name: s.name.clone() })
            .collect();
        v.sort_by_key(|l| l.name.to_lowercase());
        v.dedup_by(|a, b| a.id == b.id);
        v
    });
    &LANGS
}

pub const AUTO_LANG: &str = "auto";

fn find_syntax(lang: &str, code: &str) -> &'static SyntaxReference {
    let ss = &*SYNTAXES;
    if lang != AUTO_LANG
        && let Some(s) = ss.find_syntax_by_name(lang).or_else(|| ss.find_syntax_by_token(lang)) {
            return s;
        }
    code.lines()
        .next()
        .and_then(|first| ss.find_syntax_by_first_line(first))
        .unwrap_or_else(|| ss.find_syntax_plain_text())
}

#[derive(Clone, Debug, PartialEq)]
pub struct Span {
    pub text: String,
    pub fg: Rgb,
    pub bold: bool,
    pub italic: bool,
}

#[derive(Clone, Debug)]
pub struct Highlighted {
    pub background: Rgb,
    pub foreground: Rgb,
    pub gutter: Rgb,
    pub lines: Vec<Vec<Span>>,
}

fn rgb(c: Color) -> Rgb {
    Rgb(c.r, c.g, c.b)
}

/// Replace tabs with spaces up to the next tab stop, so columns line up in a
/// monospace grid regardless of the renderer's own tab handling.
fn expand_tabs(line: &str, col: &mut usize) -> String {
    let mut out = String::with_capacity(line.len());
    for ch in line.chars() {
        if ch == '\t' {
            let n = TAB_WIDTH - (*col % TAB_WIDTH);
            out.extend(std::iter::repeat_n(' ', n));
            *col += n;
        } else {
            out.push(ch);
            *col += unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
        }
    }
    out
}

/// Solarized's greys (and any other near-neutral token color) are the ones
/// that carry its background's tone; the accents read on any surface.
fn is_neutral(c: Rgb) -> bool {
    let (hi, lo) = (c.0.max(c.1).max(c.2), c.0.min(c.1).min(c.2));
    hi - lo < 48
}

pub fn highlight(code: &str, lang: &str, hl: &str, palette: &Palette) -> Highlighted {
    let theme = resolve_theme(hl, palette.dark);
    // A palette with its own surface repaints the follow-the-palette theme:
    // its fill, and the neutral text tones re-derived from its content color.
    let surface = palette.surface.filter(|_| hl_theme(hl).is_some_and(|t| t.syntect.is_none()));
    let syntax = find_syntax(lang, code);
    let mut h = HighlightLines::new(syntax, theme);

    let background = theme.settings.background.map(rgb).unwrap_or(Rgb(0x1e, 0x1e, 0x1e));
    let foreground = theme.settings.foreground.map(rgb).unwrap_or(Rgb(0xdd, 0xdd, 0xdd));
    let gutter = theme
        .settings
        .gutter_foreground
        .map(rgb)
        .unwrap_or_else(|| foreground.mix(background, 0.55));
    let theme_fg = foreground;
    let (background, foreground, gutter) = match surface {
        Some((bg, fg)) => (bg, fg.mix(bg, 0.1), fg.mix(bg, 0.55)),
        None => (background, foreground, gutter),
    };
    let recolor = |c: Rgb| match surface {
        Some(_) if c == theme_fg => foreground,
        Some((bg, fg)) if is_neutral(c) => fg.mix(bg, 0.45),
        _ => c,
    };

    let normalized = code.replace("\r\n", "\n");
    let mut lines = Vec::new();
    for line in LinesWithEndings::from(&normalized) {
        let mut spans: Vec<Span> = Vec::new();
        let mut col = 0usize;
        let ranges = h.highlight_line(line, &SYNTAXES).unwrap_or_default();
        for (style, piece) in ranges {
            let piece = piece.trim_end_matches(['\n', '\r']);
            if piece.is_empty() {
                continue;
            }
            let text = expand_tabs(piece, &mut col);
            let span = Span {
                text,
                fg: recolor(rgb(style.foreground)),
                bold: style.font_style.contains(FontStyle::BOLD),
                italic: style.font_style.contains(FontStyle::ITALIC),
            };
            // Coalesce neighbours of the same style to keep the SVG small.
            match spans.last_mut() {
                Some(prev) if prev.fg == span.fg && prev.bold == span.bold && prev.italic == span.italic => {
                    prev.text.push_str(&span.text)
                }
                _ => spans.push(span),
            }
        }
        lines.push(spans);
    }
    if lines.is_empty() {
        lines.push(Vec::new());
    }

    Highlighted { background, foreground, gutter, lines }
}

#[cfg(test)]
mod tests {
    use super::*;

    use crate::render::palette;

    #[test]
    fn solarized_follows_palette() {
        let pal = |id| palette::get(id).unwrap();
        let dark = highlight("fn main() {}", "Rust", "solarized", pal("breeze-dark"));
        let light = highlight("fn main() {}", "Rust", "solarized", pal("oxygen"));
        assert_eq!(dark.background, Rgb::hex(0x002b36));
        assert_eq!(light.background, Rgb::hex(0xfdf6e3));
    }

    #[test]
    fn neiam_palettes_fill_with_their_own_surface() {
        let her = palette::get("her").unwrap();
        let h = highlight("// hi\nfn main() {}", "Rust", "solarized", her);
        assert_eq!(h.background, Rgb::hex(0x661e1e));
        // An explicit theme keeps its own background.
        let h = highlight("fn main() {}", "Rust", "solarized-dark", her);
        assert_eq!(h.background, Rgb::hex(0x002b36));
    }

    #[test]
    fn tabs_expand_to_stops() {
        let h = highlight("a\tb\n\tc", "Plain Text", "solarized-dark", palette::get("breeze").unwrap());
        let line0: String = h.lines[0].iter().map(|s| s.text.as_str()).collect();
        let line1: String = h.lines[1].iter().map(|s| s.text.as_str()).collect();
        assert_eq!(line0, "a   b");
        assert_eq!(line1, "    c");
    }

    #[test]
    fn every_hl_theme_resolves() {
        for t in HL_THEMES {
            resolve_theme(t.id, true);
            resolve_theme(t.id, false);
        }
    }
}