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
}
fn scan_spans_and_stop(content: &str) -> (Vec<(u32, &str)>, Option<usize>) {
let mut scanner = EntityScanner::new(content);
let mut spans = Vec::new();
while let Some((id, _type_name, start, end)) = scanner.next_entity() {
spans.push((id, &content[start..end]));
}
(spans, scanner.malformed_record_start())
}
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"
);
}
#[test]
fn fused_id_prefix_retains_refusal_and_malformed_order_3987() {
for id in ["1", "0000000000000000001", "4294967295", "4294967296",
"999999999999999999999999999999999999"] {
for trivia in ["", " ", "/* quote' ; #7=IFCX(); */", "\r\n/* a=b */\x0b"] {
let prefix = format!("#{id}{trivia}=");
let parsed = id.parse::<u32>().ok();
for body in ["IFCWALL('a;''b',/* x; */$);", "/* x'; */IFCWALL($);", "IFCWALL($);"] {
let record = format!("{prefix}{body}");
let source = format!("{record}#7=IFCDOOR($);");
let mut scanner = EntityScanner::new(&source);
if let Some(expected) = parsed {
let found = scanner.next_entity().unwrap();
assert_eq!((found.0, found.1, found.2, found.3),
(expected, "IFCWALL", 0, record.len()));
}
assert_eq!(scanner.next_entity(), Some((7, "IFCDOOR", record.len(), source.len())));
assert_eq!(scanner.skipped_oversized_id_starts(),
if parsed.is_none() { &[0][..] } else { &[][..] });
assert_eq!(scanner.malformed_record_start(), None);
}
for body in ["IFCWALL('unterminated);", "IFCWALL(/* unterminated", "IFCWALL($)"] {
let source = format!("{prefix}{body}");
let mut scanner = EntityScanner::new(&source);
assert!(scanner.next_entity().is_none());
assert_eq!(scanner.malformed_record_start(), Some(0));
assert!(scanner.skipped_oversized_id_starts().is_empty());
}
}
}
let source = "#4294967296/* never closed";
let mut scanner = EntityScanner::new(source);
assert!(scanner.next_entity().is_none());
assert_eq!(scanner.malformed_record_start(), source.find("/*"));
assert!(scanner.skipped_oversized_id_starts().is_empty());
let source = "#4294967296 not-a-declaration #8=IFCWALL($);";
let mut scanner = EntityScanner::new(source);
assert_eq!(scanner.next_entity().map(|x| x.0), Some(8));
assert!(scanner.skipped_oversized_id_starts().is_empty());
}
#[test]
fn unterminated_record_does_not_swallow_the_next_record_4179() {
let content = "#1=IFCA(1);\n#2=IFCB(2)\n#3=IFCC(3);\n#4=IFCD(4);\n";
let (spans, stop) = scan_spans_and_stop(content);
assert_eq!(spans, vec![(1, "#1=IFCA(1);"), (3, "#3=IFCC(3);"), (4, "#4=IFCD(4);")]);
assert_eq!(
stop,
content.find("#2"),
"a record whose own ';' is missing must be reported, not completed \
with the next record's ';'"
);
}
#[test]
fn unterminated_last_record_does_not_swallow_the_footer_4179() {
let content = "#1=IFCPROJECT('a');\n#2=IFCWALL('b')\nENDSEC;\nEND-ISO-10303-21;\n";
let (spans, stop) = scan_spans_and_stop(content);
assert_eq!(spans, vec![(1, "#1=IFCPROJECT('a');")]);
assert_eq!(stop, content.find("#2"));
}
#[test]
fn recovery_does_not_resume_past_an_unclosed_string_or_comment_4179() {
for body in ["IFCWALL('never closes,$)", "IFCWALL(/* never closes $)"] {
let content = format!("#1=IFCA(1);\n#2={body}\n#3=IFCC(3);\n");
let (spans, stop) = scan_spans_and_stop(&content);
assert_eq!(spans, vec![(1, "#1=IFCA(1);")], "body: {body}");
assert_eq!(stop, content.find("#2"), "body: {body}");
}
}
#[test]
fn record_boundary_guards_accept_legal_records_4179() {
let mut bodies = vec![
"IFCWALL('a=b',$);".to_string(),
"IFCWALL($ /* a=b */);".to_string(),
"IFCDOCUMENTREFERENCE('http://h/q?a=b&c=d',$);".to_string(),
"IFCWALL($)/* trailing */;".to_string(),
"IFCWALL($)/* one *//* two */;".to_string(),
"IFCWALL($) /* spaced */ \t /* twice */ ;".to_string(),
"IFCWALL($)/* multi\nline */;".to_string(),
"IFCWALL(('a'),(1.,2.));".to_string(),
"IFCWALL(\n 'a',\n $\n);".to_string(),
"(IFCA(1)IFCB(2));".to_string(),
];
for space in [" ", "\t", "\r", "\n", "\x0b", "\x0c"] {
bodies.push(format!("IFCWALL($){space};"));
bodies.push(format!("IFCWALL($){space}/* c */{space};"));
}
for body in &bodies {
let body = body.as_str();
let content = format!("#1={body}\n#2=IFCDOOR($);\n");
let (spans, stop) = scan_spans_and_stop(&content);
let record = format!("#1={body}");
assert_eq!(spans, vec![(1, record.as_str()), (2, "#2=IFCDOOR($);")], "body: {body}");
assert_eq!(stop, None, "body: {body}");
}
}