clankerdiff-syntax 0.1.0

Portable Arborium syntax highlighting
Documentation
use clankerdiff_syntax::{
    CacheConfig, DocumentHighlights, HighlightSpan, HighlightStats, LanguageHint, SourceSequenceId,
    SyntaxHighlighter, SyntaxStream, SyntaxStreamError,
};
use clankerdiff_theme::SyntaxTheme;
use std::{error::Error, fmt::Write, sync::Arc};

#[test]
fn stream_append_projects_every_line_including_multibyte_text() -> Result<(), Box<dyn Error>> {
    let mut highlighter = SyntaxHighlighter::default();
    let theme = SyntaxTheme::default();
    let mut stream = SyntaxStream::new("rust");
    let update = highlighter
        .with_theme(&theme)
        .append(&mut stream, ["fn main() {", "  println!(\"é\");", "}"])?;
    assert_eq!(update.highlights.line_count(), 3);
    assert_eq!(stream.source(), "fn main() {\n  println!(\"é\");\n}\n");
    assert!(!line(&update.highlights, 1)?.is_empty());
    let spans = highlighter
        .with_theme(&theme)
        .highlight_source(LanguageHint::Id("rust"), "let x = 1;");
    assert!(!spans.is_empty());
    Ok(())
}

#[test]
fn document_highlights_and_take_stats_are_public_contracts() -> Result<(), Box<dyn Error>> {
    let text = "/* open\nclosed */\n";
    let id = SourceSequenceId::from_lines(text.lines());
    let mut highlighter = SyntaxHighlighter::default();
    let theme = SyntaxTheme::default();
    let highlights = highlighter
        .with_theme(&theme)
        .highlight_document(id, "rust", text);
    assert_eq!(highlights.line_count(), 2);
    assert!(!line(&highlights, 1)?.is_empty());
    let first = highlighter.take_stats();
    assert_eq!(first.calls, 1);
    assert_eq!(first.misses, 1);
    assert_eq!(first.bytes, text.len());
    assert_eq!(highlighter.take_stats(), HighlightStats::default());
    Ok(())
}

#[test]
fn complete_document_is_parsed_once_then_lines_are_constant_time_lookups()
-> Result<(), Box<dyn Error>> {
    let mut text = String::new();
    for index in 0..10_000 {
        writeln!(text, "let value_{index} = {index};")?;
    }
    let id = SourceSequenceId::from_lines(text.lines());
    let theme = SyntaxTheme::default();
    let config = CacheConfig {
        max_entries: 64,
        max_documents: 4,
        ..CacheConfig::default()
    };
    let mut highlighter = SyntaxHighlighter::new(config);
    let highlights = highlighter
        .with_theme(&theme)
        .highlight_document(id, "rust", &text);
    assert!(!line(&highlights, 9_000)?.is_empty());
    let first_parse = highlighter.take_stats();
    assert_eq!(first_parse.calls, 1);
    assert_eq!(first_parse.misses, 1);
    assert_eq!(first_parse.bytes, text.len());
    for index in [0, 5_000, 9_999] {
        assert!(highlights.line(index).is_some());
    }
    assert_eq!(highlighter.stats(), HighlightStats::default());
    let cached = highlighter
        .with_theme(&theme)
        .highlight_document(id, "rust", &text);
    assert!(Arc::ptr_eq(&highlights, &cached));
    assert_eq!(highlighter.stats().calls, 1);
    assert_eq!(highlighter.stats().hits, 1);
    assert_eq!(highlighter.stats().bytes, 0);
    Ok(())
}

#[test]
fn line_sequences_detect_a_shebang_on_the_first_line() -> Result<(), Box<dyn Error>> {
    let theme = SyntaxTheme::default();
    let mut highlighter = SyntaxHighlighter::default();
    let lines = ["#!/usr/bin/env python3", "print('hi')"];
    let highlights = highlighter.with_theme(&theme).highlight_document_lines(
        SourceSequenceId::from_lines(lines),
        LanguageHint::Auto,
        lines,
    );
    assert!(!line(&highlights, 1)?.is_empty());
    Ok(())
}

#[test]
fn document_cache_evictions_are_counted() {
    let theme = SyntaxTheme::default();
    let mut highlighter = SyntaxHighlighter::new(CacheConfig {
        max_documents: 1,
        ..CacheConfig::default()
    });
    for text in ["let a = 1;\n", "let b = 2;\n"] {
        let id = SourceSequenceId::from_lines(text.lines());
        highlighter
            .with_theme(&theme)
            .highlight_document(id, "rust", text);
    }
    assert_eq!(highlighter.take_stats().evictions, 1);
}

#[test]
fn stream_appends_do_not_evict_cached_documents() -> Result<(), Box<dyn Error>> {
    let theme = SyntaxTheme::default();
    let mut highlighter = SyntaxHighlighter::new(CacheConfig {
        max_documents: 1,
        ..CacheConfig::default()
    });
    let text = "fn main() {}\n";
    let id = SourceSequenceId::from_lines(text.lines());
    highlighter
        .with_theme(&theme)
        .highlight_document(id, "rust", text);
    let mut stream = SyntaxStream::new("rust");
    for index in 0..4 {
        let appended = format!("let value_{index} = {index};");
        highlighter
            .with_theme(&theme)
            .append(&mut stream, [appended.as_str()])?;
    }
    highlighter.take_stats();
    highlighter
        .with_theme(&theme)
        .highlight_document(id, "rust", text);
    assert_eq!(
        highlighter.take_stats(),
        HighlightStats {
            calls: 1,
            hits: 1,
            ..HighlightStats::default()
        }
    );
    Ok(())
}

