clankerdiff-syntax 0.1.4

Portable Tree-sitter syntax highlighting
Documentation
mod support;

use clankerdiff_syntax::{HighlightStats, SyntaxError};
use clankerdiff_theme::ReviewTheme;
use std::{fmt::Write, str, sync::Arc};
use support::{CacheBuilder, TestResult, line};

#[test]
fn growing_code_and_plain_text_have_linear_work() -> TestResult {
    for language in ["rust", "binary.zzz", ""] {
        let mut fixture = CacheBuilder::default()
            .language(language)
            .entries(0)
            .build();
        let chunk = "let value = 123;\n".repeat(16);
        for _ in 0..96 {
            fixture.append(&chunk)?;
        }
        let budget = 4 * fixture.stream.source().len() + 64 * 1024;
        let work = fixture.stream.work_stats();
        assert!(work.parser_input_bytes <= budget, "{language}: {work:?}");
        assert!(work.queried_bytes <= budget, "{language}: {work:?}");
        assert!(work.projected_bytes <= budget, "{language}: {work:?}");
        assert_eq!(fixture.highlighter.stats().bytes, work.parser_input_bytes);
        assert_eq!(work.full_parses, usize::from(language == "rust"));
        assert_eq!(
            work.incremental_parses,
            if language == "rust" { 95 } else { 0 }
        );
        fixture.assert_equivalent()?;
    }
    Ok(())
}

#[test]
fn aether_code_fixture_meets_the_parser_and_query_budget() -> TestResult {
    let mut source = String::new();
    let mut line = 0;
    while source.len() < 24 * 1024 {
        writeln!(
            source,
            "let value_{line} = state.reconcile(incoming[{line}]).expect(\"delta accepted\");"
        )?;
        line += 1;
    }
    let mut fixture = CacheBuilder::default().build();
    for chunk in source.as_bytes().chunks(256) {
        fixture.append(str::from_utf8(chunk)?)?;
    }
    let work = fixture.stream.work_stats();
    let budget = 4 * source.len() + 64 * 1024;
    assert!(
        work.parser_input_bytes <= budget,
        "{work:?}, budget {budget}"
    );
    assert!(work.queried_bytes <= budget, "{work:?}, budget {budget}");
    assert!(work.projected_bytes <= budget, "{work:?}, budget {budget}");
    fixture.assert_equivalent()
}

#[test]
fn aether_open_function_fixture_meets_the_parser_and_query_budget() -> TestResult {
    let mut fixture = CacheBuilder::default().build();
    fixture.append("fn main() {\n")?;
    let mut source = String::new();
    let mut line = 0;
    while source.len() < 24 * 1024 {
        writeln!(
            source,
            "let value_{line} = state.reconcile(incoming[{line}]).expect(\"delta accepted\");"
        )?;
        line += 1;
    }
    for chunk in source.as_bytes().chunks(256) {
        fixture.append(str::from_utf8(chunk)?)?;
    }
    let work = fixture.stream.work_stats();
    let budget = 4 * fixture.stream.source().len() + 64 * 1024;
    assert!(
        work.parser_input_bytes <= budget,
        "{work:?}, budget {budget}"
    );
    assert!(work.queried_bytes <= budget, "{work:?}, budget {budget}");
    assert!(work.projected_bytes <= budget, "{work:?}, budget {budget}");
    assert!(work.compared_nodes <= budget, "{work:?}, budget {budget}");
    fixture.assert_equivalent()?;
    fixture.append("}\n")?;
    fixture.assert_equivalent()
}

#[test]
fn unchanged_line_projections_survive_an_append() -> TestResult {
    for language in ["rust", "binary.zzz"] {
        let mut fixture = CacheBuilder::default().language(language).build();
        let first = fixture.append("fn first() {}\n")?;
        let second = fixture.append("fn second() {}\n")?;
        let before = first
            .highlights
            .line_shared(0)
            .ok_or("missing first line")?;
        let after = second
            .highlights
            .line_shared(0)
            .ok_or("missing first line")?;
        assert!(Arc::ptr_eq(&before, &after));
        assert_eq!(second.changed_lines, 1..2);
    }
    Ok(())
}

