editor-core 0.4.1

A headless editor engine focused on state management, Unicode-aware text measurement, and coordinate conversion.
Documentation
use editor_core::{
    AutoPairsConfig, Command, CommandExecutor, CursorCommand, EditCommand,
    MATCH_HIGHLIGHT_STYLE_ID, Position, Selection, SelectionDirection, StyleCommand, StyleLayerId,
    ViewCommand,
};

#[test]
fn type_char_auto_closes_brackets_and_places_caret_between() {
    let mut executor = CommandExecutor::empty(80);
    executor
        .execute(Command::View(ViewCommand::SetAutoPairsEnabled {
            enabled: true,
        }))
        .unwrap();

    executor
        .execute(Command::Edit(EditCommand::TypeChar { ch: '(' }))
        .unwrap();

    assert_eq!(executor.editor().get_text(), "()");
    assert_eq!(executor.editor().cursor_position(), Position::new(0, 1));
    assert!(executor.editor().selection().is_none());
}

#[test]
fn type_char_auto_closes_quotes() {
    let mut executor = CommandExecutor::empty(80);
    executor
        .execute(Command::View(ViewCommand::SetAutoPairsEnabled {
            enabled: true,
        }))
        .unwrap();

    executor
        .execute(Command::Edit(EditCommand::TypeChar { ch: '"' }))
        .unwrap();

    assert_eq!(executor.editor().get_text(), "\"\"");
    assert_eq!(executor.editor().cursor_position(), Position::new(0, 1));
}

#[test]
fn type_char_wraps_selection_when_enabled() {
    let mut executor = CommandExecutor::new("abc", 80);
    executor
        .execute(Command::View(ViewCommand::SetAutoPairsEnabled {
            enabled: true,
        }))
        .unwrap();

    executor
        .execute(Command::Cursor(CursorCommand::SetSelection {
            start: Position::new(0, 1),
            end: Position::new(0, 2),
        }))
        .unwrap();

    executor
        .execute(Command::Edit(EditCommand::TypeChar { ch: '(' }))
        .unwrap();

    assert_eq!(executor.editor().get_text(), "a(b)c");
    assert_eq!(executor.editor().cursor_position(), Position::new(0, 3));
    assert_eq!(
        executor.editor().selection().cloned(),
        Some(Selection {
            start: Position::new(0, 2),
            end: Position::new(0, 3),
            direction: SelectionDirection::Forward,
        })
    );
}

#[test]
fn type_char_skips_over_existing_closing_delimiter_without_creating_undo_step() {
    let mut executor = CommandExecutor::new("()", 80);
    executor
        .execute(Command::View(ViewCommand::SetAutoPairsEnabled {
            enabled: true,
        }))
        .unwrap();
    executor
        .execute(Command::Cursor(CursorCommand::MoveTo {
            line: 0,
            column: 1,
        }))
        .unwrap();

    executor
        .execute(Command::Edit(EditCommand::TypeChar { ch: ')' }))
        .unwrap();

    assert_eq!(executor.editor().get_text(), "()");
    assert_eq!(executor.editor().cursor_position(), Position::new(0, 2));
    assert!(
        !executor.can_undo(),
        "expected skip-over to be a no-op edit"
    );
    assert!(
        executor.take_last_text_delta().is_none(),
        "expected no text delta for skip-over"
    );
}

#[test]
fn backspace_deletes_pair_when_between_matching_delimiters() {
    let mut executor = CommandExecutor::new("()", 80);
    executor
        .execute(Command::View(ViewCommand::SetAutoPairsEnabled {
            enabled: true,
        }))
        .unwrap();
    executor
        .execute(Command::Cursor(CursorCommand::MoveTo {
            line: 0,
            column: 1,
        }))
        .unwrap();

    executor
        .execute(Command::Edit(EditCommand::Backspace))
        .unwrap();

    assert_eq!(executor.editor().get_text(), "");
    assert_eq!(executor.editor().cursor_position(), Position::new(0, 0));
    assert!(executor.can_undo(), "expected pair deletion to be undoable");
}

