editor-core-treesitter 0.4.1

Tree-sitter integration for editor-core (highlighting, folding, incremental parsing).
Documentation
use editor_core::{
    Command, DocumentProcessor, EditCommand, EditorStateManager, ProcessingEdit, StyleLayerId,
};
use editor_core_treesitter::{
    TreeSitterConfig, TreeSitterProcessor, TreeSitterUpdateMode, load_processor_config_from_config,
};
use std::collections::BTreeMap;

fn rust_fixture_config() -> TreeSitterConfig {
    let dir =
        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/treesitter/rust");
    TreeSitterConfig::from_language_dir(&dir).expect("rust treesitter fixture directory")
}

fn rust_fixture_processor_config() -> editor_core_treesitter::TreeSitterProcessorConfig {
    let cfg = rust_fixture_config();
    let mut config =
        load_processor_config_from_config("rust", &cfg).expect("load processor config");
    config.capture_styles =
        BTreeMap::from([("comment".to_string(), 10), ("string".to_string(), 11)]);
    config
}

#[test]
fn test_processor_produces_highlights_and_folds_from_fixture() {
    let text = include_str!("fixtures/rust_sample.rs");
    let state = EditorStateManager::new(text, 80);

    let config = rust_fixture_processor_config();

    let mut processor = TreeSitterProcessor::new(config).unwrap();
    let edits = processor.process(&state).unwrap();
    assert_eq!(processor.last_update_mode(), TreeSitterUpdateMode::Initial);

    let mut saw_style = false;
    let mut saw_folds = false;
    for edit in edits {
        match edit {
            ProcessingEdit::ReplaceStyleLayer { layer, intervals } => {
                assert_eq!(layer, StyleLayerId::TREE_SITTER);
                assert!(!intervals.is_empty());
                saw_style = true;
            }
            ProcessingEdit::ReplaceFoldingRegions { regions, .. } => {
                assert!(!regions.is_empty());
                saw_folds = true;
            }
            _ => {}
        }
    }
    assert!(saw_style);
    assert!(saw_folds);
}

#[test]
fn test_processor_uses_text_delta_incrementally() {
    let text = include_str!("fixtures/rust_sample.rs");
    let mut state = EditorStateManager::new(text, 80);

    let mut config = rust_fixture_processor_config();
    config.capture_styles = BTreeMap::from([("comment".to_string(), 1), ("string".to_string(), 2)]);

    let mut processor = TreeSitterProcessor::new(config).unwrap();
    state.apply_processor(&mut processor).unwrap();

    // Insert a small change; `EditorStateManager` records a `TextDelta`.
    state
        .execute(Command::Edit(EditCommand::Insert {
            offset: 0,
            text: "// header\n".to_string(),
        }))
        .unwrap();

    let edits = processor.process(&state).unwrap();
    assert_eq!(
        processor.last_update_mode(),
        TreeSitterUpdateMode::Incremental
    );
    assert!(!edits.is_empty());
}

#[test]
fn test_process_text_api_supports_incremental_and_full_resync() {
    let initial = "fn main() {\n  let x = 1;\n}\n";

    let mut config = rust_fixture_processor_config();
    config.capture_styles = BTreeMap::from([("comment".to_string(), 1), ("string".to_string(), 2)]);

    let mut processor = TreeSitterProcessor::new(config).unwrap();

    let edits1 = processor.process_text(1, None, Some(initial)).unwrap();
    assert_eq!(processor.last_update_mode(), TreeSitterUpdateMode::Initial);
    assert!(!edits1.is_empty());

    let insert = "// header\n";
    let delta = editor_core::delta::TextDelta {
        before_char_count: initial.chars().count(),
        after_char_count: initial.chars().count() + insert.chars().count(),
        edits: vec![editor_core::delta::TextDeltaEdit {
            start: 0,
            deleted_text: String::new(),
            inserted_text: insert.to_string(),
        }],
        undo_group_id: None,
    };

    let edits2 = processor.process_text(2, Some(&delta), None).unwrap();
    assert_eq!(
        processor.last_update_mode(),
        TreeSitterUpdateMode::Incremental
    );
    assert!(!edits2.is_empty());

    // Corrupt delta should surface as a mismatch unless the caller provides a full resync text.
    let bad_delta = editor_core::delta::TextDelta {
        before_char_count: delta.after_char_count,
        after_char_count: delta.after_char_count,
        edits: vec![editor_core::delta::TextDeltaEdit {
            start: 0,
            deleted_text: "not-a-match".to_string(),
            inserted_text: String::new(),
        }],
        undo_group_id: None,
    };
    assert!(matches!(
        processor.process_text(3, Some(&bad_delta), None),
        Err(editor_core_treesitter::TreeSitterError::DeltaMismatch)
    ));

    let full = format!("{insert}{initial}");
    let edits3 = processor
        .process_text(3, Some(&bad_delta), Some(&full))
        .unwrap();
    assert_eq!(
        processor.last_update_mode(),
        TreeSitterUpdateMode::FullReparse
    );
    assert!(!edits3.is_empty());
}