#[test]
fn raw_chunks_and_injections_match_complete_documents() -> TestResult {
    for (language, source) in [
        (
            "rust",
            "fn main() {\nlet café = r###\"open\nline\"###;\n}\n",
        ),
        (
            "python",
            "def run():\n    text = \"\"\"first\nsecond\"\"\"\n    return text\n",
        ),
        (
            "html",
            "<p>café</p><script>const x = 1;</script><style>b{color:red}</style>",
        ),
        ("rust", "/* open\n\nclose */\n"),
        ("markdown", "# Hello\n\n```rust\nfn main() {}\n```\n"),
        (
            "javascript",
            "function run() {\n  return `hello ${name}`;\n}\n",
        ),
        ("typescript", "const x: number = 1;\nconst y = false;\n"),
        ("binary.zzz", "café\r\n\nplain text\r\n"),
    ] {
        for chunk_chars in [1, 7, 256] {
            let mut fixture = CacheBuilder::default().language(language).build();
            let mut characters = source.chars();
            loop {
                let chunk: String = characters.by_ref().take(chunk_chars).collect();
                if chunk.is_empty() {
                    break;
                }
                fixture.append(&chunk)?;
                fixture.assert_equivalent()?;
            }
            assert_eq!(fixture.stream.source(), source);
        }
    }
    Ok(())
}

#[test]
fn eof_predicates_and_container_edits_match_reference() -> TestResult {
    for (language, source) in [
        (
            "rust",
            "fn main() { let VALUE = Some(123); println!(\"{VALUE}\"); }",
        ),
        (
            "python",
            "class Example:\n    def method(self):\n        return self.VALUE\n",
        ),
        ("javascript", "const value = /foo/g;\nconsole.log(value);\n"),
        ("bash", "echo \"${VALUE:-$(printf '%s' hello)}\"\n"),
        ("html", "<script>const text = `hello ${name}`;</script>\n"),
        ("markdown", "[label][ref]\n\n[ref]: https://example.com\n"),
        ("yaml", "key: |\n  value\nnext: true\n"),
        ("json", "{\"key\": true, \"nested\": [1, 2]}"),
    ] {
        let mut fixture = CacheBuilder::default().language(language).build();
        for character in source.chars() {
            fixture.append(character.encode_utf8(&mut [0; 4]))?;
            fixture.assert_equivalent()?;
        }
    }
    Ok(())
}

#[test]
fn nested_injections_obey_the_reference_depth_limit() -> TestResult {
    let mut source = String::from("const café = 123;\n");
    for depth in 0..6 {
        let fence = "`".repeat(depth + 3);
        let language = if depth == 0 { "javascript" } else { "markdown" };
        source = format!("{fence}{language}\n{source}{fence}\n");
        let mut fixture = CacheBuilder::default().language("markdown").build();
        let mut characters = source.chars();
        loop {
            let chunk: String = characters.by_ref().take(13).collect();
            if chunk.is_empty() {
                break;
            }
            fixture.append(&chunk)?;
            fixture.assert_equivalent()?;
        }
    }
    Ok(())
}

#[test]
fn injection_boundaries_can_close_and_be_reinterpreted() -> TestResult {
    for (language, chunks) in [
        (
            "markdown",
            vec![
                "```html\n<script>const café = 1;</script>\n",
                "```",
                "x\n",
                "```\n",
            ],
        ),
        (
            "html",
            vec![
                "<script>const café = `hello ${",
                "value}",
                "`;</script",
                ">tail<style>b{color:red}",
                "</style>",
            ],
        ),
    ] {
        let mut fixture = CacheBuilder::default().language(language).build();
        for chunk in chunks {
            fixture.append(chunk)?;
            fixture.assert_equivalent()?;
        }
        let before = fixture.stream.work_stats();
        fixture.append("")?;
        assert_eq!(fixture.stream.work_stats(), before);
    }
    Ok(())
}

#[test]
fn empty_updates_and_recoloring_do_not_parse() -> TestResult {
    let mut fixture = CacheBuilder::default().language("html").build();
    let first = fixture.append("<script>const café = 1;</script>")?;
    let work = fixture.stream.work_stats();
    fixture.highlighter.take_stats();
    let unchanged = fixture.append("")?;
    assert!(unchanged.changed_lines.is_empty());
    assert_eq!(unchanged.base_revision, unchanged.revision);
    assert!(Arc::ptr_eq(&first.highlights, &unchanged.highlights));
    assert_eq!(fixture.stream.work_stats(), work);
    assert_eq!(
        fixture.highlighter.take_stats(),
        HighlightStats {
            calls: 1,
            ..HighlightStats::default()
        }
    );
    fixture.theme = ReviewTheme::ayu()?.syntax;
    let recolored = fixture.append("")?;
    assert_eq!(recolored.revision, first.revision);
    assert_ne!(recolored.highlights.line(0), first.highlights.line(0));
    assert_eq!(fixture.highlighter.stats().bytes, 0);
    assert_eq!(
        fixture.stream.work_stats().parser_input_bytes,
        work.parser_input_bytes
    );
    assert_eq!(
        fixture.stream.work_stats().queried_bytes,
        work.queried_bytes
    );
    fixture.assert_equivalent()
}

