bibtex-parser 0.3.1

BibTeX parser for Rust
Documentation
use bibtex_parser::{Parser, SourceId, SourceMap};

#[test]
fn parsed_document_tracks_anonymous_and_named_sources() {
    let input = r#"@article{paper, title = "Example Paper"}"#;

    let anonymous = Parser::new().parse_document(input).unwrap();
    assert_eq!(anonymous.sources()[0].id, SourceId::new(0));
    assert!(anonymous.sources()[0].name.is_none());
    assert_eq!(
        anonymous.entries()[0].source.unwrap().source,
        Some(SourceId::new(0))
    );

    let named = Parser::new().parse_source("refs/main.bib", input).unwrap();
    assert_eq!(named.sources()[0].id, SourceId::new(0));
    assert_eq!(named.sources()[0].name.as_deref(), Some("refs/main.bib"));
    assert_eq!(
        named.entries()[0].source.unwrap().source,
        Some(SourceId::new(0))
    );
}

#[test]
fn entry_and_field_locations_cover_ascii_tokens() {
    let input = "@article{key,\n  title = \"A\",\n  year = 2026\n}";
    let document = Parser::new().parse_document(input).unwrap();
    let entry = &document.entries()[0];
    let title = &entry.fields[0];
    let year = &entry.fields[1];

    let entry_span = entry.source.unwrap();
    assert_eq!(entry_span.byte_start, 0);
    assert_eq!(entry_span.byte_end, input.len());
    assert_eq!((entry_span.line, entry_span.column), (1, 1));
    assert_eq!((entry_span.end_line, entry_span.end_column), (4, 2));

    let entry_type = entry.entry_type_source.unwrap();
    assert_eq!(
        &input[entry_type.byte_start..entry_type.byte_end],
        "article"
    );
    assert_eq!((entry_type.line, entry_type.column), (1, 2));
    assert_eq!((entry_type.end_line, entry_type.end_column), (1, 9));

    let key = entry.key_source.unwrap();
    assert_eq!(&input[key.byte_start..key.byte_end], "key");
    assert_eq!((key.line, key.column), (1, 10));
    assert_eq!((key.end_line, key.end_column), (1, 13));

    let title_name = title.name_source.unwrap();
    assert_eq!(&input[title_name.byte_start..title_name.byte_end], "title");
    assert_eq!((title_name.line, title_name.column), (2, 3));
    assert_eq!((title_name.end_line, title_name.end_column), (2, 8));

    let title_value = title.value_source.unwrap();
    assert_eq!(
        &input[title_value.byte_start..title_value.byte_end],
        "\"A\""
    );
    assert_eq!((title_value.line, title_value.column), (2, 11));
    assert_eq!((title_value.end_line, title_value.end_column), (2, 14));

    let title_field = title.source.unwrap();
    assert_eq!(
        &input[title_field.byte_start..title_field.byte_end],
        "title = \"A\","
    );

    let year_value = year.value_source.unwrap();
    assert_eq!(&input[year_value.byte_start..year_value.byte_end], "2026");
    assert_eq!((year_value.line, year_value.column), (3, 10));
    assert_eq!((year_value.end_line, year_value.end_column), (3, 14));
}

#[test]
fn locations_count_unicode_columns_not_bytes() {
    let input = "@article{paper,\n  title = \"Café\"\n}";
    let document = Parser::new().parse_document(input).unwrap();
    let value = document.entries()[0].fields[0].value_source.unwrap();

    assert_eq!(&input[value.byte_start..value.byte_end], "\"Café\"");
    assert_eq!((value.line, value.column), (2, 11));
    assert_eq!((value.end_line, value.end_column), (2, 17));
    assert_eq!(value.byte_end - value.byte_start, "\"Café\"".len());
}

#[test]
fn multiline_value_locations_end_on_the_final_line() {
    let input = "@article{paper,\n  title = {Line one\n    Line two}\n}";
    let document = Parser::new().parse_document(input).unwrap();
    let value = document.entries()[0].fields[0].value_source.unwrap();

    assert_eq!(
        &input[value.byte_start..value.byte_end],
        "{Line one\n    Line two}"
    );
    assert_eq!((value.line, value.column), (2, 11));
    assert_eq!((value.end_line, value.end_column), (3, 14));
}

#[test]
fn source_map_handles_end_of_file_positions() {
    let input = "α\nbeta";
    let map = SourceMap::new(Some(SourceId::new(7)), Some("unicode.bib".into()), input);
    let span = map.span(0, input.len());

    assert_eq!(span.source, Some(SourceId::new(7)));
    assert_eq!(map.name(), Some("unicode.bib"));
    assert_eq!((span.line, span.column), (1, 1));
    assert_eq!((span.end_line, span.end_column), (2, 5));
    assert_eq!(map.slice(span), Some(input));
}

#[test]
fn diagnostics_carry_named_source_locations_when_available() {
    let input = "@article{bad, title = \"Missing close\"\n@book{ok, title = \"Recovered\"}";
    let document = Parser::new()
        .tolerant()
        .parse_source("broken.bib", input)
        .unwrap();
    let diagnostic = &document.diagnostics()[0];
    let span = diagnostic.source.unwrap();

    assert_eq!(span.source, Some(SourceId::new(0)));
    assert_eq!(document.sources()[0].name.as_deref(), Some("broken.bib"));
    assert_eq!((span.line, span.column), (2, 1));
    assert_eq!(span.byte_start, span.byte_end);
    assert!(diagnostic
        .snippet
        .as_deref()
        .unwrap()
        .contains("@article{bad"));
}