use logos::Logos;
use unicode_normalization::UnicodeNormalization as _;
use super::errors::{CalError, CalResult, Span};
pub fn check_bidi(input: &str) -> CalResult<()> {
for (byte_offset, ch) in input.char_indices() {
let cp = ch as u32;
let is_bidi = matches!(cp, 0x202A..=0x202E | 0x2066..=0x2069);
if is_bidi {
return Err(CalError::InvalidUtf8 {
detail: format!("bidi override U+{:04X} is not permitted (spec §3.1)", cp),
span: Some(Span::new(
byte_offset,
byte_offset + ch.len_utf8(),
1,
byte_offset + 1,
)),
});
}
}
Ok(())
}
pub fn nfc_normalize(input: &str) -> String {
input.nfc().collect()
}
pub fn is_destructive_keyword(word: &str) -> bool {
matches!(
word.to_ascii_uppercase().as_str(),
"DELETE"
| "ERASE"
| "DESTROY"
| "TRUNCATE"
| "INSERT"
| "CREATE"
| "WRITE"
| "STORE"
| "KEY"
| "ENCRYPT"
| "DECRYPT"
| "ROTATE"
| "MASTER"
| "DEK"
| "SECRET"
| "POLICY"
| "SEAL"
| "UNSEAL"
| "TOKEN"
| "CONSENT"
| "RESTRICT"
| "SCHEMA"
| "PARTITION"
| "INDEX"
| "MIGRATION"
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SectionBody {
Bare,
Body(String),
}
fn section_body(lex: &mut logos::Lexer<Token>) -> SectionBody {
let rest = lex.remainder();
let bytes = rest.as_bytes();
let mut i = 0;
while i < bytes.len() && matches!(bytes[i], b' ' | b'\t' | b'\r' | b'\n') {
i += 1;
}
if i >= bytes.len() || bytes[i] != b'{' {
return SectionBody::Bare;
}
let body_start = i + 1;
let mut depth = 1usize;
let mut j = body_start;
while j < bytes.len() {
match bytes[j] {
b'{' if bytes.get(j + 1) == Some(&b'{') => j += 2,
b'}' if bytes.get(j + 1) == Some(&b'}') => j += 2,
b'{' => {
depth += 1;
j += 1;
}
b'}' => {
depth -= 1;
if depth == 0 {
break;
}
j += 1;
}
_ => j += 1,
}
}
if depth != 0 {
return SectionBody::Bare;
}
let raw = &rest[body_start..j];
lex.bump(j + 1);
SectionBody::Body(trim_section_text(raw))
}
fn trim_section_text(raw: &str) -> String {
let mut s = raw;
let lead = s.len() - s.trim_start_matches([' ', '\t', '\r']).len();
if let Some(rest) = s[lead..].strip_prefix('\n') {
s = rest;
}
let end_trimmed = s.trim_end_matches([' ', '\t', '\r']);
if let Some(rest) = end_trimmed.strip_suffix('\n') {
s = rest;
}
s.to_string()
}
#[derive(Logos, Debug, Clone, PartialEq)]
#[logos(skip r"([ \t\r\n]+)|(--[^\n]*)")]
pub enum Token {
#[token("CAL", ignore(ascii_case))]
Cal,
#[token("RECALL", ignore(ascii_case))]
Recall,
#[token("ASSEMBLE", ignore(ascii_case))]
Assemble,
#[token("WHERE", ignore(ascii_case))]
Where,
#[token("AND", ignore(ascii_case))]
And,
#[token("OR", ignore(ascii_case))]
Or,
#[token("NOT", ignore(ascii_case))]
Not,
#[token("IN", ignore(ascii_case))]
In,
#[token("BETWEEN", ignore(ascii_case))]
Between,
#[token("LIMIT", ignore(ascii_case))]
Limit,
#[token("OFFSET", ignore(ascii_case))]
Offset,
#[token("ORDER", ignore(ascii_case))]
Order,
#[token("BY", ignore(ascii_case))]
By,
#[token("ASC", ignore(ascii_case))]
Asc,
#[token("DESC", ignore(ascii_case))]
Desc,
#[token("WITH", ignore(ascii_case))]
With,
#[token("EXPLAIN", ignore(ascii_case))]
Explain,
#[token("SCOPE", ignore(ascii_case))]
Scope,
#[token("UNION", ignore(ascii_case))]
Union,
#[token("INTERSECT", ignore(ascii_case))]
Intersect,
#[token("EXCEPT", ignore(ascii_case))]
Except,
#[token("SELECT", ignore(ascii_case))]
Select,
#[token("COUNT", ignore(ascii_case))]
Count,
#[token("FIRST", ignore(ascii_case))]
First,
#[token("GROUP", ignore(ascii_case))]
Group,
#[token("SUBJECTS", ignore(ascii_case))]
Subjects,
#[token("OBJECTS", ignore(ascii_case))]
Objects,
#[token("HASHES", ignore(ascii_case))]
Hashes,
#[token("PROJECT", ignore(ascii_case))]
Project,
#[token("INCLUDE", ignore(ascii_case))]
Include,
#[token("EXCLUDE", ignore(ascii_case))]
Exclude,
#[token("IS", ignore(ascii_case))]
Is,
#[token("NULL", ignore(ascii_case))]
Null,
#[token("TRUE", ignore(ascii_case))]
True,
#[token("FALSE", ignore(ascii_case))]
False,
#[token("EXISTS", ignore(ascii_case))]
Exists,
#[token("HISTORY", ignore(ascii_case))]
History,
#[token("DESCRIBE", ignore(ascii_case))]
Describe,
#[token("BATCH", ignore(ascii_case))]
Batch,
#[token("COALESCE", ignore(ascii_case))]
Coalesce,
#[token("ABOUT", ignore(ascii_case))]
About,
#[token("RECENT", ignore(ascii_case))]
Recent,
#[token("SINCE", ignore(ascii_case))]
Since,
#[token("UNTIL", ignore(ascii_case))]
Until,
#[token("LIKE", ignore(ascii_case))]
Like,
#[token("MY", ignore(ascii_case))]
My,
#[token("CONTRADICTIONS", ignore(ascii_case))]
Contradictions,
#[token("AS", ignore(ascii_case))]
As,
#[token("FOR", ignore(ascii_case))]
For,
#[token("FROM", ignore(ascii_case))]
From,
#[token("BUDGET", ignore(ascii_case))]
Budget,
#[token("PRIORITY", ignore(ascii_case))]
Priority,
#[token("LITERAL", ignore(ascii_case))]
Literal,
#[token("PIN", ignore(ascii_case))]
Pin,
#[token("FORMAT", ignore(ascii_case))]
Format,
#[token("LET", ignore(ascii_case))]
Let,
#[token("THREAD", ignore(ascii_case))]
Thread,
#[token("DIFF", ignore(ascii_case))]
Diff,
#[token("STREAM", ignore(ascii_case))]
Stream,
#[token("TEMPLATE", ignore(ascii_case))]
Template,
#[token("DEFINE", ignore(ascii_case))]
Define,
#[token("DROP", ignore(ascii_case))]
Drop,
#[token("QUERY", ignore(ascii_case))]
Query,
#[token("RUN", ignore(ascii_case))]
Run,
#[token("EXTENDS", ignore(ascii_case))]
Extends,
#[token("HEADER", section_body, ignore(ascii_case))]
Header(SectionBody),
#[token("ELEMENT", section_body, ignore(ascii_case))]
Element(SectionBody),
#[token("ELEMENT_SUMMARY", section_body, ignore(ascii_case))]
ElementSummary(SectionBody),
#[token("ELEMENT_OMIT", section_body, ignore(ascii_case))]
ElementOmit(SectionBody),
#[token("SOURCE_BREAK", section_body, ignore(ascii_case))]
SourceBreak(SectionBody),
#[token("FOOTER", section_body, ignore(ascii_case))]
Footer(SectionBody),
#[token("OF", ignore(ascii_case))]
Of,
#[token("ADD", ignore(ascii_case))]
Add,
#[token("ACCUMULATE", ignore(ascii_case))]
Accumulate,
#[token("SUPERSEDE", ignore(ascii_case))]
Supersede,
#[token("REVERT", ignore(ascii_case))]
Revert,
#[token("REMEMBER", ignore(ascii_case))]
Remember,
#[token("ENTITY", ignore(ascii_case))]
Entity,
#[token("MERGE", ignore(ascii_case))]
Merge,
#[token("RELATED", ignore(ascii_case))]
Related,
#[token("NOVELTY", ignore(ascii_case))]
Novelty,
#[token("RUNS", ignore(ascii_case))]
Runs,
#[token("DERIVED", ignore(ascii_case))]
Derived,
#[token("APPROVE", ignore(ascii_case))]
Approve,
#[token("REJECT", ignore(ascii_case))]
Reject,
#[token("APPLY", ignore(ascii_case))]
Apply,
#[token("ROLLBACK", ignore(ascii_case))]
Rollback,
#[token("GRANT", ignore(ascii_case))]
Grant,
#[token("REVOKE", ignore(ascii_case))]
Revoke,
#[token("SHOW", ignore(ascii_case))]
Show,
#[token("TO", ignore(ascii_case))]
To,
#[token("FORGET", ignore(ascii_case))]
Forget,
#[token("PURGE", ignore(ascii_case))]
Purge,
#[token("REPORT", ignore(ascii_case))]
Report,
#[token("SET", ignore(ascii_case))]
Set,
#[token("REASON", ignore(ascii_case))]
Reason,
#[token("BECAUSE", ignore(ascii_case))]
Because,
#[token("PREFERENCE", ignore(ascii_case))]
Preference,
#[token("KNOWLEDGE", ignore(ascii_case))]
Knowledge,
#[token("PERMISSION", ignore(ascii_case))]
Permission,
#[token("INTERACTION", ignore(ascii_case))]
Interaction,
#[token("AGENCY", ignore(ascii_case))]
Agency,
#[token("LIFECYCLE", ignore(ascii_case))]
Lifecycle,
#[token("OBSERVATION", ignore(ascii_case))]
Observation,
#[token("MARKDOWN", ignore(ascii_case))]
Markdown,
#[token("JSON", ignore(ascii_case))]
Json,
#[token("YAML", ignore(ascii_case))]
Yaml,
#[token("TEXT", ignore(ascii_case))]
Text,
#[token("SML", ignore(ascii_case))]
Sml,
#[token("TOON", ignore(ascii_case))]
Toon,
#[token("TRIPLES", ignore(ascii_case))]
Triples,
#[token("STRUCTURED", ignore(ascii_case))]
Structured,
#[token("READABLE", ignore(ascii_case))]
Readable,
#[token("COMPACT", ignore(ascii_case))]
Compact,
#[token("DATA", ignore(ascii_case))]
Data,
#[token("PROGRESS", ignore(ascii_case))]
Progress,
#[token("CHUNKS", ignore(ascii_case))]
Chunks,
#[token("ALL", ignore(ascii_case))]
All,
#[token("CHUNK_SIZE", ignore(ascii_case))]
ChunkSize,
#[token("SUPERSEDED", ignore(ascii_case))]
Superseded,
#[token("SCORE_BREAKDOWN", ignore(ascii_case))]
ScoreBreakdown,
#[token("EXPLANATION", ignore(ascii_case))]
Explanation,
#[token("PROVENANCE", ignore(ascii_case))]
Provenance,
#[token("CONTRADICTION_DETECTION", ignore(ascii_case))]
ContradictionDetection,
#[token("DIVERSITY", ignore(ascii_case))]
Diversity,
#[token("DEDUP", ignore(ascii_case))]
Dedup,
#[token("PROGRESSIVE_DISCLOSURE", ignore(ascii_case))]
ProgressiveDisclosure,
#[token("CONSISTENCY", ignore(ascii_case))]
Consistency,
#[token("LOCALE", ignore(ascii_case))]
Locale,
#[token("CACHE", ignore(ascii_case))]
Cache,
#[token("TTL", ignore(ascii_case))]
Ttl,
#[token("RERANK", ignore(ascii_case))]
Rerank,
#[token("LLM_RERANK", ignore(ascii_case))]
LlmRerank,
#[token("QUERY_EXPANSION", ignore(ascii_case))]
QueryExpansion,
#[token("QUERY_DECOMPOSE", ignore(ascii_case))]
QueryDecompose,
#[token("HYDE", ignore(ascii_case))]
Hyde,
#[token("CONFLICT_RESOLUTION", ignore(ascii_case))]
ConflictResolution,
#[token("INCLUDE_SOURCES", ignore(ascii_case))]
IncludeSources,
#[token("ANNOTATE_RELATIVE_TIME", ignore(ascii_case))]
AnnotateRelativeTime,
#[token("RECENCY_WEIGHT", ignore(ascii_case))]
RecencyWeight,
#[token("MIN_SCORE", ignore(ascii_case))]
MinScore,
#[token("MULTI_HOP", ignore(ascii_case))]
MultiHop,
#[token("SESSION_AFFINITY", ignore(ascii_case))]
SessionAffinity,
#[token("SUBJECT_AFFINITY", ignore(ascii_case))]
SubjectAffinity,
#[token("SESSION_COVERAGE", ignore(ascii_case))]
SessionCoverage,
#[token("MAX_NAMESPACES", ignore(ascii_case))]
MaxNamespaces,
#[token("EXHAUSTIVE", ignore(ascii_case))]
Exhaustive,
#[token("SESSION_CENSUS", ignore(ascii_case))]
SessionCensus,
#[token("AGGREGATION_INTENT", ignore(ascii_case))]
AggregationIntent,
#[token("PREFERENCE_ENRICHMENT", ignore(ascii_case))]
PreferenceEnrichment,
#[token("EXTRACT_EVENT_DATE", ignore(ascii_case))]
ExtractEventDate,
#[token("AUTO_RELATE", ignore(ascii_case))]
AutoRelate,
#[token("EXTRACT_MEMORIES", ignore(ascii_case))]
ExtractMemories,
#[token("SYNC", ignore(ascii_case))]
SyncOption,
#[token("VARS", ignore(ascii_case))]
Vars,
#[token("ON", ignore(ascii_case))]
On,
#[token("WHEN", ignore(ascii_case))]
When,
#[token("BIND", ignore(ascii_case))]
Bind,
#[token("->")]
Arrow,
#[token("!=")]
NotEq,
#[token(">=")]
Gte,
#[token("<=")]
Lte,
#[token(">")]
Gt,
#[token("<")]
Lt,
#[token("=")]
Eq,
#[token("*")]
Asterisk,
#[token("(")]
LParen,
#[token(")")]
RParen,
#[token("[")]
LBracket,
#[token("]")]
RBracket,
#[token("{")]
LBrace,
#[token("}")]
RBrace,
#[token(",")]
Comma,
#[token(";")]
Semicolon,
#[token("|")]
Pipe,
#[token("/")]
Slash,
#[token(":")]
Colon,
#[token(".")]
Dot,
#[token("$")]
Dollar,
#[token("#")]
Hash,
#[regex("(?i:sha256:)[0-9a-fA-F]{8,64}", |lex| {
// Find the colon (the prefix is 6 chars "sha256")
let s = lex.slice();
let colon_pos = s.find(':').unwrap_or(6);
s[colon_pos + 1..].to_ascii_lowercase()
})]
HashLiteral(String),
#[regex(r"\$[a-zA-Z_][a-zA-Z0-9_]*", |lex| lex.slice()[1..].to_string())]
Parameter(String),
#[regex(r#""([^"\\]|\\.)*""#, |lex| {
let slice = lex.slice();
let inner = &slice[1..slice.len() - 1];
let mut out = String::with_capacity(inner.len());
let mut chars = inner.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('"') => out.push('"'),
Some('\\') => out.push('\\'),
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some('r') => out.push('\r'),
Some(other) => { out.push('\\'); out.push(other); }
None => out.push('\\'),
}
} else {
out.push(c);
}
}
out
})]
StringLiteral(String),
#[regex(r#""([^"\\]|\\.)*"#)]
UnterminatedString,
#[regex(r"-?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?", |lex| lex.slice().parse::<f64>().ok())]
NumberLiteral(f64),
#[regex("[a-zA-Z_][a-zA-Z0-9_]*", |lex| lex.slice().to_string())]
Ident(String),
}
impl Token {
pub fn description(&self) -> String {
match self {
Token::Cal => "CAL".into(),
Token::Recall => "RECALL".into(),
Token::Assemble => "ASSEMBLE".into(),
Token::Literal => "LITERAL".into(),
Token::Pin => "PIN".into(),
Token::Where => "WHERE".into(),
Token::And => "AND".into(),
Token::Or => "OR".into(),
Token::Not => "NOT".into(),
Token::In => "IN".into(),
Token::Between => "BETWEEN".into(),
Token::Limit => "LIMIT".into(),
Token::Offset => "OFFSET".into(),
Token::Order => "ORDER".into(),
Token::By => "BY".into(),
Token::Asc => "ASC".into(),
Token::Desc => "DESC".into(),
Token::With => "WITH".into(),
Token::Explain => "EXPLAIN".into(),
Token::Scope => "SCOPE".into(),
Token::Union => "UNION".into(),
Token::Intersect => "INTERSECT".into(),
Token::Except => "EXCEPT".into(),
Token::Select => "SELECT".into(),
Token::Count => "COUNT".into(),
Token::First => "FIRST".into(),
Token::Group => "GROUP".into(),
Token::Subjects => "SUBJECTS".into(),
Token::Objects => "OBJECTS".into(),
Token::Hashes => "HASHES".into(),
Token::Project => "PROJECT".into(),
Token::Include => "INCLUDE".into(),
Token::Exclude => "EXCLUDE".into(),
Token::Is => "IS".into(),
Token::Null => "NULL".into(),
Token::True => "TRUE".into(),
Token::False => "FALSE".into(),
Token::Exists => "EXISTS".into(),
Token::History => "HISTORY".into(),
Token::Describe => "DESCRIBE".into(),
Token::Batch => "BATCH".into(),
Token::Coalesce => "COALESCE".into(),
Token::About => "ABOUT".into(),
Token::Recent => "RECENT".into(),
Token::Since => "SINCE".into(),
Token::Until => "UNTIL".into(),
Token::Like => "LIKE".into(),
Token::My => "MY".into(),
Token::Contradictions => "CONTRADICTIONS".into(),
Token::As => "AS".into(),
Token::For => "FOR".into(),
Token::From => "FROM".into(),
Token::Budget => "BUDGET".into(),
Token::Priority => "PRIORITY".into(),
Token::Format => "FORMAT".into(),
Token::Let => "LET".into(),
Token::Thread => "THREAD".into(),
Token::Diff => "DIFF".into(),
Token::Stream => "STREAM".into(),
Token::Template => "TEMPLATE".into(),
Token::Define => "DEFINE".into(),
Token::Drop => "DROP".into(),
Token::Query => "QUERY".into(),
Token::Run => "RUN".into(),
Token::Extends => "EXTENDS".into(),
Token::Header(_) => "HEADER".into(),
Token::Element(_) => "ELEMENT".into(),
Token::ElementSummary(_) => "ELEMENT_SUMMARY".into(),
Token::ElementOmit(_) => "ELEMENT_OMIT".into(),
Token::SourceBreak(_) => "SOURCE_BREAK".into(),
Token::Footer(_) => "FOOTER".into(),
Token::Of => "OF".into(),
Token::On => "ON".into(),
Token::When => "WHEN".into(),
Token::Bind => "BIND".into(),
Token::Arrow => "->".into(),
Token::Asterisk => "*".into(),
Token::Add => "ADD".into(),
Token::Accumulate => "ACCUMULATE".into(),
Token::Supersede => "SUPERSEDE".into(),
Token::Revert => "REVERT".into(),
Token::Grant => "GRANT".into(),
Token::Revoke => "REVOKE".into(),
Token::Show => "SHOW".into(),
Token::To => "TO".into(),
Token::Remember => "REMEMBER".into(),
Token::Entity => "ENTITY".into(),
Token::Merge => "MERGE".into(),
Token::Related => "RELATED".into(),
Token::Novelty => "NOVELTY".into(),
Token::Runs => "RUNS".into(),
Token::Derived => "DERIVED".into(),
Token::Approve => "APPROVE".into(),
Token::Reject => "REJECT".into(),
Token::Apply => "APPLY".into(),
Token::Rollback => "ROLLBACK".into(),
Token::Forget => "FORGET".into(),
Token::Purge => "PURGE".into(),
Token::Report => "REPORT".into(),
Token::Set => "SET".into(),
Token::Reason => "REASON".into(),
Token::Because => "BECAUSE".into(),
Token::Preference => "PREFERENCE".into(),
Token::Knowledge => "KNOWLEDGE".into(),
Token::Permission => "PERMISSION".into(),
Token::Interaction => "INTERACTION".into(),
Token::Agency => "AGENCY".into(),
Token::Lifecycle => "LIFECYCLE".into(),
Token::Observation => "OBSERVATION".into(),
Token::Markdown => "MARKDOWN".into(),
Token::Json => "JSON".into(),
Token::Yaml => "YAML".into(),
Token::Text => "TEXT".into(),
Token::Sml => "SML".into(),
Token::Toon => "TOON".into(),
Token::Triples => "TRIPLES".into(),
Token::Structured => "STRUCTURED".into(),
Token::Readable => "READABLE".into(),
Token::Compact => "COMPACT".into(),
Token::Data => "DATA".into(),
Token::Progress => "PROGRESS".into(),
Token::Chunks => "CHUNKS".into(),
Token::All => "ALL".into(),
Token::ChunkSize => "CHUNK_SIZE".into(),
Token::Superseded => "SUPERSEDED".into(),
Token::ScoreBreakdown => "SCORE_BREAKDOWN".into(),
Token::Explanation => "EXPLANATION".into(),
Token::Provenance => "PROVENANCE".into(),
Token::ContradictionDetection => "CONTRADICTION_DETECTION".into(),
Token::Diversity => "DIVERSITY".into(),
Token::Dedup => "DEDUP".into(),
Token::Rerank => "RERANK".into(),
Token::LlmRerank => "LLM_RERANK".into(),
Token::QueryExpansion => "QUERY_EXPANSION".into(),
Token::QueryDecompose => "QUERY_DECOMPOSE".into(),
Token::Hyde => "HYDE".into(),
Token::ConflictResolution => "CONFLICT_RESOLUTION".into(),
Token::IncludeSources => "INCLUDE_SOURCES".into(),
Token::AnnotateRelativeTime => "ANNOTATE_RELATIVE_TIME".into(),
Token::RecencyWeight => "RECENCY_WEIGHT".into(),
Token::MinScore => "MIN_SCORE".into(),
Token::MultiHop => "MULTI_HOP".into(),
Token::SessionAffinity => "SESSION_AFFINITY".into(),
Token::SubjectAffinity => "SUBJECT_AFFINITY".into(),
Token::SessionCoverage => "SESSION_COVERAGE".into(),
Token::MaxNamespaces => "MAX_NAMESPACES".into(),
Token::Exhaustive => "EXHAUSTIVE".into(),
Token::SessionCensus => "SESSION_CENSUS".into(),
Token::AggregationIntent => "AGGREGATION_INTENT".into(),
Token::PreferenceEnrichment => "PREFERENCE_ENRICHMENT".into(),
Token::ExtractEventDate => "EXTRACT_EVENT_DATE".into(),
Token::AutoRelate => "AUTO_RELATE".into(),
Token::ExtractMemories => "EXTRACT_MEMORIES".into(),
Token::SyncOption => "SYNC".into(),
Token::Vars => "VARS".into(),
Token::NotEq => "!=".into(),
Token::Gte => ">=".into(),
Token::Lte => "<=".into(),
Token::Gt => ">".into(),
Token::Lt => "<".into(),
Token::Eq => "=".into(),
Token::LParen => "(".into(),
Token::RParen => ")".into(),
Token::LBracket => "[".into(),
Token::RBracket => "]".into(),
Token::LBrace => "{".into(),
Token::RBrace => "}".into(),
Token::Comma => ",".into(),
Token::Semicolon => ";".into(),
Token::Pipe => "|".into(),
Token::Slash => "/".into(),
Token::Colon => ":".into(),
Token::Dot => ".".into(),
Token::Dollar => "$".into(),
Token::Hash => "#".into(),
Token::HashLiteral(h) => format!("sha256:{}", h),
Token::Parameter(n) => format!("${}", n),
Token::StringLiteral(s) => format!("\"{}\"", s),
Token::UnterminatedString => "unterminated string".into(),
Token::NumberLiteral(n) => n.to_string(),
Token::Ident(i) => i.clone(),
Token::ProgressiveDisclosure => "PROGRESSIVE_DISCLOSURE".into(),
Token::Consistency => "CONSISTENCY".into(),
Token::Locale => "LOCALE".into(),
Token::Cache => "CACHE".into(),
Token::Ttl => "TTL".into(),
}
}
pub fn is_statement_starter(&self) -> bool {
matches!(
self,
Token::Recall
| Token::Assemble
| Token::Exists
| Token::History
| Token::Explain
| Token::Describe
| Token::Batch
| Token::Coalesce
| Token::Add
| Token::Accumulate
| Token::Supersede
| Token::Revert
| Token::Forget
| Token::Purge
| Token::Report
| Token::Drop
| Token::Run
)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SpannedToken {
pub token: Token,
pub span: Span,
pub text: String,
}
impl SpannedToken {
pub fn new(token: Token, span: Span, text: impl Into<String>) -> Self {
Self {
token,
span,
text: text.into(),
}
}
}
pub struct Lexer {
source: String,
}
impl Lexer {
pub fn new(input: &str) -> CalResult<Self> {
check_bidi(input)?;
let source = nfc_normalize(input);
Ok(Self { source })
}
pub fn run(&self) -> CalResult<Vec<SpannedToken>> {
Self::tokenize_str(&self.source)
}
pub fn tokenize(input: &str) -> CalResult<Vec<SpannedToken>> {
let lexer = Self::new(input)?;
lexer.run()
}
pub fn source(&self) -> &str {
&self.source
}
fn tokenize_str(source: &str) -> CalResult<Vec<SpannedToken>> {
let mut tokens = Vec::new();
let mut line: usize = 1;
let mut line_start: usize = 0;
let mut logos_lex = Token::lexer(source);
while let Some(result) = logos_lex.next() {
let logos_span = logos_lex.span();
let text = logos_lex.slice().to_string();
let slice_base = line_start;
for (idx, byte) in source[slice_base..logos_span.start].bytes().enumerate() {
if byte == b'\n' {
line += 1;
line_start = slice_base + idx + 1;
}
}
let col = logos_span.start - line_start + 1;
let span = Span::new(logos_span.start, logos_span.end, line, col);
match result {
Ok(token) => {
if token == Token::UnterminatedString {
return Err(CalError::UnterminatedString { span: Some(span) });
}
tokens.push(SpannedToken::new(token, span, text));
}
Err(()) => {
return Err(CalError::UnexpectedToken {
expected: "a valid CAL token".into(),
found: format!("{:?}", text),
span: Some(span),
suggestion: None,
});
}
}
}
Ok(tokens)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tok(input: &str) -> Vec<Token> {
Lexer::tokenize(input)
.unwrap()
.into_iter()
.map(|st| st.token)
.collect()
}
#[test]
fn test_recall_keyword() {
let tokens = tok("RECALL");
assert_eq!(tokens, vec![Token::Recall]);
}
#[test]
fn test_where_and_or_keywords() {
let tokens = tok("WHERE AND OR NOT");
assert_eq!(
tokens,
vec![Token::Where, Token::And, Token::Or, Token::Not]
);
}
#[test]
fn test_pipeline_keywords() {
let tokens = tok("SELECT ORDER BY ASC DESC LIMIT OFFSET COUNT FIRST");
assert_eq!(
tokens,
vec![
Token::Select,
Token::Order,
Token::By,
Token::Asc,
Token::Desc,
Token::Limit,
Token::Offset,
Token::Count,
Token::First,
]
);
}
#[test]
fn test_case_insensitive_keywords() {
let tokens = tok("recall Recall RECALL rEcAlL");
assert_eq!(
tokens,
vec![Token::Recall, Token::Recall, Token::Recall, Token::Recall]
);
}
#[test]
fn test_case_insensitive_where() {
let tokens = tok("where WHERE Where");
assert_eq!(tokens, vec![Token::Where, Token::Where, Token::Where]);
}
#[test]
fn test_string_literal_basic() {
let tokens = tok(r#""hello world""#);
assert_eq!(tokens, vec![Token::StringLiteral("hello world".into())]);
}
#[test]
fn test_string_literal_with_escape() {
let tokens = tok(r#""say \"hi\"""#);
assert_eq!(tokens, vec![Token::StringLiteral("say \"hi\"".into())]);
}
#[test]
fn test_string_literal_with_backslash_escape() {
let tokens = tok(r#""path\\file""#);
assert_eq!(tokens, vec![Token::StringLiteral("path\\file".into())]);
}
#[test]
fn test_integer_literal() {
let tokens = tok("42");
assert_eq!(tokens, vec![Token::NumberLiteral(42.0)]);
}
#[test]
#[allow(clippy::approx_constant)]
fn test_float_literal() {
let tokens = tok("3.14");
assert_eq!(tokens, vec![Token::NumberLiteral(3.14)]);
}
#[test]
fn test_negative_number() {
let tokens = tok("-5");
assert_eq!(tokens, vec![Token::NumberLiteral(-5.0)]);
}
#[test]
fn test_hash_literal_lowercase() {
let tokens = tok("sha256:abc123def456");
assert_eq!(tokens, vec![Token::HashLiteral("abc123def456".into())]);
}
#[test]
fn test_hash_literal_uppercase_prefix() {
let tokens = tok("SHA256:ABC123DEF456");
assert_eq!(tokens, vec![Token::HashLiteral("abc123def456".into())]);
}
#[test]
fn test_hash_literal_full_64_chars() {
let digest = "a".repeat(64);
let input = format!("sha256:{}", digest);
let tokens = tok(&input);
assert_eq!(tokens, vec![Token::HashLiteral(digest)]);
}
#[test]
fn test_parameter_basic() {
let tokens = tok("$name");
assert_eq!(tokens, vec![Token::Parameter("name".into())]);
}
#[test]
fn test_parameter_with_underscore() {
let tokens = tok("$user_id");
assert_eq!(tokens, vec![Token::Parameter("user_id".into())]);
}
#[test]
fn test_comparison_operators() {
let tokens = tok("= != >= <= > <");
assert_eq!(
tokens,
vec![
Token::Eq,
Token::NotEq,
Token::Gte,
Token::Lte,
Token::Gt,
Token::Lt,
]
);
}
#[test]
fn test_line_comment_ignored() {
let tokens = tok("RECALL -- this is a comment\nfacts");
assert_eq!(tokens, vec![Token::Recall, Token::Ident("facts".into())]);
}
#[test]
fn test_comment_at_end_of_input() {
let tokens = tok("RECALL -- end comment");
assert_eq!(tokens, vec![Token::Recall]);
}
#[test]
fn test_bidi_ltr_embedding_rejected() {
let input = "RECALL \u{202A}facts";
let err = Lexer::tokenize(input).unwrap_err();
assert!(err.to_string().contains("bidi override"));
}
#[test]
fn test_bidi_rtl_override_rejected() {
let input = "RECALL \u{202E}facts";
let err = Lexer::tokenize(input).unwrap_err();
assert!(err.to_string().contains("bidi override"));
}
#[test]
fn test_bidi_isolate_rejected() {
let input = "RECALL \u{2066}facts";
let err = Lexer::tokenize(input).unwrap_err();
assert!(err.to_string().contains("bidi override"));
}
#[test]
fn test_bidi_all_codepoints_rejected_s1() {
let bidi_chars = [
'\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}', ];
for ch in &bidi_chars {
let input = format!("RECALL {}facts", ch);
let result = Lexer::tokenize(&input);
assert!(
result.is_err(),
"Bidi character U+{:04X} should be rejected (S-1)",
*ch as u32
);
let err = result.unwrap_err();
assert!(
err.to_string().contains("bidi override"),
"Error for U+{:04X} should mention bidi override, got: {}",
*ch as u32,
err
);
}
}
#[test]
fn test_bidi_inside_string_literal_rejected_s1() {
let input = format!("RECALL facts WHERE subject = \"john{}bob\"", '\u{202E}');
let result = Lexer::tokenize(&input);
assert!(
result.is_err(),
"Bidi chars inside string literals must be rejected (S-1)"
);
}
#[test]
fn test_bidi_at_start_of_input_rejected_s1() {
let input = format!("{}RECALL facts", '\u{202A}');
let result = Lexer::tokenize(&input);
assert!(
result.is_err(),
"Bidi char at start of input must be rejected (S-1)"
);
}
#[test]
fn test_bidi_at_end_of_input_rejected_s1() {
let input = format!("RECALL facts{}", '\u{2069}');
let result = Lexer::tokenize(&input);
assert!(
result.is_err(),
"Bidi char at end of input must be rejected (S-1)"
);
}
#[test]
fn test_forget_is_not_destructive_keyword() {
assert!(!is_destructive_keyword("FORGET"));
assert!(!is_destructive_keyword("forget"));
assert!(!is_destructive_keyword("Forget"));
assert!(is_destructive_keyword("DELETE"));
assert!(is_destructive_keyword("delete"));
}
#[test]
fn test_drop_and_purge_are_not_destructive_keywords() {
assert!(!is_destructive_keyword("DROP"));
assert!(!is_destructive_keyword("PURGE"));
assert!(!is_destructive_keyword("RECALL"));
assert!(!is_destructive_keyword("WHERE"));
}
#[test]
fn test_is_destructive_keyword_crypto_words() {
assert!(is_destructive_keyword("ENCRYPT"));
assert!(is_destructive_keyword("DECRYPT"));
assert!(is_destructive_keyword("ROTATE"));
assert!(is_destructive_keyword("KEY"));
assert!(is_destructive_keyword("DEK"));
}
#[test]
fn test_is_destructive_keyword_all_blocked_words() {
let blocked = [
"DELETE",
"ERASE",
"DESTROY",
"TRUNCATE",
"INSERT",
"CREATE",
"WRITE",
"STORE",
"KEY",
"ENCRYPT",
"DECRYPT",
"ROTATE",
"MASTER",
"DEK",
"SECRET",
"POLICY",
"SEAL",
"UNSEAL",
"TOKEN",
"CONSENT",
"RESTRICT",
"SCHEMA",
"PARTITION",
"INDEX",
"MIGRATION",
];
for word in &blocked {
assert!(
is_destructive_keyword(word),
"'{}' should be a destructive keyword",
word
);
assert!(
is_destructive_keyword(&word.to_lowercase()),
"'{}' (lowercase) should be destructive",
word
);
}
assert!(!is_destructive_keyword("GRANT"));
assert!(!is_destructive_keyword("REVOKE"));
assert_eq!(
tok("GRANT revoke Show TO"),
vec![Token::Grant, Token::Revoke, Token::Show, Token::To]
);
}
#[test]
fn test_safe_keywords_not_destructive() {
let safe = [
"RECALL",
"WHERE",
"AND",
"OR",
"NOT",
"LIMIT",
"OFFSET",
"ORDER",
"BY",
"SELECT",
"COUNT",
"FIRST",
"WITH",
"ABOUT",
"EXPLAIN",
"DESCRIBE",
"BATCH",
"COALESCE",
"EXISTS",
"HISTORY",
"UNION",
"INTERSECT",
"EXCEPT",
"FORMAT",
];
for word in &safe {
assert!(
!is_destructive_keyword(word),
"'{}' should NOT be a destructive keyword",
word
);
}
}
#[test]
fn test_empty_input() {
let tokens = tok("");
assert!(tokens.is_empty());
}
#[test]
fn test_whitespace_only() {
let tokens = tok(" \t\n ");
assert!(tokens.is_empty());
}
#[test]
fn test_unterminated_string_error() {
let err = Lexer::tokenize("\"hello world").unwrap_err();
assert!(matches!(err, CalError::UnterminatedString { .. }));
}
#[test]
fn test_pipe_operator() {
let tokens = tok("RECALL facts | LIMIT 5");
assert_eq!(
tokens,
vec![
Token::Recall,
Token::Ident("facts".into()),
Token::Pipe,
Token::Limit,
Token::NumberLiteral(5.0),
]
);
}
#[test]
fn test_whitespace_collapsed() {
let tokens = tok("RECALL \t facts");
assert_eq!(tokens, vec![Token::Recall, Token::Ident("facts".into())]);
}
#[test]
fn test_combined_query_tokenization() {
let input = r#"RECALL facts WHERE subject = "john" | ORDER BY confidence DESC | LIMIT 10"#;
let tokens = tok(input);
assert_eq!(
tokens,
vec![
Token::Recall,
Token::Ident("facts".into()),
Token::Where,
Token::Ident("subject".into()),
Token::Eq,
Token::StringLiteral("john".into()),
Token::Pipe,
Token::Order,
Token::By,
Token::Ident("confidence".into()),
Token::Desc,
Token::Pipe,
Token::Limit,
Token::NumberLiteral(10.0),
]
);
}
#[test]
fn test_span_tracking() {
let spanned = Lexer::tokenize("RECALL facts").unwrap();
assert_eq!(spanned[0].span.start, 0);
assert_eq!(spanned[0].span.end, 6);
assert_eq!(spanned[1].span.start, 7);
assert_eq!(spanned[1].span.end, 12);
}
#[test]
fn test_span_tracking_multiple_newlines() {
let spanned = Lexer::tokenize("RECALL\nbeliefs\nWHERE subject = \"x\"").unwrap();
let where_tok = spanned
.iter()
.find(|t| matches!(t.token, Token::Where))
.expect("WHERE token must be present");
assert_eq!(where_tok.span.line, 3, "WHERE should be on line 3");
assert_eq!(where_tok.span.col, 1, "WHERE should be at column 1");
}
#[test]
fn test_hash_literal_min_length() {
let tokens = tok("sha256:abc12345");
assert_eq!(tokens, vec![Token::HashLiteral("abc12345".into())]);
}
#[test]
fn test_nfc_normalization_idempotent_ascii() {
let result = nfc_normalize("RECALL facts");
assert_eq!(result, "RECALL facts");
}
#[test]
fn test_nfc_normalization_combines_codepoints_s6() {
let decomposed = "RECALL facts WHERE subject = \"caf\u{0065}\u{0301}\"";
let precomposed = "RECALL facts WHERE subject = \"caf\u{00E9}\"";
let norm_decomposed = nfc_normalize(decomposed);
let norm_precomposed = nfc_normalize(precomposed);
assert_eq!(
norm_decomposed, norm_precomposed,
"NFC equivalents must produce identical normalized forms (S-6)"
);
}
#[test]
fn test_nfc_equivalent_inputs_same_token_stream_s6() {
let decomposed = "RECALL facts WHERE subject = \"caf\u{0065}\u{0301}\"";
let precomposed = "RECALL facts WHERE subject = \"caf\u{00E9}\"";
let tokens_a = Lexer::tokenize(decomposed).unwrap();
let tokens_b = Lexer::tokenize(precomposed).unwrap();
let tok_a: Vec<_> = tokens_a.iter().map(|st| &st.token).collect();
let tok_b: Vec<_> = tokens_b.iter().map(|st| &st.token).collect();
assert_eq!(
tok_a, tok_b,
"NFC-equivalent inputs must yield identical token streams (S-6)"
);
}
#[test]
fn test_with_option_keywords() {
let tokens = tok("WITH SUPERSEDED SCORE_BREAKDOWN EXPLANATION PROVENANCE");
assert_eq!(
tokens,
vec![
Token::With,
Token::Superseded,
Token::ScoreBreakdown,
Token::Explanation,
Token::Provenance,
]
);
}
#[test]
fn test_format_keywords() {
let tokens = tok("FORMAT JSON YAML MARKDOWN TEXT SML TOON TRIPLES");
assert_eq!(
tokens,
vec![
Token::Format,
Token::Json,
Token::Yaml,
Token::Markdown,
Token::Text,
Token::Sml,
Token::Toon,
Token::Triples,
]
);
}
#[test]
fn test_token_description() {
assert_eq!(Token::Recall.description(), "RECALL");
assert_eq!(Token::Eq.description(), "=");
assert_eq!(Token::Ident("foo".into()).description(), "foo");
assert_eq!(Token::Parameter("bar".into()).description(), "$bar");
assert_eq!(
Token::HashLiteral("abc123".into()).description(),
"sha256:abc123"
);
}
#[test]
fn test_vars_keyword() {
let tokens = tok("VARS");
assert_eq!(tokens, vec![Token::Vars]);
}
#[test]
fn test_vars_case_insensitive() {
let tokens = tok("vars Vars VARS vArS");
assert_eq!(
tokens,
vec![Token::Vars, Token::Vars, Token::Vars, Token::Vars]
);
}
#[test]
fn test_with_vars_sequence() {
let tokens = tok("WITH VARS");
assert_eq!(tokens, vec![Token::With, Token::Vars]);
}
fn body(input: &str) -> String {
match tok(input).into_iter().next().expect("a token") {
Token::Header(SectionBody::Body(s))
| Token::Element(SectionBody::Body(s))
| Token::ElementSummary(SectionBody::Body(s))
| Token::ElementOmit(SectionBody::Body(s))
| Token::SourceBreak(SectionBody::Body(s))
| Token::Footer(SectionBody::Body(s)) => s,
other => panic!("expected a section body, got {other:?}"),
}
}
#[test]
fn test_section_body_is_captured_raw() {
assert_eq!(
body(r#"ELEMENT { <fact>{{grain.content}}</fact> }"#),
" <fact>{{grain.content}}</fact> "
);
}
#[test]
fn test_section_body_strips_one_newline_of_layout_at_each_end() {
assert_eq!(body("HEADER {\n<context>\n }"), "<context>");
assert_eq!(body("HEADER {\n\n<context>\n\n }"), "\n<context>\n");
assert_eq!(body("FOOTER { x }"), " x ");
}
#[test]
fn test_section_brace_matching_is_mustache_aware() {
assert_eq!(body("ELEMENT {{{grain.type}}}"), "{{grain.type}}");
assert_eq!(
body(r#"ELEMENT { {"t": "{{grain.type}}"} }"#),
r#" {"t": "{{grain.type}}"} "#
);
}
#[test]
fn test_section_body_may_contain_text_that_is_not_cal() {
assert_eq!(body("ELEMENT { don't }"), " don't ");
assert_eq!(body("ELEMENT { 50% off — \"quoted }"), " 50% off — \"quoted ");
assert_eq!(body("ELEMENT { DELETE the old note }"), " DELETE the old note ");
}
#[test]
fn test_section_keywords_are_still_ordinary_words_without_a_body() {
assert_eq!(
tok(r#"WHERE header = "x""#),
vec![
Token::Where,
Token::Header(SectionBody::Bare),
Token::Eq,
Token::StringLiteral("x".into()),
]
);
}
#[test]
fn test_unterminated_section_body_is_left_for_the_parser() {
assert_eq!(
tok("ELEMENT { unclosed"),
vec![
Token::Element(SectionBody::Bare),
Token::LBrace,
Token::Ident("unclosed".into()),
]
);
}
#[test]
fn test_all_six_sections_lex() {
let toks = tok("HEADER {a} ELEMENT {b} ELEMENT_SUMMARY {c} \
ELEMENT_OMIT {d} SOURCE_BREAK {e} FOOTER {f}");
assert_eq!(
toks,
vec![
Token::Header(SectionBody::Body("a".into())),
Token::Element(SectionBody::Body("b".into())),
Token::ElementSummary(SectionBody::Body("c".into())),
Token::ElementOmit(SectionBody::Body("d".into())),
Token::SourceBreak(SectionBody::Body("e".into())),
Token::Footer(SectionBody::Body("f".into())),
]
);
}
}