use super::*;
#[test]
fn type_name_ascii_fast_path_preserves_utf8_and_invalid_byte_behavior() {
for keyword in [
b"IFCCARTESIANPOINT".as_slice(),
b"IfcVendor_123".as_slice(),
"IfcVéndor".as_bytes(),
"IfcVendor\u{1f600}".as_bytes(),
b"Ifc\xffVendor".as_slice(),
b"IfcVendor\xc3".as_slice(),
b"\x80IFCVENDOR".as_slice(),
] {
for delimiter in [b"(".as_slice(), b"\x0b(".as_slice(), b"/* note */(".as_slice()] {
let mut content = b"#1=".to_vec();
content.extend_from_slice(keyword);
content.extend_from_slice(delimiter);
content.extend_from_slice(b"$);");
let first_end = content.len();
content.extend_from_slice(b"#2=IFCWALL($);");
let mut scanner = EntityScanner::new(&content);
let (id, name, start, end) = scanner.next_entity().unwrap();
assert_eq!((id, start, end), (1, 0, first_end));
assert_eq!(name, std::str::from_utf8(keyword).unwrap_or("UNKNOWN"));
assert_eq!(scanner.next_entity().map(|(id, name, _, _)| (id, name)), Some((2, "IFCWALL")));
assert!(scanner.next_entity().is_none());
assert_eq!(scanner.malformed_record_start(), None);
}
}
}
#[test]
fn test_entity_scanner() {
let content = r#"
#1=IFCPROJECT('guid',$,$,$,$,$,$,$,$);
#2=IFCWALL('guid2',$,$,$,$,$,$,$);
#3=IFCDOOR('guid3',$,$,$,$,$,$,$);
#4=IFCWALL('guid4',$,$,$,$,$,$,$);
"#;
let mut scanner = EntityScanner::new(content);
let (id, type_name, _, _) = scanner.next_entity().unwrap();
assert_eq!(id, 1);
assert_eq!(type_name, "IFCPROJECT");
scanner.reset();
let walls = scanner.find_by_type("IFCWALL");
assert_eq!(walls.len(), 2);
assert_eq!(walls[0].0, 2);
assert_eq!(walls[1].0, 4);
scanner.reset();
let counts = scanner.count_by_type();
assert_eq!(counts.get("IFCPROJECT"), Some(&1));
assert_eq!(counts.get("IFCWALL"), Some(&2));
assert_eq!(counts.get("IFCDOOR"), Some(&1));
}
#[test]
fn test_entity_scanner_hash_in_header_filename() {
let content = "ISO-10303-21;\nHEADER;\n\
FILE_DESCRIPTION(('ViewDefinition [ReferenceView]'),'2;1');\n\
FILE_NAME('26-IFC\\X2\\00B1\\X0\\2#.ifc','2026-04-29T18:21:27',$,$,'CATIA','CATIA',$);\n\
FILE_SCHEMA(('IFC4'));\nENDSEC;\n\
DATA;\n\
#1=IFCPROJECT('guid',$,$,$,$,$,$,$,$);\n\
#2=IFCWALL('guid2',$,$,$,$,$,$,$);\n\
ENDSEC;\nEND-ISO-10303-21;\n";
let mut scanner = EntityScanner::new(content);
let counts = scanner.count_by_type();
assert_eq!(counts.get("IFCPROJECT"), Some(&1));
assert_eq!(counts.get("IFCWALL"), Some(&1));
}
#[test]
fn test_entity_scanner_no_header() {
let content = "#1=IFCWALL('guid',$,$,$,$,$,$,$);\n";
let mut scanner = EntityScanner::new(content);
let (id, type_name, _, _) = scanner.next_entity().unwrap();
assert_eq!(id, 1);
assert_eq!(type_name, "IFCWALL");
}
#[test]
fn test_entity_scanner_data_marker_inside_header_string() {
let content = "ISO-10303-21;\nHEADER;\n\
FILE_DESCRIPTION(('section DATA; in description'),'2;1');\n\
FILE_NAME('weird DATA; name.ifc','2026-04-29T18:21:27',$,$,'a','b',$);\n\
FILE_SCHEMA(('IFC4'));\nENDSEC;\n\
DATA;\n\
#1=IFCWALL('guid',$,$,$,$,$,$,$);\n\
ENDSEC;\nEND-ISO-10303-21;\n";
let mut scanner = EntityScanner::new(content);
let counts = scanner.count_by_type();
assert_eq!(counts.get("IFCWALL"), Some(&1));
let pos = scanner.position();
assert!(pos == content.len() || pos > content.find("ENDSEC;").unwrap());
}
#[test]
fn test_entity_count_matches_scan() {
let content = "ISO-10303-21;\nHEADER;\n\
FILE_DESCRIPTION(('has a #99 and DATA; inside'),'2;1');\n\
FILE_NAME('26-IFC\\X2\\00B1\\X0\\2#.ifc','2026-04-29T18:21:27',$,$,'a','b',$);\n\
FILE_SCHEMA(('IFC4'));\nENDSEC;\n\
DATA;\n\
#1=IFCPROJECT('guid',$,$,$,$,$,$,$,$);\n\
/* a comment with #77= IFCWALL inside */\n\
#2=IFCWALL('guid2',$,$,$,'name with ; semicolon',$,$,$);\n\
#3=IFCDOOR('guid3',$,$,$,$,$,$,$);\n\
ENDSEC;\nEND-ISO-10303-21;\n";
assert_eq!(entity_count(content), 3);
assert_eq!(EntityScanner::new(content).count(), 3);
let total: usize = EntityScanner::new(content).count_by_type().values().sum();
assert_eq!(total, 3);
}
#[test]
fn test_entity_count_empty() {
assert_eq!(entity_count(""), 0);
assert_eq!(entity_count("ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\nENDSEC;\n"), 0);
}
#[test]
fn test_entity_scanner_skips_express_id_above_u32() {
let content = "#1=IFCWALL('a');\n#4294967297=IFCWALL('b');\n#2=IFCDOOR('c');\n";
let mut scanner = EntityScanner::new(content);
let mut ids = Vec::new();
while let Some((id, _type_name, _start, _end)) = scanner.next_entity() {
ids.push(id);
}
assert_eq!(ids, vec![1, 2]);
assert_eq!(scanner.skipped_oversized_ids(), 1);
}
#[test]
fn test_entity_scanner_admits_express_id_at_u32_max() {
let content = "#4294967295=IFCWALL('a');\n#1=IFCDOOR('b');\n";
let mut scanner = EntityScanner::new(content);
let mut ids = Vec::new();
while let Some((id, _type_name, _start, _end)) = scanner.next_entity() {
ids.push(id);
}
assert_eq!(ids, vec![u32::MAX, 1]);
assert_eq!(scanner.skipped_oversized_ids(), 0);
}
#[test]
fn test_entity_scanner_leading_zero_padded_id() {
let content = "#0000000000000042=IFCWALL('a');\n";
let mut scanner = EntityScanner::new(content);
let (id, type_name, _, _) = scanner.next_entity().unwrap();
assert_eq!(id, 42);
assert_eq!(type_name, "IFCWALL");
assert_eq!(scanner.skipped_oversized_ids(), 0);
}
#[test]
fn test_entity_scanner_escaped_quote_in_header() {
let content = "ISO-10303-21;\nHEADER;\n\
FILE_DESCRIPTION(('it''s fine: DATA; inside'),'2;1');\n\
FILE_NAME('a','b',$,$,'c','d',$);\n\
FILE_SCHEMA(('IFC4'));\nENDSEC;\n\
DATA;\n\
#7=IFCDOOR('guid',$,$,$,$,$,$,$);\n\
ENDSEC;\n";
let mut scanner = EntityScanner::new(content);
let counts = scanner.count_by_type();
assert_eq!(counts.get("IFCDOOR"), Some(&1));
}
fn scan_spans(content: &str) -> Vec<(u32, String, String)> {
let mut scanner = EntityScanner::new(content);
let mut out = Vec::new();
while let Some((id, type_name, start, end)) = scanner.next_entity() {
out.push((id, type_name.to_string(), content[start..end].to_string()));
}
out
}
const DATA_PREAMBLE: &str = "ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n";
fn data_file(records: &[&str]) -> String {
format!(
"{DATA_PREAMBLE}{}\nENDSEC;\nEND-ISO-10303-21;\n",
records.join("\n")
)
}
#[test]
fn comment_between_instance_name_and_equals_is_trivia() {
let record = "#1 /* was #7 */ = IFCWALL('a',$);";
assert_eq!(
scan_spans(&data_file(&[record])),
vec![(1, "IFCWALL".to_string(), record.to_string())]
);
}
#[test]
fn semicolon_inside_a_comment_does_not_end_the_record() {
let record = "#2=IFCWALL('a', /* pending; revise */ $);";
assert_eq!(
scan_spans(&data_file(&[record])),
vec![(2, "IFCWALL".to_string(), record.to_string())]
);
}
#[test]
fn comment_between_equals_and_type_name_is_trivia() {
let record = "#3 /* a=b */ = /* c */ IFCWALL /* d */ ('a',$);";
assert_eq!(
scan_spans(&data_file(&[record])),
vec![(3, "IFCWALL".to_string(), record.to_string())]
);
}
#[test]
fn comment_opener_inside_a_string_literal_is_literal_text() {
let record = "#4=IFCWALL('rev /* pending */ note',$);";
assert_eq!(
scan_spans(&data_file(&[record])),
vec![(4, "IFCWALL".to_string(), record.to_string())]
);
}
#[test]
fn quote_inside_a_comment_is_comment_text() {
let record = "#5=IFCWALL(/* don't reuse */ 'a',$);";
assert_eq!(
scan_spans(&data_file(&[record])),
vec![(5, "IFCWALL".to_string(), record.to_string())]
);
}
#[test]
fn records_after_a_commented_one_still_scan() {
let first = "#7 /* x */ = IFCWALL('a', /* y; */ $);";
let second = "#8=IFCSLAB('b',$);";
assert_eq!(
scan_spans(&data_file(&[first, second])),
vec![
(7, "IFCWALL".to_string(), first.to_string()),
(8, "IFCSLAB".to_string(), second.to_string()),
]
);
}
#[test]
fn a_commented_out_record_is_still_not_a_record() {
let live = "#10=IFCSLAB('b',$);";
assert_eq!(
scan_spans(&data_file(&["/* #9=IFCWALL('x',$); */", live])),
vec![(10, "IFCSLAB".to_string(), live.to_string())]
);
}
#[test]
fn unterminated_comment_inside_a_record_ends_the_scan() {
let content = data_file(&["#11=IFCWALL('a', /* never closes $);"]);
assert_eq!(scan_spans(&content), vec![]);
}
#[test]
fn comment_free_records_scan_unchanged() {
let first = "#11=IFCWALL('a',$);";
let second = "#12=IFCSLAB($,$);";
assert_eq!(
scan_spans(&data_file(&[first, second])),
vec![
(11, "IFCWALL".to_string(), first.to_string()),
(12, "IFCSLAB".to_string(), second.to_string()),
]
);
}
#[test]
fn has_non_null_attribute_treats_a_comment_before_dollar_as_still_null() {
let content = "#1=IFCWALL(/* c1 */ $);";
let scanner = EntityScanner::new(content);
assert!(!scanner.has_non_null_attribute(0, content.len(), 0));
}
#[test]
fn has_non_null_attribute_treats_a_comment_before_a_value_as_non_null() {
let content = "#1=IFCWALL(/* c1 */ 'a');";
let scanner = EntityScanner::new(content);
assert!(scanner.has_non_null_attribute(0, content.len(), 0));
}
#[test]
fn has_non_null_attribute_comma_inside_a_comment_does_not_split_attributes() {
let content = "#1=IFCWALL('a', /* x, y */ $);";
let scanner = EntityScanner::new(content);
assert!(!scanner.has_non_null_attribute(0, content.len(), 1));
}
#[test]
fn has_non_null_attribute_dollar_inside_a_comment_is_comment_text() {
let content = "#1=IFCWALL(/* was $ */ 'a');";
let scanner = EntityScanner::new(content);
assert!(scanner.has_non_null_attribute(0, content.len(), 0));
}
#[test]
fn has_non_null_attribute_dollar_inside_a_comment_before_a_real_null_attribute() {
let content = "#1=IFCWALL(/* was $ */ $);";
let scanner = EntityScanner::new(content);
assert!(!scanner.has_non_null_attribute(0, content.len(), 0));
}
#[test]
fn has_non_null_attribute_comment_free_records_unchanged() {
let content = "#1=IFCWALL('a',$,5);";
let scanner = EntityScanner::new(content);
assert!(scanner.has_non_null_attribute(0, content.len(), 0));
assert!(!scanner.has_non_null_attribute(0, content.len(), 1));
assert!(scanner.has_non_null_attribute(0, content.len(), 2));
}
#[test]
fn entity_scanner_reads_a_type_name_preceded_by_a_form_feed() {
let content = "#1=\x0cIFCWALL('a',$);";
let mut scanner = EntityScanner::new(content);
let (id, type_name, _, _) = scanner.next_entity().unwrap();
assert_eq!(id, 1);
assert_eq!(type_name, "IFCWALL");
}
#[test]
fn entity_scanner_reads_a_type_name_preceded_by_a_vertical_tab() {
let content = "#1=\x0bIFCWALL('a',$);";
let mut scanner = EntityScanner::new(content);
let (id, type_name, _, _) = scanner.next_entity().unwrap();
assert_eq!(id, 1);
assert_eq!(type_name, "IFCWALL");
}
#[test]
fn entity_scanner_does_not_treat_a_form_feed_inside_a_string_as_trivia() {
let content = "#1=IFCWALL('a\x0cb',$);";
let mut scanner = EntityScanner::new(content);
let (id, type_name, start, end) = scanner.next_entity().unwrap();
assert_eq!(id, 1);
assert_eq!(type_name, "IFCWALL");
assert_eq!(&content[start..end], content);
}
#[test]
fn unterminated_string_in_a_record_loses_every_entity_after_it() {
let content =
data_file(&["#11=IFCWALL('a',$);", "#12=IFCWALL('never closes);", "#13=IFCSLAB($,$);"]);
let mut scanner = EntityScanner::new(&content);
let mut ids = Vec::new();
while let Some((id, _type_name, _start, _end)) = scanner.next_entity() {
ids.push(id);
}
assert_eq!(ids, vec![11]);
assert_eq!(
scanner.malformed_record_start(),
Some(content.find("#12").unwrap()),
"an unterminated string must be reported, not just silently end the scan"
);
}
#[test]
fn unterminated_string_alone_is_reported() {
let content = "#1=IFCWALL('never closes);";
let mut scanner = EntityScanner::new(content);
assert_eq!(scanner.next_entity(), None);
assert_eq!(scanner.malformed_record_start(), Some(0));
}
#[test]
fn clean_end_of_scan_reports_no_malformed_record() {
let content = data_file(&["#1=IFCWALL('a',$);"]);
let mut scanner = EntityScanner::new(&content);
while scanner.next_entity().is_some() {}
assert_eq!(scanner.malformed_record_start(), None);
}
#[test]
fn unterminated_comment_inside_a_record_is_reported() {
let content = data_file(&["#11=IFCWALL('a', /* never closes $);"]);
let mut scanner = EntityScanner::new(&content);
assert_eq!(scanner.next_entity(), None);
assert_eq!(
scanner.malformed_record_start(),
Some(content.find("#11").unwrap())
);
}
#[test]
fn unterminated_comment_between_records_is_reported() {
let content = data_file(&["#11=IFCWALL('a',$);", "/* never closes"]);
let mut scanner = EntityScanner::new(&content);
let mut ids = Vec::new();
while let Some((id, _type_name, _start, _end)) = scanner.next_entity() {
ids.push(id);
}
assert_eq!(ids, vec![11]);
assert_eq!(
scanner.malformed_record_start(),
Some(content.find("/* never closes").unwrap())
);
}
#[test]
fn data_marker_inside_a_header_comment_is_not_the_marker() {
let content = "ISO-10303-21;\nHEADER;\n\
/* DATA; #99=IFCWALL($); */\n\
ENDSEC;\nDATA;\n\
#1=IFCWALL('a',$);\n\
ENDSEC;\nEND-ISO-10303-21;\n";
let mut scanner = EntityScanner::new(content);
let mut ids = Vec::new();
while let Some((id, _type_name, _start, _end)) = scanner.next_entity() {
ids.push(id);
}
assert_eq!(
ids,
vec![1],
"the commented-out #99 is not a record this file declares"
);
assert_eq!(scanner.malformed_record_start(), None);
}
#[test]
fn unterminated_header_comment_is_reported() {
let content = "ISO-10303-21;\nHEADER;\n\
/* never closes\n\
ENDSEC;\nDATA;\n\
#1=IFCWALL('a',$);\n";
let mut scanner = EntityScanner::new(content);
assert_eq!(scanner.next_entity(), None);
assert_eq!(
scanner.malformed_record_start(),
Some(content.find("/* never closes").unwrap()),
"an unterminated HEADER comment must be reported, not silently skipped"
);
}