#[test]
fn raw_input_limit_preserves_partial_line_and_work_state() -> TestResult {
    let mut fixture = CacheBuilder::default().source_limit(12).build();
    let accepted = fixture.append("let café")?;
    let work = fixture.stream.work_stats();
    let stats = fixture.highlighter.stats();
    for source in [" = 123;", "x\nthis line exceeds the budget"] {
        assert!(matches!(
            fixture.append(source),
            Err(SyntaxError::InputLimit { limit: 12, .. })
        ));
        assert_eq!(fixture.stream.source(), "let café");
        assert_eq!(fixture.stream.revision(), accepted.revision);
        assert!(Arc::ptr_eq(
            fixture.stream.highlights(),
            &accepted.highlights
        ));
        assert_eq!(fixture.stream.work_stats(), work);
        assert_eq!(fixture.highlighter.stats(), stats);
    }
    fixture.append("=1;")?;
    assert_eq!(fixture.stream.source(), "let café=1;");
    fixture.assert_equivalent()
}

#[test]
fn long_multiline_context_matches_complete_highlighting() -> TestResult {
    for (opening, closing) in [("let value = r###\"", "\"###;"), ("/*", "*/")] {
        let mut fixture = CacheBuilder::default().build();
        fixture.append(&format!("{opening}\n{}", "still inside\n".repeat(4096)))?;
        for chunk in ["let café = false;", closing, "fn done() {}"] {
            fixture.append(&format!("{chunk}\n"))?;
            fixture.assert_equivalent()?;
        }
    }
    Ok(())
}

#[test]
fn cloned_streams_append_independently() -> TestResult {
    let mut fixture = CacheBuilder::default().build();
    fixture.append("/* stable\n")?;
    let mut speculative = fixture.stream.clone();
    fixture
        .highlighter
        .with_theme(&fixture.theme)
        .append(&mut speculative, "closed */\n")?;
    fixture.append("still comment\n")?;
    assert!(!fixture.stream.source().contains("closed"));
    assert_eq!(speculative.source(), "/* stable\nclosed */\n");
    assert!(!line(fixture.stream.highlights(), 1)?.is_empty());
    fixture.assert_equivalent()
}

#[test]
fn changed_lines_include_restyled_earlier_content() -> TestResult {
    let mut fixture = CacheBuilder::default().build();
    let first = fixture.append("let value = r#\"open\n")?;
    let update = fixture.append("closed\"#;\n")?;
    for index in 0..update.highlights.line_count() {
        if first.highlights.line(index) != update.highlights.line(index) {
            assert!(update.changed_lines.contains(&index));
        }
    }
    fixture.assert_equivalent()
}

#[test]
fn split_shebangs_can_enable_syntax_after_plain_text() -> TestResult {
    let mut fixture = CacheBuilder::default().language("").build();
    for chunk in ["#!", "/usr/bin/env py", "thon3\n", "print('café')\n"] {
        fixture.append(chunk)?;
        fixture.assert_equivalent()?;
    }
    assert_eq!(fixture.stream.work_stats().full_parses, 1);
    assert!(!line(fixture.stream.highlights(), 1)?.is_empty());
    Ok(())
}

#[test]
fn line_boundaries_preserve_exact_text_and_projection_counts() -> TestResult {
    for language in ["rust", "binary.zzz"] {
        let mut fixture = CacheBuilder::default().language(language).build();
        for chunk in [
            "",
            "\n",
            "\n",
            "/* café",
            "\r",
            "\n",
            "still comment",
            "\r\n",
            "*/",
            "\n",
        ] {
            fixture.append(chunk)?;
            fixture.assert_equivalent()?;
        }
        let document = fixture.highlight(fixture.stream.source().to_owned().as_str())?;
        assert_eq!(
            document.line_count(),
            fixture.stream.highlights().line_count()
        );
    }
    Ok(())
}