mod reclassify;
mod token;
use logos::Logos;
#[expect(
clippy::expect_used,
reason = "the span comes from lexing this same `src`, so it always slices"
)]
fn span_text(src: &str, span: core::ops::Range<usize>) -> &str {
src.get(span)
.expect("a span produced by lexing this source should slice it")
}
use crate::v1_0::lexer::reclassify::reclassify;
pub use crate::v1_0::lexer::token::Token;
#[derive(Debug, Clone, PartialEq)]
pub struct Spanned {
pub token: Token,
pub span: std::ops::Range<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("unrecognised token {text:?} at byte offset {}", .span.start)]
pub struct LexError {
pub span: std::ops::Range<usize>,
pub text: String,
}
pub fn lex_odin(src: &str) -> Result<Vec<Spanned>, LexError> {
let mut out = Vec::new();
let mut lexer = Token::lexer(src);
while let Some(result) = lexer.next() {
let span = lexer.span();
let text = span_text(src, span.clone());
let Ok(produced) = result else {
let end = stuck_at(src, span.start, span.end);
return Err(LexError {
text: span_text(src, span.start..end).to_owned(),
span: span.start..end,
});
};
match reclassify(&produced, text, src, span.start) {
Some(Token::Bom) => {}
Some(read) => out.push(Spanned { token: read, span }),
None => {
let resumed = narrow(src, span.start, span.end, &mut out)?;
lexer = Token::lexer(src);
lexer.bump(resumed);
}
}
}
retag_odin_value_words_in_key_position(src, &mut out);
Ok(out)
}
fn retag_odin_value_words_in_key_position(src: &str, spanned: &mut [Spanned]) {
let mut index = 0;
while index < spanned.len() {
let is_value_word = matches!(
spanned.get(index).map(|s| &s.token),
Some(Token::SymTrue | Token::SymFalse | Token::SymInfinity)
);
let before_eq = matches!(spanned.get(index + 1).map(|s| &s.token), Some(Token::SymEq));
if is_value_word
&& before_eq
&& let Some(entry) = spanned.get_mut(index)
{
let slice = span_text(src, entry.span.clone());
entry.token = if slice.starts_with(|c: char| c.is_ascii_uppercase()) {
Token::AlphaUcId(slice.to_owned())
} else {
Token::AlphaLcId(slice.to_owned())
};
}
index += 1;
}
}
fn narrow(
src: &str,
start: usize,
limit: usize,
out: &mut Vec<Spanned>,
) -> Result<usize, LexError> {
let mut end = previous_boundary(src, limit, start);
while end > start {
if let Some(produced) = single_token(src, start, end) {
let text = span_text(src, start..end);
if let Some(read) = reclassify(&produced, text, src, start) {
if !matches!(read, Token::Bom) {
out.push(Spanned {
token: read,
span: start..end,
});
}
return Ok(end);
}
}
end = previous_boundary(src, end, start);
}
let end = next_boundary(src, start);
Err(LexError {
span: start..end,
text: span_text(src, start..end).to_owned(),
})
}
fn stuck_at(src: &str, start: usize, union_end: usize) -> usize {
if src.get(start..).is_some_and(|rest| rest.starts_with('?')) {
next_boundary(src, start)
} else {
union_end
}
}
fn single_token(src: &str, start: usize, end: usize) -> Option<Token> {
let text = src.get(start..end)?;
let mut lexer = Token::lexer(text);
let produced = lexer.next()?.ok()?;
(lexer.span().end == text.len()).then_some(produced)
}
fn previous_boundary(src: &str, at: usize, floor: usize) -> usize {
let mut back = at.saturating_sub(1);
while back > floor && !src.is_char_boundary(back) {
back -= 1;
}
back
}
fn next_boundary(src: &str, at: usize) -> usize {
let mut forward = at.saturating_add(1);
while forward < src.len() && !src.is_char_boundary(forward) {
forward += 1;
}
forward.min(src.len())
}
#[cfg(test)]
mod tests {
use super::{Token, lex_odin};
fn odin(src: &str) -> Vec<Token> {
lex_odin(src)
.unwrap_or_else(|e| panic!("lex failed: {e}"))
.into_iter()
.map(|s| s.token)
.collect()
}
#[test]
fn odin_reserves_no_foreign_keyword() {
for word in [
"matches",
"occurrences",
"existence",
"cardinality",
"ordered",
"include",
"then",
"and",
"or",
"not",
"implies",
"for_all",
"there_exists",
"in",
"use_archetype",
] {
assert_eq!(
odin(word),
vec![Token::AlphaLcId(word.to_owned())],
"{word} must be an ODIN identifier"
);
}
assert_eq!(odin("MATCHES"), vec![Token::AlphaUcId("MATCHES".into())]);
}
#[test]
fn odin_has_no_adl_only_token_classes() {
assert_eq!(odin("at0000"), vec![Token::AlphaLcId("at0000".into())]);
assert_eq!(
odin("id1.1"),
vec![
Token::AlphaLcId("id1".into()),
Token::SymDot,
Token::Integer("1".into()),
]
);
assert_eq!(
odin("2.0.5"),
vec![
Token::Real("2.0".into()),
Token::SymDot,
Token::Integer("5".into()),
]
);
assert_eq!(
odin("yyyy-mm-dd"),
vec![
Token::AlphaLcId("yyyy".into()),
Token::SymMinus,
Token::AlphaLcId("mm".into()),
Token::SymMinus,
Token::AlphaLcId("dd".into()),
]
);
for refused in ["$v", "{", ":", "%", "^", "!="] {
assert!(lex_odin(refused).is_err(), "ODIN must refuse {refused:?}");
}
assert_eq!(odin("<>"), vec![Token::SymLt, Token::SymGt]);
assert_eq!(odin("@"), vec![Token::SymAt]);
}
#[test]
fn underscore_initial_words_are_not_1_0_0_tokens() {
assert!(lex_odin("_default").is_err());
assert!(lex_odin("_x = <1>").is_err());
assert_eq!(
odin("some_attr"),
vec![Token::AlphaLcId("some_attr".into())]
);
}
#[test]
fn iso_values_and_partials() {
assert_eq!(
odin("2004-06-01"),
vec![Token::Iso8601Date("2004-06-01".into())]
);
assert_eq!(odin("2004-06"), vec![Token::Iso8601Date("2004-06".into())]);
assert_eq!(
odin("2004-06-??"),
vec![Token::Iso8601Date("2004-06-??".into())]
);
assert_eq!(
odin("2004-06-01T10:30:00"),
vec![Token::Iso8601DateTime("2004-06-01T10:30:00".into())]
);
assert_eq!(
odin("10:30:00"),
vec![Token::Iso8601Time("10:30:00".into())]
);
assert_eq!(odin("P1Y2M"), vec![Token::Iso8601Duration("P1Y2M".into())]);
assert_eq!(odin("PT30M"), vec![Token::Iso8601Duration("PT30M".into())]);
assert_eq!(odin("P0W"), vec![Token::Iso8601Duration("P0W".into())]);
}
#[test]
fn time_fractional_seconds_are_comma_separated() {
assert_eq!(
odin("16:35:04,5"),
vec![Token::Iso8601Time("16:35:04,5".into())]
);
assert_eq!(
odin("16:35:04.5"),
vec![
Token::Iso8601Time("16:35:04".into()),
Token::SymDot,
Token::Integer("5".into()),
]
);
assert_eq!(
odin("2004-06-01T16:35:04,5"),
vec![Token::Iso8601DateTime("2004-06-01T16:35:04,5".into())]
);
}
#[test]
fn duration_fractional_seconds_are_dot_separated() {
assert_eq!(
odin("PT2.5S"),
vec![Token::Iso8601Duration("PT2.5S".into())]
);
assert_ne!(
odin("PT2,5S"),
vec![Token::Iso8601Duration("PT2,5S".into())]
);
}
#[test]
fn term_code_ref_and_embedded_uri() {
assert_eq!(
odin("[ISO_639-1::en]"),
vec![Token::TermCodeRef("[ISO_639-1::en]".into())]
);
assert_eq!(
odin("<http://loinc.org/id/9272-6>"),
vec![Token::EmbeddedUri("<http://loinc.org/id/9272-6>".into())]
);
assert_eq!(
odin("<[ISO_639-1::en]>"),
vec![
Token::SymLt,
Token::TermCodeRef("[ISO_639-1::en]".into()),
Token::SymGt,
]
);
}
#[test]
fn local_term_code_ref_and_keys() {
assert_eq!(
odin("[at0200]"),
vec![Token::LocalTermCodeRef("[at0200]".into())]
);
assert_eq!(
odin("[1]"),
vec![Token::LBracket, Token::Integer("1".into()), Token::RBracket,]
);
}
#[test]
fn interval_symbols_and_infinity() {
assert_eq!(
odin("|>=0.0..<10.0|"),
vec![
Token::SymIvlDelim,
Token::SymGe,
Token::Real("0.0".into()),
Token::SymIvlSep,
Token::SymLt,
Token::Real("10.0".into()),
Token::SymIvlDelim,
]
);
assert_eq!(
odin("|0..infinity|"),
vec![
Token::SymIvlDelim,
Token::Integer("0".into()),
Token::SymIvlSep,
Token::SymInfinity,
Token::SymIvlDelim,
]
);
assert_eq!(
odin("1..5"),
vec![
Token::Integer("1".into()),
Token::SymIvlSep,
Token::Integer("5".into()),
]
);
}
#[test]
fn boolean_words_and_key_position_retag() {
assert_eq!(odin("True"), vec![Token::SymTrue]);
assert_eq!(odin("true"), vec![Token::SymTrue]);
assert_eq!(odin("fAlSe"), vec![Token::SymFalse]);
assert_eq!(
odin("true = <1>"),
vec![
Token::AlphaLcId("true".into()),
Token::SymEq,
Token::SymLt,
Token::Integer("1".into()),
Token::SymGt,
]
);
}
#[test]
fn space_separated_date_time_is_normalised() {
assert_eq!(
odin("2004-11-02 09:31:04+1000"),
vec![Token::Iso8601DateTime("2004-11-02T09:31:04+1000".into())]
);
}
#[test]
fn multi_line_string_leaders_are_stripped() {
let src = " text = <\"first\n second\">";
let stripped = Token::String("\"first\nsecond\"".into());
assert!(odin(src).contains(&stripped));
}
#[test]
fn strings_characters_and_escapes() {
assert_eq!(
odin(r#""a\"x'c\\d""#),
vec![Token::String(r#""a\"x'c\\d""#.into())]
);
for ok in [
r"'\n'", r"'\t'", r"'\r'", r"'\\'", r#"'\"'"#, r"'\''", "'x'", "'ü'",
] {
assert!(lex_odin(ok).is_ok(), "legal character must lex: {ok}");
}
assert!(lex_odin(r#""bad \d escape""#).is_err());
assert!(lex_odin(r"'\q'").is_err());
assert_eq!(
odin("\u{feff}archetype"),
vec![Token::AlphaLcId("archetype".into())]
);
}
#[test]
fn comments_are_skipped() {
assert_eq!(
odin("items -- a trailing comment\nvalue"),
vec![
Token::AlphaLcId("items".into()),
Token::AlphaLcId("value".into())
]
);
}
#[test]
fn plug_in_block_lexes() {
assert_eq!(
odin("(cadl) <# ELEMENT[at0001] #>"),
vec![
Token::LParen,
Token::AlphaLcId("cadl".into()),
Token::RParen,
Token::PlugInBlock("<# ELEMENT[at0001] #>".into()),
]
);
}
#[test]
fn path_segment_heads_are_lower_case() {
assert_eq!(odin("/foo/bar"), vec![Token::AdlPath("/foo/bar".into())]);
assert_ne!(odin("/Foo/bar"), vec![Token::AdlPath("/Foo/bar".into())]);
}
}