pub struct EntityScanner<'a> {
bytes: &'a [u8],
position: usize,
}
impl<'a> EntityScanner<'a> {
pub fn new<T>(content: &'a T) -> Self
where
T: AsRef<[u8]> + ?Sized,
{
let bytes = content.as_ref();
Self {
bytes,
position: data_section_start(bytes),
}
}
pub fn new_at<T>(content: &'a T, position: usize) -> Self
where
T: AsRef<[u8]> + ?Sized,
{
let bytes = content.as_ref();
let clamped = position.min(bytes.len());
Self {
bytes,
position: clamped,
}
}
pub fn position(&self) -> usize {
self.position
}
#[inline]
pub fn next_entity(&mut self) -> Option<(u32, &'a str, usize, usize)> {
let bytes = self.bytes;
let len = bytes.len();
let (line_start, id_end_validated) = loop {
let remaining = &bytes[self.position..];
let next = memchr::memchr2(b'#', b'/', remaining)?;
let candidate = self.position + next;
let candidate_byte = bytes[candidate];
if candidate_byte == b'/' {
if candidate + 1 < len && bytes[candidate + 1] == b'*' {
let mut p = candidate + 2;
while p + 1 < len {
let from = p;
let star = match memchr::memchr(b'*', &bytes[from..]) {
Some(off) => from + off,
None => return None, };
if star + 1 < len && bytes[star + 1] == b'/' {
self.position = star + 2;
break;
}
p = star + 1;
}
if self.position <= candidate {
return None;
}
continue;
}
self.position = candidate + 1;
continue;
}
let after = candidate + 1;
if after >= len || !bytes[after].is_ascii_digit() {
self.position = after;
continue;
}
let mut digit_end = after;
while digit_end < len && bytes[digit_end].is_ascii_digit() {
digit_end += 1;
}
let mut probe = digit_end;
while probe < len && bytes[probe].is_ascii_whitespace() {
probe += 1;
}
if probe < len && bytes[probe] == b'=' {
break (candidate, digit_end);
}
self.position = digit_end;
};
let line_content = &bytes[line_start..];
let end_offset = self.find_entity_end(line_content)?;
let line_end = line_start + end_offset + 1;
let id_start = line_start + 1;
let id_end = id_end_validated;
let id = self.parse_u32_fast(id_start, id_end)?;
let eq_search = &self.bytes[id_end..line_end];
let eq_offset = memchr::memchr(b'=', eq_search)?;
let mut type_start = id_end + eq_offset + 1;
while type_start < line_end && self.bytes[type_start].is_ascii_whitespace() {
type_start += 1;
}
let mut type_end = type_start;
while type_end < line_end {
let b = self.bytes[type_end];
if b == b'(' || b.is_ascii_whitespace() {
break;
}
type_end += 1;
}
let type_name = std::str::from_utf8(&self.bytes[type_start..type_end]).unwrap_or("UNKNOWN");
self.position = line_end;
Some((id, type_name, line_start, line_end))
}
#[inline]
fn parse_u32_fast(&self, start: usize, end: usize) -> Option<u32> {
let mut result: u32 = 0;
for i in start..end {
let digit = self.bytes[i].wrapping_sub(b'0');
if digit > 9 {
return None;
}
result = result.wrapping_mul(10).wrapping_add(digit as u32);
}
Some(result)
}
#[inline]
fn find_entity_end(&self, content: &[u8]) -> Option<usize> {
let mut pos = 0;
loop {
pos += memchr::memchr2(b'\'', b';', &content[pos..])?;
if content[pos] == b';' {
return Some(pos);
}
pos += 1;
loop {
pos += memchr::memchr(b'\'', &content[pos..])?;
if content.get(pos + 1) == Some(&b'\'') {
pos += 2;
continue;
}
pos += 1;
break;
}
}
}
pub fn find_by_type(&mut self, target_type: &str) -> Vec<(u32, usize, usize)> {
let mut results = Vec::new();
while let Some((id, type_name, start, end)) = self.next_entity() {
if type_name.eq_ignore_ascii_case(target_type) {
results.push((id, start, end));
}
}
results
}
pub fn count_by_type(&mut self) -> rustc_hash::FxHashMap<String, usize> {
let mut counts = rustc_hash::FxHashMap::default();
while let Some((_, type_name, _, _)) = self.next_entity() {
*counts.entry(type_name.to_string()).or_insert(0) += 1;
}
counts
}
pub fn count(&mut self) -> usize {
let mut n = 0usize;
while self.next_entity().is_some() {
n += 1;
}
n
}
pub fn reset(&mut self) {
self.position = data_section_start(self.bytes);
}
#[inline]
pub fn has_non_null_attribute(&self, start: usize, end: usize, attr_index: usize) -> bool {
let content = &self.bytes[start..end];
let paren_pos = match memchr::memchr(b'(', content) {
Some(p) => p + 1,
None => return false,
};
let mut pos = paren_pos;
let mut current_attr = 0;
let mut depth = 0; let mut in_string = false;
let check_target = |pos: usize, current_attr: usize, depth: usize| -> Option<bool> {
if current_attr == attr_index && depth == 0 {
let mut p = pos;
while p < content.len() && content[p].is_ascii_whitespace() {
p += 1;
}
if p < content.len() {
return Some(content[p] != b'$');
}
return Some(false);
}
None
};
if let Some(result) = check_target(pos, current_attr, depth) {
return result;
}
while pos < content.len() {
let b = content[pos];
if in_string {
if b == b'\'' {
if pos + 1 < content.len() && content[pos + 1] == b'\'' {
pos += 2;
continue;
}
in_string = false;
}
pos += 1;
continue;
}
match b {
b'\'' => {
in_string = true;
pos += 1;
}
b'(' => {
depth += 1;
pos += 1;
}
b')' => {
if depth == 0 {
return false;
}
depth -= 1;
pos += 1;
}
b',' if depth == 0 => {
current_attr += 1;
pos += 1;
while pos < content.len() && content[pos].is_ascii_whitespace() {
pos += 1;
}
if let Some(result) = check_target(pos, current_attr, depth) {
return result;
}
}
_ => {
pos += 1;
}
}
}
false
}
}
pub fn entity_count<T>(content: &T) -> usize
where
T: AsRef<[u8]> + ?Sized,
{
EntityScanner::new(content).count()
}
fn data_section_start(bytes: &[u8]) -> usize {
const MARKER: &[u8] = b"DATA;";
let len = bytes.len();
if len < MARKER.len() {
return 0;
}
let limit = len.min(1 << 18); let mut pos = 0;
let mut in_string = false;
while pos < limit {
let b = bytes[pos];
if in_string {
if b == b'\'' {
if pos + 1 < limit && bytes[pos + 1] == b'\'' {
pos += 2; continue;
}
in_string = false;
}
pos += 1;
continue;
}
if b == b'\'' {
in_string = true;
pos += 1;
continue;
}
if b == b'D' && pos + MARKER.len() <= len && &bytes[pos..pos + MARKER.len()] == MARKER {
return pos + MARKER.len();
}
pos += 1;
}
0
}
#[cfg(test)]
mod tests {
use super::*;
#[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_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));
}
}