#[test]
fn stream_highlights_long_multiline_constructs_with_complete_context() -> Result<(), Box<dyn Error>>
{
    let theme = SyntaxTheme::default();
    let mut highlighter = SyntaxHighlighter::default();
    let mut stream = SyntaxStream::new("rust");
    let mut lines = vec!["/* opening comment"];
    lines.extend(std::iter::repeat_n("still comment", 1_100));
    highlighter
        .with_theme(&theme)
        .append(&mut stream, lines.iter().copied())?;
    let actual = highlighter
        .with_theme(&theme)
        .append(&mut stream, ["let still_comment = 1;"])?;
    lines.push("let still_comment = 1;");
    let expected = highlighter
        .with_theme(&theme)
        .highlight_lines("rust", lines.iter().copied());
    assert_eq!(
        actual.highlights.line(lines.len() - 1),
        expected.last().map(Vec::as_slice)
    );
    Ok(())
}

#[test]
fn cloned_streams_append_independently() -> Result<(), Box<dyn Error>> {
    let theme = SyntaxTheme::default();
    let mut highlighter = SyntaxHighlighter::default();
    let mut stable = SyntaxStream::new("rust");
    highlighter
        .with_theme(&theme)
        .append(&mut stable, ["/* stable"])?;
    let mut speculative = stable.clone();
    highlighter
        .with_theme(&theme)
        .append(&mut speculative, ["closed */"])?;
    let update = highlighter
        .with_theme(&theme)
        .append(&mut stable, ["still comment"])?;
    let expected = highlighter
        .with_theme(&theme)
        .highlight_lines("rust", ["/* stable", "still comment"]);
    assert_eq!(
        update.highlights.line(1),
        expected.last().map(Vec::as_slice)
    );
    assert!(!stable.source().contains("closed"));
    Ok(())
}

#[test]
fn stream_updates_report_restyled_earlier_lines_and_reuse_unchanged_projections()
-> Result<(), Box<dyn Error>> {
    let theme = SyntaxTheme::default();
    let mut highlighter = SyntaxHighlighter::default();
    let mut stream = SyntaxStream::new("rust");
    let first = highlighter
        .with_theme(&theme)
        .append(&mut stream, ["let value = r#\"open"])?;
    let update = highlighter
        .with_theme(&theme)
        .append(&mut stream, ["closed\"#;"])?;
    let expected = highlighter
        .with_theme(&theme)
        .highlight_lines("rust", stream.source().lines());
    for (index, spans) in expected.iter().enumerate() {
        assert_eq!(update.highlights.line(index), Some(spans.as_slice()));
        if first.highlights.line(index) != Some(spans.as_slice()) {
            assert!(update.changed_lines.contains(&index));
        }
    }
    highlighter.clear_cache();
    highlighter.take_stats();
    let unchanged = highlighter.with_theme(&theme).append(&mut stream, [])?;
    assert!(unchanged.changed_lines.is_empty());
    assert!(Arc::ptr_eq(&unchanged.highlights, &update.highlights));
    assert_eq!(unchanged.base_revision, unchanged.revision);
    assert_eq!(highlighter.take_stats(), HighlightStats::default());
    let changed_theme = SyntaxTheme::ayu()?;
    let recolored = highlighter
        .with_theme(&changed_theme)
        .append(&mut stream, [])?;
    let expected = highlighter
        .with_theme(&changed_theme)
        .highlight_lines("rust", stream.source().lines());
    assert_eq!(recolored.revision, unchanged.revision);
    assert!(!recolored.changed_lines.is_empty());
    for (index, spans) in expected.iter().enumerate() {
        assert_eq!(recolored.highlights.line(index), Some(spans.as_slice()));
    }
    Ok(())
}

#[test]
fn oversized_updates_are_atomic_including_a_single_long_line() -> Result<(), Box<dyn Error>> {
    let theme = SyntaxTheme::default();
    let mut highlighter = SyntaxHighlighter::new(CacheConfig {
        max_stream_bytes: 16,
        ..CacheConfig::default()
    });
    let mut stream = SyntaxStream::new("rust");
    let accepted = highlighter
        .with_theme(&theme)
        .append(&mut stream, ["/* open"])?;
    let source = stream.source().to_owned();
    highlighter.take_stats();
    for lines in [
        vec!["a", "this line exceeds the budget"],
        vec!["0123456789abcdef"],
    ] {
        let error = highlighter
            .with_theme(&theme)
            .append(&mut stream, lines)
            .expect_err("oversized input");
        assert!(matches!(
            error,
            SyntaxStreamError::InputLimit { limit: 16, .. }
        ));
        assert_eq!(stream.source(), source);
        assert_eq!(stream.revision(), accepted.revision);
        assert!(Arc::ptr_eq(stream.highlights(), &accepted.highlights));
        assert_eq!(highlighter.take_stats(), HighlightStats::default());
    }
    let next = highlighter
        .with_theme(&theme)
        .append(&mut stream, ["end */"])?;
    assert_eq!(next.revision, accepted.revision + 1);
    Ok(())
}

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