#[test]
fn delete_forward_deletes_pair_when_between_matching_delimiters() {
    let mut executor = CommandExecutor::new("()", 80);
    executor
        .execute(Command::View(ViewCommand::SetAutoPairsEnabled {
            enabled: true,
        }))
        .unwrap();
    executor
        .execute(Command::Cursor(CursorCommand::MoveTo {
            line: 0,
            column: 1,
        }))
        .unwrap();

    executor
        .execute(Command::Edit(EditCommand::DeleteForward))
        .unwrap();

    assert_eq!(executor.editor().get_text(), "");
    assert_eq!(executor.editor().cursor_position(), Position::new(0, 0));
}

#[test]
fn move_to_matching_bracket_jumps_to_pair() {
    let mut executor = CommandExecutor::new("(a[b]c)", 80);
    executor
        .execute(Command::Cursor(CursorCommand::MoveTo {
            line: 0,
            column: 1,
        }))
        .unwrap();

    executor
        .execute(Command::Cursor(CursorCommand::MoveToMatchingBracket))
        .unwrap();
    assert_eq!(executor.editor().cursor_position(), Position::new(0, 6));

    executor
        .execute(Command::Cursor(CursorCommand::MoveTo {
            line: 0,
            column: 7,
        }))
        .unwrap();
    executor
        .execute(Command::Cursor(CursorCommand::MoveToMatchingBracket))
        .unwrap();
    assert_eq!(executor.editor().cursor_position(), Position::new(0, 0));
}

#[test]
fn move_to_matching_bracket_handles_nested_mixed_brackets_in_fixture() {
    let text = include_str!("fixtures/bracket_matching.txt");
    let line = text.lines().next().expect("fixture should have 1+ line");
    let open_col = line.find('(').expect("fixture should include '('");
    let close_col = line.find(')').expect("fixture should include ')'");

    let mut executor = CommandExecutor::new(text, 120);
    executor
        .execute(Command::Cursor(CursorCommand::MoveTo {
            line: 0,
            column: open_col + 1,
        }))
        .unwrap();
    executor
        .execute(Command::Cursor(CursorCommand::MoveToMatchingBracket))
        .unwrap();

    assert_eq!(
        executor.editor().cursor_position(),
        Position::new(0, close_col)
    );
}

#[test]
fn bracket_match_highlights_update_and_clear_style_layer() {
    let mut executor = CommandExecutor::new("(a)", 80);
    executor
        .execute(Command::Cursor(CursorCommand::MoveTo {
            line: 0,
            column: 1,
        }))
        .unwrap();

    executor
        .execute(Command::Style(StyleCommand::UpdateBracketMatchHighlights))
        .unwrap();

    let tree = executor
        .editor()
        .style_layer(StyleLayerId::BRACKET_MATCHES)
        .expect("expected BRACKET_MATCHES style layer");
    assert_eq!(tree.len(), 2);
    assert!(
        tree.query_point(0)
            .iter()
            .any(|i| i.style_id == MATCH_HIGHLIGHT_STYLE_ID),
        "expected match highlight at opening bracket"
    );
    assert!(
        tree.query_point(2)
            .iter()
            .any(|i| i.style_id == MATCH_HIGHLIGHT_STYLE_ID),
        "expected match highlight at closing bracket"
    );

    executor
        .execute(Command::Style(StyleCommand::ClearBracketMatchHighlights))
        .unwrap();
    assert!(
        !executor
            .editor()
            .style_layers()
            .contains_key(&StyleLayerId::BRACKET_MATCHES)
    );
}

#[test]
fn auto_pairs_config_can_be_replaced_per_view() {
    let mut executor = CommandExecutor::empty(80);
    executor
        .execute(Command::View(ViewCommand::SetAutoPairsConfig {
            config: AutoPairsConfig {
                enabled: true,
                pairs: vec![],
                wrap_selection: false,
                skip_over_closing: false,
                delete_pair: false,
            },
        }))
        .unwrap();

    executor
        .execute(Command::Edit(EditCommand::TypeChar { ch: '(' }))
        .unwrap();
    assert_eq!(executor.editor().get_text(), "(");
}