use super::Language;
use super::token::Token;
#[expect(
clippy::too_many_lines,
reason = "one exhaustive match over the whole token superset; splitting it would forfeit the compiler's proof that every token has a per-language reading"
)]
#[expect(
clippy::match_same_arms,
reason = "the arms are grouped by lexical family so each carries the grammar production and citation its reading comes from; collapsing equal bodies would erase which production a refusal belongs to"
)]
pub(super) fn reclassify(
language: Language,
token: &Token,
slice: &str,
src: &str,
start: usize,
) -> Option<Token> {
match token {
Token::Bom => match language {
Language::Adl | Language::Odin => Some(Token::Bom),
Language::Bel | Language::El => None,
},
Token::SymMatches => shared_keyword(
language,
token,
slice,
&["matches", "is_in", "\u{2208}"],
&["matches", "is_in", "\u{2208}"],
),
Token::SymAnd => shared_keyword(
language,
token,
slice,
&["and", "\u{2227}"],
&["and", "AND", "\u{2227}"],
),
Token::SymOr => shared_keyword(
language,
token,
slice,
&["or", "\u{2228}"],
&["or", "OR", "\u{2228}"],
),
Token::SymXor => shared_keyword(
language,
token,
slice,
&["xor"],
&["xor", "XOR", "\u{22BB}"],
),
Token::SymNot => shared_keyword(
language,
token,
slice,
&["not", "~", "\u{223C}", "\u{00AC}", "!"],
&["not", "NOT", "!", "~", "\u{00AC}"],
),
Token::SymImplies => shared_keyword(
language,
token,
slice,
&["implies", "\u{00AE}", "->"],
&["implies", "\u{21D2}", "\u{2192}"],
),
Token::SymForAll => shared_keyword(
language,
token,
slice,
&["for_all", "\u{2200}"],
&["for_all", "\u{2200}"],
),
Token::SymExists => {
shared_keyword(language, token, slice, &["exists"], &["exists", "\u{25A1}"])
}
Token::SymThereExists => shared_keyword(
language,
token,
slice,
&["there_exists", "\u{2203}"],
&["there_exists", "\u{2203}"],
),
Token::SymIn => match language {
Language::Bel => Some(token.clone()),
Language::El => (slice == "in").then(|| token.clone()),
Language::Adl | Language::Odin => demote_word(language, slice),
},
Token::SymIff | Token::SymBrokenBar => match language {
Language::El => Some(token.clone()),
Language::Adl | Language::Odin | Language::Bel => None,
},
Token::SymSelf
| Token::SymResult
| Token::SymCase
| Token::SymChoice
| Token::SymAssert => None,
Token::SymNotMatches
| Token::SymOccurrences
| Token::SymExistence
| Token::SymCardinality
| Token::SymOrdered
| Token::SymUnordered
| Token::SymUnique
| Token::SymUseNode
| Token::SymUseArchetype
| Token::SymAllowArchetype
| Token::SymInclude
| Token::SymExclude
| Token::SymAfter
| Token::SymBefore
| Token::SymClosed => match language {
Language::Adl => Some(token.clone()),
Language::Odin | Language::Bel | Language::El => demote_word(language, slice),
},
Token::SymThen => shared_keyword(language, token, slice, &[], &["then", "THEN"]),
Token::SymInfinity => match language {
Language::Adl | Language::Odin => Some(token.clone()),
Language::Bel | Language::El => demote_word(language, slice),
},
Token::SymTrue => shared_boolean(language, token, slice, &["true", "True"]),
Token::SymFalse => shared_boolean(language, token, slice, &["false", "False"]),
Token::RootIdCode(_) | Token::IdCode(_) | Token::AtCode(_) | Token::AcCode(_) => {
match language {
Language::Adl => Some(token.clone()),
Language::Odin | Language::Bel | Language::El => demote_word(language, slice),
}
}
Token::ArchetypeId(_) | Token::VersionId(_) | Token::Guid(_) => match language {
Language::Adl => Some(token.clone()),
Language::Odin | Language::Bel | Language::El => None,
},
Token::DateTimeConstraintPattern(_)
| Token::DateConstraintPattern(_)
| Token::TimeConstraintPattern(_)
| Token::DurationConstraintPattern(_) => match language {
Language::Adl => Some(token.clone()),
Language::Odin | Language::Bel | Language::El => demote_word(language, slice),
},
Token::VariableWithPath(_) => match language {
Language::Adl | Language::Bel => Some(token.clone()),
Language::Odin | Language::El => None,
},
Token::VariableId(_) => match language {
Language::Adl | Language::Bel | Language::El => Some(token.clone()),
Language::Odin => None,
},
Token::Iso8601DateTime(text) => iso_date_time(language, text),
Token::Iso8601Date(text) => match language {
Language::Adl | Language::Odin | Language::El => Some(token.clone()),
Language::Bel => (text.len() == 10 && !text.contains('?')).then(|| token.clone()),
},
Token::Iso8601Time(text) => match language {
Language::Adl | Language::Odin | Language::El => Some(token.clone()),
Language::Bel => (!text.contains('?')).then(|| token.clone()),
},
Token::Iso8601Duration(_) => Some(token.clone()),
Token::ContainedRegexp(_) | Token::LCurly | Token::RCurly => match language {
Language::Adl | Language::Bel | Language::El => Some(token.clone()),
Language::Odin => None,
},
Token::TermCodeRef(_) => Some(token.clone()),
Token::EmbeddedUri(_) => match language {
Language::Adl | Language::Odin => Some(token.clone()),
Language::Bel | Language::El => None,
},
Token::LocalTermCodeRef(_) => match language {
Language::Odin => Some(token.clone()),
Language::El => is_local_term_code(slice).then(|| token.clone()),
Language::Adl | Language::Bel => None,
},
Token::PlugInBlock(_) => match language {
Language::Odin => Some(token.clone()),
Language::Adl | Language::Bel | Language::El => None,
},
Token::AdlPath(text) => path(language, text).then(|| token.clone()),
Token::Real(_) | Token::Character(_) => Some(token.clone()),
Token::Integer(text) => match language {
Language::Adl | Language::Odin | Language::El => Some(token.clone()),
Language::Bel => (!text.contains(['e', 'E'])).then(|| token.clone()),
},
Token::String(text) => match language {
Language::Adl | Language::Bel | Language::El => Some(token.clone()),
Language::Odin => Some(Token::String(strip_line_leaders(
text,
leader_budget(src, start),
))),
},
Token::AlphaUcId(_) | Token::AlphaLcId(_) => match language {
Language::El => Some(el_word(slice, token)),
Language::Adl | Language::Odin | Language::Bel => Some(token.clone()),
},
Token::AlphaUnderscoreId(_) => match language {
Language::Adl | Language::Odin => Some(token.clone()),
Language::Bel | Language::El => None,
},
Token::LParen
| Token::RParen
| Token::LBracket
| Token::RBracket
| Token::SymComma
| Token::SymSemiColon
| Token::SymEq
| Token::SymLe
| Token::SymGe
| Token::SymGt
| Token::SymLt
| Token::SymIvlSep
| Token::SymDot
| Token::SymIvlDelim
| Token::SymPlus
| Token::SymMinus
| Token::SymSlash => Some(token.clone()),
Token::SymAssignment | Token::SymColon | Token::SymPercent | Token::SymCarat => {
match language {
Language::Adl | Language::Bel => Some(token.clone()),
Language::El => (slice != "::=").then(|| token.clone()),
Language::Odin => None,
}
}
Token::SymAt => Some(token.clone()),
Token::SymNe => match language {
Language::Adl | Language::El => (slice != "<>").then(|| token.clone()),
Language::Bel => Some(token.clone()),
Language::Odin => None,
},
Token::SymListContinue | Token::SymPlusOrMinus => match language {
Language::Adl | Language::Odin | Language::El => Some(token.clone()),
Language::Bel => None,
},
Token::SymStar => match language {
Language::Adl | Language::Bel | Language::El => Some(token.clone()),
Language::Odin => (slice == "*").then(|| token.clone()),
},
}
}
fn shared_keyword(
language: Language,
token: &Token,
slice: &str,
bel_spellings: &[&str],
el_spellings: &[&str],
) -> Option<Token> {
match language {
Language::Adl => Some(token.clone()),
Language::Bel | Language::El => {
let spellings = if language == Language::Bel {
bel_spellings
} else {
el_spellings
};
if spellings.contains(&slice) {
Some(token.clone())
} else {
demote_word(language, slice)
}
}
Language::Odin => demote_word(language, slice),
}
}
fn el_word(slice: &str, token: &Token) -> Token {
match slice {
"Self" => Token::SymSelf,
"Result" => Token::SymResult,
"case" => Token::SymCase,
"choice" => Token::SymChoice,
"assert" => Token::SymAssert,
_ => token.clone(),
}
}
fn is_local_term_code(slice: &str) -> bool {
let Some(body) = slice
.strip_prefix('[')
.and_then(|rest| rest.strip_suffix(']'))
else {
return false;
};
!body.is_empty() && body.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
}
fn shared_boolean(
language: Language,
token: &Token,
slice: &str,
bel_spellings: &[&str],
) -> Option<Token> {
match language {
Language::Adl | Language::Odin | Language::El => Some(token.clone()),
Language::Bel => {
if bel_spellings.contains(&slice) {
Some(token.clone())
} else {
demote_word(language, slice)
}
}
}
}
fn iso_date_time(language: Language, text: &str) -> Option<Token> {
match language {
Language::Adl => {
if text.contains(' ') {
is_date_time_constraint_pattern(text)
.then(|| Token::DateTimeConstraintPattern(text.to_owned()))
} else {
Some(Token::Iso8601DateTime(text.to_owned()))
}
}
Language::Odin => Some(Token::Iso8601DateTime(text.replacen(' ', "T", 1))),
Language::Bel => {
(!text.contains(['?', ' '])).then(|| Token::Iso8601DateTime(text.to_owned()))
}
Language::El => (!text.contains(' ')).then(|| Token::Iso8601DateTime(text.to_owned())),
}
}
fn is_date_time_constraint_pattern(text: &str) -> bool {
let mut rest = text;
let fields: [&[&str]; 6] = [
&["yyyy", "YYYY", "yyy", "YYY"],
&["mm", "MM", "??", "XX", "xx"],
&["dd", "DD", "??", "XX", "xx"],
&["hh", "HH", "??", "XX", "xx"],
&["mm", "MM", "??", "XX", "xx"],
&["ss", "SS", "??", "XX", "xx"],
];
let separators = ['-', '-', ' ', ':', ':'];
let digits = [4usize, 2, 2, 2, 2, 2];
for (index, spellings) in fields.iter().enumerate() {
let width = digits.get(index).copied().unwrap_or(2);
rest = match take_field(rest, spellings, width) {
Some(r) => r,
None => return false,
};
if let Some(&separator) = separators.get(index) {
let accepted: &[char] = if index == 2 {
&['T', ' ']
} else {
&[separator]
};
match rest.strip_prefix(accepted) {
Some(r) => rest = r,
None => return false,
}
}
}
timezone_suffix_is_pattern(rest)
}
fn take_field<'a>(rest: &'a str, spellings: &[&str], width: usize) -> Option<&'a str> {
for spelling in spellings {
if let Some(tail) = rest.strip_prefix(spelling) {
return Some(tail);
}
}
let head = rest.get(..width)?;
if head.bytes().all(|b| b.is_ascii_digit()) {
rest.get(width..)
} else {
None
}
}
fn timezone_suffix_is_pattern(rest: &str) -> bool {
if rest.is_empty() || rest == "Z" {
return true;
}
let Some(tail) = rest
.strip_prefix('+')
.or_else(|| rest.strip_prefix('-'))
.or_else(|| rest.strip_prefix('\u{00B1}'))
else {
return false;
};
let Some(tail) = tail.strip_prefix("hh").or_else(|| tail.strip_prefix("HH")) else {
return false;
};
let tail = tail.strip_prefix(':').unwrap_or(tail);
tail.is_empty() || tail == "mm" || tail == "MM"
}
fn demote_word(language: Language, slice: &str) -> Option<Token> {
let mut chars = slice.chars();
let head = chars.next()?;
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
return None;
}
match head {
'a'..='z' => Some(Token::AlphaLcId(slice.to_owned())),
'A'..='Z' => Some(Token::AlphaUcId(slice.to_owned())),
'_' if !matches!(language, Language::Bel | Language::El) => {
Some(Token::AlphaUnderscoreId(slice.to_owned()))
}
_ => None,
}
}
struct PathShape {
leading_slashes: usize,
segments: usize,
all_heads_lower: bool,
first_has_predicate: bool,
}
fn path(language: Language, text: &str) -> bool {
let Some(shape) = path_shape(text) else {
return false;
};
match language {
Language::Adl => {
shape.all_heads_lower
&& ((shape.leading_slashes == 1 && shape.segments >= 1)
|| (shape.leading_slashes == 0 && shape.segments >= 2))
}
Language::Odin => {
(shape.leading_slashes == 1 && shape.segments >= 1)
|| (shape.leading_slashes == 0 && shape.segments >= 2)
}
Language::Bel => {
shape.all_heads_lower
&& ((shape.leading_slashes >= 1
&& shape.leading_slashes <= 2
&& shape.segments >= 1)
|| (shape.leading_slashes == 0 && shape.segments >= 2)
|| (shape.leading_slashes == 0
&& shape.segments == 1
&& shape.first_has_predicate))
}
Language::El => false,
}
}
fn path_shape(text: &str) -> Option<PathShape> {
let bytes = text.as_bytes();
let mut at = 0usize;
let mut leading_slashes = 0usize;
while bytes.get(at) == Some(&b'/') {
leading_slashes += 1;
at += 1;
}
let mut segments = 0usize;
let mut all_heads_lower = true;
let mut first_has_predicate = false;
loop {
match bytes.get(at) {
Some(head) if head.is_ascii_alphabetic() => {
if !head.is_ascii_lowercase() {
all_heads_lower = false;
}
at += 1;
}
_ => return None,
}
while matches!(bytes.get(at), Some(c) if c.is_ascii_alphanumeric() || *c == b'_') {
at += 1;
}
let mut has_predicate = false;
if bytes.get(at) == Some(&b'[') {
at += 1;
loop {
match bytes.get(at) {
Some(b']') => break,
Some(b'\r' | b'\n') | None => return None,
Some(_) => at += 1,
}
}
at += 1;
has_predicate = true;
}
if segments == 0 {
first_has_predicate = has_predicate;
}
segments += 1;
if bytes.get(at) == Some(&b'/') {
at += 1;
} else {
break;
}
}
(at == bytes.len()).then_some(PathShape {
leading_slashes,
segments,
all_heads_lower,
first_has_predicate,
})
}
fn leader_budget(src: &str, start: usize) -> usize {
let Some(before) = src.get(..start) else {
return 0;
};
let line_start = before.rfind('\n').map_or(0, |index| index + 1);
before
.get(line_start..)
.map_or(0, |indent| indent.chars().count() + 1)
}
fn strip_line_leaders(raw: &str, budget: usize) -> String {
if !raw.contains('\n') {
return raw.to_owned();
}
let mut out = String::with_capacity(raw.len());
for (index, line) in raw.split_inclusive('\n').enumerate() {
if index == 0 {
out.push_str(line);
continue;
}
let kept = line
.char_indices()
.take(budget)
.take_while(|(_, c)| *c == ' ' || *c == '\t')
.last()
.map_or(0, |(i, c)| i + c.len_utf8());
out.push_str(line.get(kept..).unwrap_or(line));
}
out
}