syndiff 0.2.0

Standalone library that implements a structural diff algorithm inspired by Difftastic
Documentation
use rstest::rstest;
use std::ops::Range;
use std::path::PathBuf;

use syndiff::{build_tree, diff_trees, SyntaxDiffOptions, SyntaxTree};

fn parse_with_language(source: &str, language: tree_sitter::Language) -> SyntaxTree<'_> {
    let mut parser = tree_sitter::Parser::new();
    parser
        .set_language(&language)
        .expect("failed to set language");
    let tree = parser.parse(source, None).expect("failed to parse");
    build_tree(tree.walk(), source)
}

fn mark_text(unmarked_text: &str, ranges: &[Range<usize>]) -> String {
    let mut marked_text = unmarked_text.to_string();
    for range in ranges.iter().rev() {
        marked_text.insert(range.end, '»');
        marked_text.insert(range.start, '«');
    }
    marked_text
}

fn unmark_text(marked_text: &str) -> (String, Vec<Range<usize>>) {
    let mut unmarked_text = String::new();
    let mut current_start = None;
    let mut ranges = Vec::default();
    let mut last_end = 0;

    for (marker_index, marker) in marked_text.match_indices(&['«', '»']) {
        unmarked_text.push_str(&marked_text[last_end..marker_index]);
        last_end = marker_index + marker.len();

        match marker {
            "«" => {
                if current_start.is_some() {
                    panic!("duplicate start marker at index {marker_index}")
                } else {
                    current_start = Some(unmarked_text.len());
                }
            }
            "»" => {
                if let Some(start) = current_start.take() {
                    ranges.push(start..unmarked_text.len());
                } else {
                    panic!("unexpected end marker at index {marker_index}")
                }
            }
            _ => unreachable!(),
        }
    }

    unmarked_text.push_str(&marked_text[last_end..]);
    (unmarked_text, ranges)
}

fn assert_diff(lhs_marked: &str, rhs_marked: &str, parser: fn(&str) -> SyntaxTree) {
    let (lhs_text, expected_lhs_ranges) = unmark_text(lhs_marked);
    let (rhs_text, expected_rhs_ranges) = unmark_text(rhs_marked);

    let lhs_tree = parser(&lhs_text);
    let rhs_tree = parser(&rhs_text);

    let (lhs_ranges, rhs_ranges) = diff_trees(
        &lhs_tree,
        &rhs_tree,
        None,
        None,
        Some(SyntaxDiffOptions::default()),
    )
    .expect("diff should not exceed graph limit");

    let actual_lhs_marked = mark_text(&lhs_text, &lhs_ranges);
    let actual_rhs_marked = mark_text(&rhs_text, &rhs_ranges);

    assert_eq!(
        lhs_ranges, expected_lhs_ranges,
        "LHS ranges mismatch.\nExpected: {lhs_marked}\nActual:   {actual_lhs_marked}"
    );
    assert_eq!(
        rhs_ranges, expected_rhs_ranges,
        "RHS ranges mismatch.\nExpected: {rhs_marked}\nActual:   {actual_rhs_marked}"
    );
}

fn get_parser(lang: &str) -> fn(&str) -> SyntaxTree {
    match lang {
        "rust" => |source| parse_with_language(source, tree_sitter_rust::LANGUAGE.into()),
        "json" => |source| parse_with_language(source, tree_sitter_json::LANGUAGE.into()),
        _ => panic!("unsupported language: {lang}"),
    }
}

#[rstest]
fn test_fixtures(#[files("fixtures/*/*.before")] path: PathBuf) {
    let lang = path
        .parent()
        .and_then(|p| p.file_name())
        .and_then(|n| n.to_str())
        .expect("could not determine language from path");

    let parser = get_parser(lang);

    let before_content = std::fs::read_to_string(&path)
        .unwrap_or_else(|e| panic!("failed to read {}: {}", path.display(), e));

    let after_path = path.with_extension("after");
    let after_content = std::fs::read_to_string(&after_path)
        .unwrap_or_else(|e| panic!("failed to read {}: {}", after_path.display(), e));

    assert_diff(&before_content, &after_content, parser);
}