use logos::Logos;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Span {
pub start: usize,
pub end: usize,
pub line: u32,
pub column: u32,
}
impl Span {
#[must_use]
pub fn slice<'a>(&self, source: &'a str) -> &'a str {
&source[self.start..self.end]
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Token {
pub kind: TokenKind,
pub span: Span,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LexErrorKind {
UnexpectedCharacter,
UnterminatedString,
InvalidNumber,
InvalidBinary,
}
impl LexErrorKind {
fn as_message(&self) -> &'static str {
match self {
Self::UnexpectedCharacter => "unexpected character",
Self::UnterminatedString => "unterminated string literal",
Self::InvalidNumber => "numeric literal out of range",
Self::InvalidBinary => "invalid binary literal",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LexError {
pub kind: LexErrorKind,
pub span: Span,
pub snippet: String,
}
impl std::fmt::Display for LexError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"lex error at line {}, column {}: {} (snippet: {:?})",
self.span.line,
self.span.column,
self.kind.as_message(),
self.snippet,
)
}
}
impl std::error::Error for LexError {}
pub fn tokenize(source: &str) -> Result<Vec<Token>, LexError> {
Lexer::new(source).collect()
}
pub struct Lexer<'src> {
source: &'src str,
inner: logos::Lexer<'src, TokenKind>,
prev_end: usize,
line: u32,
column: u32,
peeked: Option<Result<Token, LexError>>,
}
impl<'src> Lexer<'src> {
#[must_use]
pub fn new(source: &'src str) -> Self {
Self {
source,
inner: TokenKind::lexer(source),
prev_end: 0,
line: 1,
column: 1,
peeked: None,
}
}
pub fn peek(&mut self) -> Option<&Result<Token, LexError>> {
if self.peeked.is_none() {
self.peeked = self.next_from_inner();
}
self.peeked.as_ref()
}
fn advance_over(&mut self, start: usize, end: usize) {
if start >= end {
return;
}
for ch in self.source[start..end].chars() {
if ch == '\n' {
self.line += 1;
self.column = 1;
} else {
self.column += 1;
}
}
}
fn next_from_inner(&mut self) -> Option<Result<Token, LexError>> {
let kind_result = self.inner.next()?;
let range = self.inner.span();
self.advance_over(self.prev_end, range.start);
let token_line = self.line;
let token_column = self.column;
self.advance_over(range.start, range.end);
self.prev_end = range.end;
let span = Span {
start: range.start,
end: range.end,
line: token_line,
column: token_column,
};
let Ok(kind) = kind_result else {
let raw = span.slice(self.source);
let snippet = truncate_to_chars(raw, 40);
let mut cs = raw.chars();
let kind = match cs.next() {
Some(c) if c.is_ascii_digit() => LexErrorKind::InvalidNumber,
Some('#' | '+' | '-') if cs.next().is_some_and(|c| c.is_ascii_digit()) => {
LexErrorKind::InvalidNumber
}
_ => LexErrorKind::UnexpectedCharacter,
};
return Some(Err(LexError {
kind,
span,
snippet,
}));
};
Some(Ok(Token { kind, span }))
}
}
impl Iterator for Lexer<'_> {
type Item = Result<Token, LexError>;
fn next(&mut self) -> Option<Self::Item> {
if let Some(buffered) = self.peeked.take() {
return Some(buffered);
}
self.next_from_inner()
}
}
fn truncate_to_chars(s: &str, max_chars: usize) -> String {
match s.char_indices().nth(max_chars) {
Some((byte_idx, _)) => s[..byte_idx].to_string(),
None => s.to_string(),
}
}
#[derive(Logos, Debug, Clone, PartialEq)]
#[logos(skip r"[ \t\r\n]+")]
#[logos(skip r"/\*([^*]|\*+[^*/])*\*+/")]
pub enum TokenKind {
#[regex(r"(?i)ISO-10303-21", priority = 10)]
IsoStart,
#[regex(r"(?i)END-ISO-10303-21", priority = 10)]
IsoEnd,
#[regex(r"(?i)HEADER", priority = 10)]
Header,
#[regex(r"(?i)DATA", priority = 10)]
Data,
#[regex(r"(?i)ENDSEC", priority = 10)]
EndSec,
#[token("(")]
LParen,
#[token(")")]
RParen,
#[token(",")]
Comma,
#[token(";")]
Semicolon,
#[token("=")]
Equals,
#[token("*")]
Asterisk,
#[token("$")]
Dollar,
#[regex(
r"[+-]?[0-9]+\.[0-9]*([Ee][+-]?[0-9]+)?",
// Reject non-finite (e.g. `1.E999` -> inf): the callback returns None, so
// the lexer surfaces it as an InvalidNumber error rather than letting inf
// into the model.
|lex| lex.slice().parse::<f64>().ok().filter(|f| f.is_finite())
)]
Real(f64),
#[regex(
r"[+-]?[0-9]+",
|lex| lex.slice().parse::<i64>().ok()
)]
Integer(i64),
#[regex(
r"'([^']|'')*'",
|lex| {
let s = lex.slice();
s[1..s.len() - 1].replace("''", "'")
}
)]
String(String),
#[regex(
r"#[0-9]+",
|lex| lex.slice()[1..].parse::<u64>().ok()
)]
EntityRef(u64),
#[regex(
r"\.[A-Za-z_][A-Za-z0-9_]*\.",
|lex| {
let s = lex.slice();
s[1..s.len() - 1].to_string()
}
)]
Enum(String),
#[regex(
r#""[0-3][0-9A-Fa-f]*""#,
|lex| {
let s = lex.slice();
s[1..s.len() - 1].to_string()
}
)]
Binary(String),
#[regex(
r"[A-Za-z_][A-Za-z0-9_]*",
|lex| lex.slice().to_string()
)]
Keyword(String),
#[regex(r"<[^>]+>", |lex| lex.slice().to_string())]
AnchorRef(String),
}
#[cfg(test)]
mod tests {
use super::*;
fn first_token(src: &str) -> TokenKind {
TokenKind::lexer(src)
.next()
.expect("expected at least one token")
.expect("expected Ok token")
}
#[test]
fn lex_punctuation() {
let mut lex = TokenKind::lexer("(),;=*$");
assert_eq!(lex.next(), Some(Ok(TokenKind::LParen)));
assert_eq!(lex.next(), Some(Ok(TokenKind::RParen)));
assert_eq!(lex.next(), Some(Ok(TokenKind::Comma)));
assert_eq!(lex.next(), Some(Ok(TokenKind::Semicolon)));
assert_eq!(lex.next(), Some(Ok(TokenKind::Equals)));
assert_eq!(lex.next(), Some(Ok(TokenKind::Asterisk)));
assert_eq!(lex.next(), Some(Ok(TokenKind::Dollar)));
assert_eq!(lex.next(), None);
}
#[test]
fn lex_integer_basic() {
assert_eq!(first_token("42"), TokenKind::Integer(42));
}
#[test]
fn lex_integer_zero() {
assert_eq!(first_token("0"), TokenKind::Integer(0));
}
#[test]
fn lex_integer_signed() {
assert_eq!(first_token("+7"), TokenKind::Integer(7));
assert_eq!(first_token("-13"), TokenKind::Integer(-13));
}
#[test]
fn lex_real_basic() {
assert_eq!(first_token("1.23"), TokenKind::Real(1.23));
}
#[test]
fn lex_real_trailing_dot() {
assert_eq!(first_token("0."), TokenKind::Real(0.0));
assert_eq!(first_token("100."), TokenKind::Real(100.0));
}
#[test]
fn lex_real_exponent() {
assert_eq!(first_token("1.E-07"), TokenKind::Real(1e-7));
assert_eq!(first_token("1.23e5"), TokenKind::Real(1.23e5));
}
#[test]
fn lex_real_signed_exponent() {
assert_eq!(first_token("-9.80E+02"), TokenKind::Real(-9.80e2));
}
#[test]
fn lex_real_wins_over_integer() {
let mut lex = TokenKind::lexer("1.23");
assert_eq!(lex.next(), Some(Ok(TokenKind::Real(1.23))));
assert_eq!(lex.next(), None);
}
#[test]
fn lex_integer_when_no_dot() {
let mut lex = TokenKind::lexer("1");
assert_eq!(lex.next(), Some(Ok(TokenKind::Integer(1))));
assert_eq!(lex.next(), None);
}
#[test]
fn lex_string_empty() {
assert_eq!(first_token("''"), TokenKind::String(String::new()));
}
#[test]
fn lex_string_simple() {
assert_eq!(first_token("'abc'"), TokenKind::String("abc".into()));
}
#[test]
fn lex_string_escaped_quote() {
assert_eq!(first_token("'a''b'"), TokenKind::String("a'b".into()));
}
#[test]
fn lex_string_only_escaped_quote() {
assert_eq!(first_token("''''"), TokenKind::String("'".into()));
}
#[test]
fn lex_string_with_newline() {
assert_eq!(
first_token("'line1\nline2'"),
TokenKind::String("line1\nline2".into())
);
}
#[test]
fn lex_string_multibyte_korean() {
assert_eq!(first_token("'한글'"), TokenKind::String("한글".into()));
}
#[test]
fn lex_string_multibyte_japanese() {
assert_eq!(first_token("'日本語'"), TokenKind::String("日本語".into()));
}
#[test]
fn lex_anchor_ref_simple() {
assert_eq!(
first_token("<TestAnchor>"),
TokenKind::AnchorRef("<TestAnchor>".into())
);
}
#[test]
fn lex_anchor_ref_with_url() {
assert_eq!(
first_token("<testAnchorAndData.stp#TestAnchor>"),
TokenKind::AnchorRef("<testAnchorAndData.stp#TestAnchor>".into())
);
}
#[test]
fn lex_entity_ref_small() {
assert_eq!(first_token("#1"), TokenKind::EntityRef(1));
}
#[test]
fn lex_entity_ref_large() {
assert_eq!(first_token("#1234567"), TokenKind::EntityRef(1_234_567));
}
#[test]
fn lex_enum_bool_true() {
assert_eq!(first_token(".T."), TokenKind::Enum("T".into()));
}
#[test]
fn lex_enum_unit() {
assert_eq!(first_token(".MILLI."), TokenKind::Enum("MILLI".into()));
}
#[test]
fn lex_binary_zero() {
assert_eq!(first_token("\"0\""), TokenKind::Binary("0".into()));
}
#[test]
fn lex_binary_hex() {
assert_eq!(first_token("\"3FFA\""), TokenKind::Binary("3FFA".into()));
}
#[test]
fn lex_keyword_simple() {
assert_eq!(
first_token("CARTESIAN_POINT"),
TokenKind::Keyword("CARTESIAN_POINT".into())
);
}
#[test]
fn lex_keyword_leading_underscore() {
assert_eq!(first_token("_x1"), TokenKind::Keyword("_x1".into()));
}
#[test]
fn lex_keyword_mixed_case() {
assert_eq!(first_token("PlAnE"), TokenKind::Keyword("PlAnE".into()));
}
#[test]
fn lex_section_iso_start_upper() {
assert_eq!(first_token("ISO-10303-21"), TokenKind::IsoStart);
}
#[test]
fn lex_section_iso_start_lower() {
assert_eq!(first_token("iso-10303-21"), TokenKind::IsoStart);
}
#[test]
fn lex_section_iso_end() {
assert_eq!(first_token("END-ISO-10303-21"), TokenKind::IsoEnd);
}
#[test]
fn lex_section_header_data_endsec() {
assert_eq!(first_token("HEADER"), TokenKind::Header);
assert_eq!(first_token("DATA"), TokenKind::Data);
assert_eq!(first_token("ENDSEC"), TokenKind::EndSec);
}
#[test]
fn lex_section_case_insensitive() {
assert_eq!(first_token("header"), TokenKind::Header);
assert_eq!(first_token("Data"), TokenKind::Data);
assert_eq!(first_token("EndSec"), TokenKind::EndSec);
}
#[test]
fn section_keyword_priority_wins_over_keyword() {
assert_ne!(first_token("HEADER"), TokenKind::Keyword("HEADER".into()));
}
#[test]
fn lex_whitespace_skipped_between_tokens() {
let mut lex = TokenKind::lexer("HEADER ;\n\tDATA");
assert_eq!(lex.next(), Some(Ok(TokenKind::Header)));
assert_eq!(lex.next(), Some(Ok(TokenKind::Semicolon)));
assert_eq!(lex.next(), Some(Ok(TokenKind::Data)));
assert_eq!(lex.next(), None);
}
#[test]
fn lex_comment_skipped_between_tokens() {
let mut lex = TokenKind::lexer("1 /* ignored */ 2");
assert_eq!(lex.next(), Some(Ok(TokenKind::Integer(1))));
assert_eq!(lex.next(), Some(Ok(TokenKind::Integer(2))));
assert_eq!(lex.next(), None);
}
#[test]
fn lex_multiline_comment_skipped() {
let mut lex = TokenKind::lexer("1/* line1\nline2 */2");
assert_eq!(lex.next(), Some(Ok(TokenKind::Integer(1))));
assert_eq!(lex.next(), Some(Ok(TokenKind::Integer(2))));
assert_eq!(lex.next(), None);
}
#[test]
fn lex_minimal_entity_line() {
let mut lex = TokenKind::lexer("#1=LINE('',#2,#3);");
assert_eq!(lex.next(), Some(Ok(TokenKind::EntityRef(1))));
assert_eq!(lex.next(), Some(Ok(TokenKind::Equals)));
assert_eq!(lex.next(), Some(Ok(TokenKind::Keyword("LINE".into()))));
assert_eq!(lex.next(), Some(Ok(TokenKind::LParen)));
assert_eq!(lex.next(), Some(Ok(TokenKind::String(String::new()))));
assert_eq!(lex.next(), Some(Ok(TokenKind::Comma)));
assert_eq!(lex.next(), Some(Ok(TokenKind::EntityRef(2))));
assert_eq!(lex.next(), Some(Ok(TokenKind::Comma)));
assert_eq!(lex.next(), Some(Ok(TokenKind::EntityRef(3))));
assert_eq!(lex.next(), Some(Ok(TokenKind::RParen)));
assert_eq!(lex.next(), Some(Ok(TokenKind::Semicolon)));
assert_eq!(lex.next(), None);
}
fn collect(src: &str) -> Vec<Token> {
Lexer::new(src)
.collect::<Result<Vec<_>, _>>()
.expect("expected all tokens to lex successfully")
}
#[test]
fn span_tracks_single_token() {
let tokens = collect("HEADER");
assert_eq!(tokens.len(), 1);
assert_eq!(tokens[0].kind, TokenKind::Header);
assert_eq!(tokens[0].span.start, 0);
assert_eq!(tokens[0].span.end, 6);
assert_eq!(tokens[0].span.line, 1);
assert_eq!(tokens[0].span.column, 1);
}
#[test]
fn span_tracks_columns_on_same_line() {
let tokens = collect("a b");
assert_eq!(tokens[0].span.column, 1);
assert_eq!(tokens[1].span.column, 3);
assert_eq!(tokens[0].span.line, 1);
assert_eq!(tokens[1].span.line, 1);
}
#[test]
fn span_tracks_line_after_newline() {
let tokens = collect("a\nb");
assert_eq!(tokens[0].span.line, 1);
assert_eq!(tokens[0].span.column, 1);
assert_eq!(tokens[1].span.line, 2);
assert_eq!(tokens[1].span.column, 1);
}
#[test]
fn span_tracks_multiple_newlines() {
let tokens = collect("a\n\n\nb");
assert_eq!(tokens[1].span.line, 4);
assert_eq!(tokens[1].span.column, 1);
}
#[test]
fn span_advances_past_crlf() {
let tokens = collect("a\r\nb");
assert_eq!(tokens[1].span.line, 2);
assert_eq!(tokens[1].span.column, 1);
}
#[test]
fn span_tracks_column_after_multibyte_char() {
let tokens = collect("'한글' a");
assert_eq!(tokens.len(), 2);
assert!(matches!(tokens[0].kind, TokenKind::String(_)));
assert_eq!(tokens[1].span.column, 6); assert_eq!(tokens[1].span.line, 1);
}
#[test]
fn span_advances_through_multiline_string() {
let tokens = collect("'line1\nline2' x");
assert!(matches!(tokens[0].kind, TokenKind::String(_)));
assert_eq!(tokens[1].span.line, 2);
assert_eq!(tokens[1].span.column, 8);
}
#[test]
fn span_slice_roundtrip_matches_source() {
let source = "#1=LINE('',#2,#3);";
let tokens = collect(source);
for tok in &tokens {
let slice = tok.span.slice(source);
assert_eq!(&source[tok.span.start..tok.span.end], slice);
}
}
#[test]
fn peek_returns_same_token_twice() {
let mut lex = Lexer::new("HEADER ; DATA");
let first = lex.peek().cloned();
let second = lex.peek().cloned();
assert_eq!(first, second);
assert!(matches!(
first,
Some(Ok(Token {
kind: TokenKind::Header,
..
}))
));
}
#[test]
fn peek_then_next_returns_buffered_token() {
let mut lex = Lexer::new("HEADER ; DATA");
let peeked = lex.peek().cloned();
let next = lex.next();
assert_eq!(peeked, next);
}
#[test]
fn peek_does_not_consume_token() {
let mut lex = Lexer::new("HEADER ; DATA");
let _ = lex.peek();
assert!(matches!(
lex.next(),
Some(Ok(Token {
kind: TokenKind::Header,
..
}))
));
assert!(matches!(
lex.next(),
Some(Ok(Token {
kind: TokenKind::Semicolon,
..
}))
));
assert!(matches!(
lex.next(),
Some(Ok(Token {
kind: TokenKind::Data,
..
}))
));
assert_eq!(lex.next(), None);
}
#[test]
fn peek_at_end_returns_none() {
let mut lex = Lexer::new("HEADER");
let _ = lex.next();
assert!(lex.peek().is_none());
assert!(lex.next().is_none());
}
#[test]
fn tokenize_returns_vec_of_tokens() {
let toks = tokenize("HEADER ; ENDSEC ;").expect("should lex cleanly");
let kinds: Vec<_> = toks.iter().map(|t| t.kind.clone()).collect();
assert_eq!(
kinds,
vec![
TokenKind::Header,
TokenKind::Semicolon,
TokenKind::EndSec,
TokenKind::Semicolon,
]
);
}
#[test]
fn tokenize_reports_unexpected_character() {
let err = tokenize("#1 = @INVALID").expect_err("expected a lex error");
assert_eq!(err.kind, LexErrorKind::UnexpectedCharacter);
assert!(err.snippet.contains('@'));
assert_eq!(err.span.line, 1);
}
#[test]
fn tokenize_reports_unterminated_string_as_error() {
let err = tokenize("'abc").expect_err("unterminated string must error");
assert!(matches!(
err.kind,
LexErrorKind::UnexpectedCharacter | LexErrorKind::UnterminatedString
));
}
#[test]
fn lex_error_display_has_line_column_and_snippet() {
let err = tokenize("\n @").expect_err("expected a lex error");
let msg = err.to_string();
assert!(msg.contains("line 2"));
assert!(msg.contains("column 3"));
assert!(msg.contains("unexpected character"));
assert!(msg.contains('@'));
}
#[test]
fn lex_error_implements_std_error() {
fn assert_error<E: std::error::Error>(_: &E) {}
let err = tokenize("@").expect_err("expected a lex error");
assert_error(&err);
}
}