idet-core 0.4.0

Editing logic for text editors, without a frontend
Documentation
#![allow(clippy::unwrap_used)]

use std::path::{Path, PathBuf};

use idet_core::syntax::{Class, Syntax};

fn language(name: &str, rules: &str) -> (Syntax, PathBuf) {
    let path = std::env::temp_dir().join(format!("idet-syntax-{name}.idet"));
    std::fs::write(&path, rules).unwrap();
    (Syntax::load(&path).unwrap(), path)
}

fn classes(syntax: &Syntax, text: &str) -> Vec<(String, Class)> {
    syntax
        .spans(text)
        .into_iter()
        .map(|(range, class)| (text.get(range).unwrap_or_default().to_owned(), class))
        .collect()
}

const RUST: &str = "extensions rs\nline_comment //\nblock_comment /* */ nested\nstrings \" \\\nkeywords fn let\ntypes u8 String\n";

#[test]
fn words_split_into_keywords_types_and_numbers() {
    let (syntax, path) = language("words", RUST);
    assert_eq!(
        classes(&syntax, "let count: u8 = 42;"),
        [
            ("let".to_owned(), Class::Keyword),
            ("u8".to_owned(), Class::Type),
            ("42".to_owned(), Class::Number),
        ]
    );
    let _ = std::fs::remove_file(path);
}

#[test]
fn a_digit_inside_a_word_is_not_a_number() {
    let (syntax, path) = language("identifier", RUST);
    assert!(classes(&syntax, "let u8x2 = value1;")[1..].is_empty());
    let _ = std::fs::remove_file(path);
}

#[test]
fn a_block_comment_spans_lines_and_nests() {
    let (syntax, path) = language("block", RUST);
    let text = "/* outer\n/* inner */\nstill */ let";
    assert_eq!(
        classes(&syntax, text),
        [
            ("/* outer\n/* inner */\nstill */".to_owned(), Class::Comment),
            ("let".to_owned(), Class::Keyword),
        ]
    );
    let _ = std::fs::remove_file(path);
}

#[test]
fn keywords_inside_comments_and_strings_stay_covered() {
    let (syntax, path) = language("cover", RUST);
    assert_eq!(
        classes(&syntax, "// let\n\"let\"\nlet"),
        [
            ("// let".to_owned(), Class::Comment),
            ("\"let\"".to_owned(), Class::String),
            ("let".to_owned(), Class::Keyword),
        ]
    );
    let _ = std::fs::remove_file(path);
}

#[test]
fn an_escaped_quote_does_not_end_the_string() {
    let (syntax, path) = language("escape", RUST);
    assert_eq!(
        classes(&syntax, "\"a\\\"b\" let"),
        [
            ("\"a\\\"b\"".to_owned(), Class::String),
            ("let".to_owned(), Class::Keyword),
        ]
    );
    let _ = std::fs::remove_file(path);
}

#[test]
fn an_unclosed_string_runs_to_the_end() {
    let (syntax, path) = language("unclosed", RUST);
    assert_eq!(
        classes(&syntax, "\"open"),
        [("\"open".to_owned(), Class::String)]
    );
    let _ = std::fs::remove_file(path);
}

#[test]
fn spans_are_byte_ranges_across_multibyte_text() {
    let (syntax, path) = language("multibyte", RUST);
    let text = "// รค\nlet";
    assert_eq!(
        syntax.spans(text),
        [(0..5, Class::Comment), (6..9, Class::Keyword)]
    );
    let _ = std::fs::remove_file(path);
}

#[test]
fn a_rule_without_its_words_is_an_error() {
    let path = std::env::temp_dir().join("idet-syntax-broken.idet");
    std::fs::write(&path, "block_comment /*\n").unwrap();
    assert!(Syntax::load(&path).is_err());
    let _ = std::fs::remove_file(path);
}

#[test]
fn the_shipped_rust_language_covers_rust_files() {
    let path = Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("..")
        .join("syntax")
        .join("rust.idet");
    let syntax = Syntax::load(&path).unwrap();
    assert!(syntax.covers("rs"));
    assert!(!syntax.covers("txt"));
    assert_eq!(
        classes(&syntax, "pub fn main() -> Result<(), String> {"),
        [
            ("pub".to_owned(), Class::Keyword),
            ("fn".to_owned(), Class::Keyword),
            ("Result".to_owned(), Class::Type),
            ("String".to_owned(), Class::Type),
        ]
    );
}