clankerdiff-syntax 0.1.2

Portable Arborium syntax highlighting
Documentation
use arborium::{Config, Highlighter};
use arborium_highlight::spans_to_flat_tokens;
use arborium_theme::tag_to_name;
use clankerdiff_syntax::{
    CacheConfig, DocumentHighlights, Fingerprint, HighlightSpan, SyntaxError, SyntaxHighlighter,
    SyntaxStream, SyntaxStreamUpdate, SyntaxTheme, resolve_language,
};
use std::{error::Error, sync::Arc};

pub type TestResult = Result<(), Box<dyn Error>>;

pub struct CacheBuilder {
    config: CacheConfig,
    language: String,
    theme: SyntaxTheme,
}

impl Default for CacheBuilder {
    fn default() -> Self {
        Self {
            config: CacheConfig::default(),
            language: "rust".to_owned(),
            theme: SyntaxTheme::default(),
        }
    }
}

impl CacheBuilder {
    pub fn entries(mut self, limit: usize) -> Self {
        self.config.max_documents = limit;
        self
    }

    pub fn source_limit(mut self, limit: usize) -> Self {
        self.config.max_source_bytes = limit;
        self
    }

    pub fn language(mut self, language: &str) -> Self {
        language.clone_into(&mut self.language);
        self
    }

    pub fn build(self) -> SyntaxFixture {
        SyntaxFixture {
            highlighter: SyntaxHighlighter::new(self.config),
            stream: SyntaxStream::new(self.language.as_str()),
            theme: self.theme,
            language: self.language,
            reference: Highlighter::with_config(Config {
                max_injection_depth: 3,
                ..Config::default()
            }),
        }
    }
}

pub struct SyntaxFixture {
    pub highlighter: SyntaxHighlighter,
    pub stream: SyntaxStream,
    pub theme: SyntaxTheme,
    language: String,
    reference: Highlighter,
}

impl SyntaxFixture {
    pub fn highlight(&mut self, source: &str) -> Result<Arc<DocumentHighlights>, SyntaxError> {
        self.highlighter.with_theme(&self.theme).highlight_document(
            Fingerprint::of([source]),
            self.language.as_str(),
            || source,
        )
    }

    pub fn append(&mut self, source: &str) -> Result<SyntaxStreamUpdate, SyntaxError> {
        self.highlighter
            .with_theme(&self.theme)
            .append(&mut self.stream, source)
    }

    pub fn assert_equivalent(&mut self) -> TestResult {
        let source = self.stream.source();
        let complete = SyntaxHighlighter::new(0)
            .with_theme(&self.theme)
            .highlight_document(Fingerprint::of([source]), self.language.as_str(), || source)?;
        assert_eq!(self.stream.highlights().line_count(), complete.line_count());
        for index in 0..complete.line_count() {
            assert_eq!(
                self.stream.highlights().line(index),
                complete.line(index),
                "{}: {source:?}, line {index}",
                self.language
            );
        }
        let expected = reference_lines(&mut self.reference, &self.theme, &self.language, source)?;
        assert_eq!(self.stream.highlights().line_count(), expected.len());
        for (index, line) in expected.iter().enumerate() {
            assert_eq!(
                self.stream.highlights().line(index),
                Some(line.as_slice()),
                "{}: {source:?}, line {index}",
                self.language
            );
        }
        Ok(())
    }
}

pub fn line(
    highlights: &DocumentHighlights,
    index: usize,
) -> Result<&[HighlightSpan], Box<dyn Error>> {
    highlights
        .line(index)
        .ok_or_else(|| format!("missing line {index}").into())
}

fn reference_lines(
    highlighter: &mut Highlighter,
    theme: &SyntaxTheme,
    hint: &str,
    source: &str,
) -> Result<Vec<Vec<HighlightSpan>>, Box<dyn Error>> {
    let raw = resolve_language(hint, source)
        .map(|language| highlighter.highlight_spans(language, source))
        .transpose()?
        .unwrap_or_default();
    let spans: Vec<_> = spans_to_flat_tokens(source, raw)
        .into_iter()
        .filter_map(|token| {
            let name = match tag_to_name(token.tag)? {
                "title" => "markup.heading",
                "strong" => "markup.bold",
                "emphasis" => "markup.italic",
                "link" => "markup.link",
                "literal" => "markup.raw",
                "strikethrough" => "markup.strikethrough",
                name => name,
            };
            let style = theme.style(name)?;
            Some(HighlightSpan {
                range: token.start as usize..token.end as usize,
                foreground: style.foreground,
                font_style: style.font_style,
            })
        })
        .collect();
    let mut offset = 0;
    Ok(source
        .split_terminator('\n')
        .map(|text| {
            let end = offset + text.strip_suffix('\r').unwrap_or(text).len();
            let mut line: Vec<HighlightSpan> = Vec::new();
            for span in &spans {
                let start = span.range.start.max(offset);
                let to = span.range.end.min(end);
                if start >= to {
                    continue;
                }
                let span = HighlightSpan {
                    range: start - offset..to - offset,
                    foreground: span.foreground,
                    font_style: span.font_style,
                };
                if let Some(last) = line.last_mut()
                    && last.range.end == span.range.start
                    && last.foreground == span.foreground
                    && last.font_style == span.font_style
                {
                    last.range.end = span.range.end;
                } else {
                    line.push(span);
                }
            }
            offset += text.len() + 1;
            line
        })
        .collect())
}