#[test]
fn test_sync_to_and_compute_edits_supports_debounced_query_and_char_range() {
    let text = "// a\nfn main() {\n  let x = 1;\n}\n// b\n";

    let config = rust_fixture_processor_config();

    let mut processor = TreeSitterProcessor::new(config).unwrap();

    let mode0 = processor.sync_to(1, None, Some(text)).unwrap();
    assert_eq!(mode0, TreeSitterUpdateMode::Initial);
    assert_eq!(processor.last_update_mode(), TreeSitterUpdateMode::Initial);

    // Debounced model: sync first, query later. Use a range-limited query (first line only).
    let end_first_line = text.find('\n').unwrap_or(0) + 1;
    let edits = processor
        .compute_processing_edits(Some((0, end_first_line)))
        .unwrap();
    let style_edits = edits
        .iter()
        .filter_map(|e| match e {
            ProcessingEdit::ReplaceStyleLayer { intervals, .. } => Some(intervals.as_slice()),
            _ => None,
        })
        .collect::<Vec<_>>();
    assert_eq!(style_edits.len(), 1);
    for interval in style_edits[0] {
        assert!(interval.end <= end_first_line);
    }

    // Already processed version 1, so compute again should yield empty edits.
    assert!(processor.compute_processing_edits(None).unwrap().is_empty());

    // Make a tiny edit to bump to version 2 and re-run query within a range.
    let delta = editor_core::delta::TextDelta {
        before_char_count: text.chars().count(),
        after_char_count: text.chars().count() + 1,
        edits: vec![editor_core::delta::TextDeltaEdit {
            start: 0,
            deleted_text: String::new(),
            inserted_text: " ".to_string(),
        }],
        undo_group_id: None,
    };
    let mode1 = processor.sync_to(2, Some(&delta), None).unwrap();
    assert_eq!(mode1, TreeSitterUpdateMode::Incremental);

    let edits2 = processor
        .compute_processing_edits(Some((0, end_first_line + 1)))
        .unwrap();
    let style_edits2 = edits2
        .iter()
        .filter_map(|e| match e {
            ProcessingEdit::ReplaceStyleLayer { intervals, .. } => Some(intervals.as_slice()),
            _ => None,
        })
        .collect::<Vec<_>>();
    assert_eq!(style_edits2.len(), 1);
    for interval in style_edits2[0] {
        assert!(interval.end <= end_first_line + 1);
    }
}

#[test]
fn test_expand_selection_syntax_expands_identifier_then_function_item() {
    let text = include_str!("fixtures/rust_sample.rs");
    let state = EditorStateManager::new(text, 80);

    let mut config = rust_fixture_processor_config();
    config.capture_styles = BTreeMap::from([("comment".to_string(), 1), ("string".to_string(), 2)]);

    let mut processor = TreeSitterProcessor::new(config).unwrap();
    let _ = processor.process(&state).unwrap();

    let add_start = char_offset_of(text, "add");
    let caret = add_start + 1; // inside identifier

    let (s1, e1) = processor
        .expand_selection_syntax(caret, caret)
        .expect("should expand");
    assert_eq!(slice_chars(text, s1, e1), "add");

    let (s2, e2) = processor
        .expand_selection_syntax(s1, e1)
        .expect("should expand again");
    let expanded = slice_chars(text, s2, e2);
    assert!(expanded.contains("fn add("));
    assert!(expanded.contains("a + b"));
}

#[test]
fn test_expand_selection_syntax_returns_none_when_already_at_root() {
    let text = include_str!("fixtures/rust_sample.rs");
    let state = EditorStateManager::new(text, 80);

    let mut config = rust_fixture_processor_config();
    config.capture_styles = BTreeMap::from([("comment".to_string(), 1)]);

    let mut processor = TreeSitterProcessor::new(config).unwrap();
    let _ = processor.process(&state).unwrap();

    let len = text.chars().count();
    assert!(processor.expand_selection_syntax(0, len).is_none());
}

fn char_offset_of(text: &str, needle: &str) -> usize {
    let byte = text.find(needle).expect("needle not found");
    text[..byte].chars().count()
}

fn slice_chars(text: &str, start: usize, end: usize) -> String {
    text.chars()
        .skip(start)
        .take(end.saturating_sub(start))
        .collect()
}