use crate::error::{LexError, LexErrorAt};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileKind {
Data,
Schema,
TypeLib,
UnitLib,
Mapping,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LineKind<'a> {
Blank,
Comment(&'a str),
Doc(&'a str),
Decl(&'a str),
SectionOpen(&'a str),
SectionClose,
NsOpen(&'a str),
NsClose,
Content {
left: &'a str,
value: &'a str,
},
Meta(&'a str),
VarSplat(&'a str),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Line<'a> {
pub no: usize,
pub kind: LineKind<'a>,
}
const DECL_KEYWORDS: &[&str] = &[
"kaiv",
"raiv",
"daiv",
"saiv",
"csaiv",
"taiv",
"faiv",
"maiv",
"msaiv",
"schema",
"types",
"units",
"registry",
"provenance",
"ref",
"compose",
"source",
"target",
"via",
"drop",
"bind",
"unique",
"fk",
];
pub fn lex(input: &[u8], kind: FileKind) -> Result<Vec<Line<'_>>, LexErrorAt> {
if input.starts_with(&[0xEF, 0xBB, 0xBF]) {
return Err(LexErrorAt {
error: LexError::Bom,
line: 0,
});
}
let text = std::str::from_utf8(input).map_err(|_| LexErrorAt {
error: LexError::InvalidUtf8,
line: 0,
})?;
let bytes = text.as_bytes();
let mut line_no = 1usize;
for (i, &b) in bytes.iter().enumerate() {
match b {
0x00 => {
return Err(LexErrorAt {
error: LexError::InvalidCharacter,
line: line_no,
})
}
b'\r' if bytes.get(i + 1) != Some(&b'\n') => {
return Err(LexErrorAt {
error: LexError::InvalidCharacter,
line: line_no,
})
}
b'\n' => line_no += 1,
_ => {}
}
}
if !bytes.is_empty() && *bytes.last().unwrap() != b'\n' {
return Err(LexErrorAt {
error: LexError::MissingFinalEol,
line: 0,
});
}
let mut raw_lines: Vec<&str> = text.split('\n').collect();
raw_lines.pop();
let has_unit_imports = raw_lines
.iter()
.any(|l| l.trim_start_matches([' ', '\t']).starts_with(".!units"));
let mut out = Vec::new();
for (idx, raw) in raw_lines.into_iter().enumerate() {
let no = idx + 1;
let raw = raw.strip_suffix('\r').unwrap_or(raw);
let s = raw.trim_start_matches([' ', '\t']);
let k = classify(s, no, kind, has_unit_imports)?;
out.push(Line { no, kind: k });
}
Ok(out)
}
pub fn expect_kind(text: &str, kind: &str) -> Result<(), LexErrorAt> {
let mut lines = text.split('\n');
let mut first = lines.next().unwrap_or("");
let mut no = 1;
if first.starts_with("#!") {
first = lines.next().unwrap_or("");
no = 2;
}
let body = first.strip_suffix('\r').unwrap_or(first);
let ok = body
.trim_start_matches([' ', '\t'])
.strip_prefix(".!")
.and_then(|r| r.strip_prefix(kind))
.is_some_and(|r| r.is_empty() || r.starts_with([' ', '\t']));
if ok {
Ok(())
} else {
Err(LexErrorAt {
error: LexError::FormatKind,
line: no,
})
}
}
fn classify<'a>(
s: &'a str,
no: usize,
kind: FileKind,
has_unit_imports: bool,
) -> Result<LineKind<'a>, LexErrorAt> {
if s.is_empty() {
return Ok(LineKind::Blank);
}
if no == 1 && s.starts_with("#!") {
return Ok(LineKind::Blank);
}
if let Some(c) = s.strip_prefix('#') {
return Ok(LineKind::Comment(c));
}
if let Some(c) = s.strip_prefix("//") {
return Ok(LineKind::Doc(c));
}
if s.starts_with(".!") || s.starts_with(".?") {
check_declaration(s, no)?;
return Ok(LineKind::Decl(s));
}
if s == "[]" {
return Ok(LineKind::SectionClose);
}
if s == "()" {
return Ok(LineKind::NsClose);
}
if let Some(rest) = s.strip_prefix('[') {
if let Some(inner) = rest.strip_suffix(']') {
let toks = crate::table::tokens(inner);
if !toks.first().is_some_and(|p| valid_array_path(p)) {
return Err(LexErrorAt {
error: LexError::InvalidKey,
line: no,
});
}
if toks.len() > 1 && crate::table::parse_header(&toks[1..]).is_none() {
return Err(LexErrorAt {
error: LexError::InvalidConstraint,
line: no,
});
}
return Ok(LineKind::SectionOpen(inner));
}
}
if let Some(rest) = s.strip_prefix('(') {
if let Some(inner) = rest.strip_suffix(')') {
let toks = crate::table::tokens(inner);
if !toks.first().is_some_and(|p| valid_ns_path(p))
|| !toks[1..].iter().all(|t| t.starts_with("schema:"))
{
return Err(LexErrorAt {
error: LexError::InvalidKey,
line: no,
});
}
return Ok(LineKind::NsOpen(inner));
}
}
if early_meta(s, kind) {
check_schema_units(s, kind, has_unit_imports, no)?;
return Ok(LineKind::Meta(s));
}
if matches!(kind, FileKind::Schema | FileKind::TypeLib) && re_leader(s) {
return Err(LexErrorAt {
error: LexError::InvalidConstraint,
line: no,
});
}
if let Some(i) = split_index(s) {
let left = s[..i].trim_end_matches([' ', '\t']);
let value = &s[i + 1..];
if left.is_empty() {
return Err(LexErrorAt {
error: LexError::EmptyKey,
line: no,
});
}
check_key(left, no, kind)?;
return Ok(LineKind::Content { left, value });
}
if kind == FileKind::Data {
if let Some(name) = s.strip_prefix("$/.") {
if valid_bare_name(name) {
return Ok(LineKind::VarSplat(name));
}
}
}
let first = s.chars().next().unwrap();
let meta_ok = match kind {
FileKind::Data => matches!(first, '!' | '?' | '&'),
FileKind::UnitLib => first.is_ascii_alphanumeric() || first == '$',
FileKind::Schema | FileKind::TypeLib => {
matches!(first, '!' | '?' | '&' | '/' | '{' | '.' | '[')
}
FileKind::Mapping => false,
};
if !meta_ok {
return Err(LexErrorAt {
error: LexError::MissingOperator,
line: no,
});
}
if matches!(kind, FileKind::Data | FileKind::Schema | FileKind::TypeLib)
&& (first == '!' || first == '&')
&& crate::anno::parse_annotation(s).is_none()
{
return Err(LexErrorAt {
error: LexError::InvalidConstraint,
line: no,
});
}
if matches!(kind, FileKind::Schema | FileKind::TypeLib)
&& (matches!(first, '/' | '{' | '[') || s.starts_with(".."))
{
return Err(LexErrorAt {
error: LexError::InvalidConstraint,
line: no,
});
}
check_schema_units(s, kind, has_unit_imports, no)?;
Ok(LineKind::Meta(s))
}
fn check_schema_units(
s: &str,
kind: FileKind,
has_unit_imports: bool,
no: usize,
) -> Result<(), LexErrorAt> {
if !matches!(kind, FileKind::Schema | FileKind::TypeLib)
|| has_unit_imports
|| !s.starts_with('!')
{
return Ok(());
}
if let Some(a) = crate::anno::parse_annotation(s) {
if let Some(u) = &a.unit {
if !crate::unit::members_ok(u, &Default::default()) {
return Err(LexErrorAt {
error: LexError::InvalidConstraint,
line: no,
});
}
}
}
Ok(())
}
fn early_meta(s: &str, kind: FileKind) -> bool {
let Some(first) = s.chars().next() else {
return false;
};
match kind {
FileKind::Data => match first {
'!' => crate::anno::parse_annotation(s).is_some(),
'&' => named_annotation_ok(s),
_ => false,
},
FileKind::Schema | FileKind::TypeLib => match first {
'!' => crate::anno::parse_annotation(s).is_some(),
'&' => named_annotation_ok(s),
'/' | '{' | '[' => crate::anno::parse_constraint_items(s).is_some(),
'.' if s.starts_with("..") => crate::anno::parse_constraint_items(s).is_some(),
'r' if re_leader(s) => crate::anno::parse_constraint_items(s).is_some(),
_ => false,
},
FileKind::UnitLib => {
(first.is_ascii_alphanumeric() || first == '$')
&& crate::faiv::parse_def_line(s).is_some()
}
FileKind::Mapping => false,
}
}
fn re_leader(s: &str) -> bool {
s.starts_with("re") && s[2..].starts_with(crate::anno::RE_SEPS)
}
fn named_annotation_ok(s: &str) -> bool {
let Some(rest) = s.strip_prefix('&') else {
return false;
};
let end = rest.find([' ', '\t']).unwrap_or(rest.len());
let (name, items) = rest.split_at(end);
let bs = name.as_bytes();
!bs.is_empty()
&& (bs[0].is_ascii_alphabetic() || bs[0] == b'_')
&& bs[1..]
.iter()
.all(|b| b.is_ascii_alphanumeric() || *b == b'_')
&& (items.trim_matches([' ', '\t']).is_empty()
|| crate::anno::parse_constraint_items(items).is_some())
}
fn split_index(s: &str) -> Option<usize> {
let b = s.as_bytes();
let mut i = 0;
let mut in_quote = false;
while i < b.len() {
match b[i] {
b'"' => {
if in_quote && b.get(i + 1) == Some(&b'"') {
i += 1; } else {
in_quote = !in_quote;
}
}
b'=' if !in_quote => return Some(i),
_ => {}
}
i += 1;
}
None
}
fn check_declaration(s: &str, no: usize) -> Result<(), LexErrorAt> {
if s.starts_with(".?") {
return Ok(()); }
let body = &s[2..];
let word: String = body
.chars()
.take_while(|c| c.is_ascii_alphanumeric())
.collect();
if !DECL_KEYWORDS.contains(&word.as_str()) {
return Err(LexErrorAt {
error: LexError::InvalidDirective,
line: no,
});
}
if matches!(
word.as_str(),
"kaiv" | "raiv" | "daiv" | "saiv" | "csaiv" | "taiv" | "faiv" | "maiv" | "msaiv"
) {
let data_kind = matches!(word.as_str(), "kaiv" | "raiv" | "daiv" | "maiv");
let rest = body[word.len()..].trim_start_matches([' ', '\t']);
let version: &str = rest.split([' ', '\t']).next().unwrap_or("");
if version.is_empty() && data_kind {
return Ok(());
}
let major = check_version(version).ok_or(LexErrorAt {
error: LexError::InvalidVersion,
line: no,
})?;
if major != 1 {
return Err(LexErrorAt {
error: LexError::UnsupportedVersion,
line: no,
});
}
if data_kind && !rest[version.len()..].trim_matches([' ', '\t']).is_empty() {
return Err(LexErrorAt {
error: LexError::InvalidVersion,
line: no,
});
}
}
Ok(())
}
fn check_version(v: &str) -> Option<u64> {
let mut parts = v.split('.');
let major = parts.next()?;
if major.is_empty() || !major.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let mut n = 0;
for p in parts {
n += 1;
if n > 2 || p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
}
Some(major.parse().unwrap_or(u64::MAX))
}
pub(crate) fn check_key(left: &str, no: usize, kind: FileKind) -> Result<(), LexErrorAt> {
let err = || LexErrorAt {
error: LexError::InvalidKey,
line: no,
};
let b = left.as_bytes();
let mut i = 0;
let mut in_quote = false;
let mut tick: Option<usize> = None;
while i < b.len() {
match b[i] {
b'"' => {
if in_quote && b.get(i + 1) == Some(&b'"') {
i += 1;
} else {
in_quote = !in_quote;
}
}
b' ' | b'\t' if !in_quote => return Err(err()),
b'\'' if !in_quote => tick = tick.or(Some(i)),
_ => {}
}
i += 1;
}
if in_quote {
return Err(err()); }
let mut key = match tick {
Some(t) => {
if !left[..t].starts_with('!') {
return Err(err());
}
&left[t + 1..]
}
None => left,
};
let named_def = key.starts_with('&');
if matches!(kind, FileKind::Schema | FileKind::TypeLib | FileKind::UnitLib) {
key = key.strip_suffix('?').unwrap_or(key);
if matches!(kind, FileKind::TypeLib | FileKind::UnitLib) {
key = key.strip_prefix('&').unwrap_or(key);
}
if kind == FileKind::UnitLib {
if let Some(code) = key.strip_prefix('~') {
return if code.len() == 3 && code.bytes().all(|b| b.is_ascii_uppercase()) {
Ok(())
} else {
Err(err())
};
}
}
}
let mut array_op = false;
key = if let Some(k) = key.strip_suffix("+:") {
array_op = true;
k
} else if let Some(k) = key.strip_suffix(':').filter(|k| !k.is_empty()) {
if kind == FileKind::Data && !k.starts_with('/') {
return Err(err());
}
k
} else if let Some(k) = key.strip_suffix('+') {
array_op = true;
k
} else if let Some(k) = key.strip_suffix(';') {
array_op = true;
k
} else {
key
};
let cs: Vec<char> = key.chars().collect();
let mut seg = String::new();
let mut segs: Vec<String> = Vec::new();
let mut j = 0;
let mut q = false;
while j < cs.len() {
match cs[j] {
'"' => {
if q && cs.get(j + 1) == Some(&'"') {
seg.push_str("\"\"");
j += 1;
} else {
q = !q;
seg.push('"');
}
}
'/' if !q => segs.push(std::mem::take(&mut seg)),
':' if !q && cs.get(j + 1) == Some(&':') => {
segs.push(std::mem::take(&mut seg));
j += 1;
}
c => seg.push(c),
}
j += 1;
}
segs.push(seg);
for (idx, s) in segs.iter().enumerate() {
if s.is_empty() {
if idx == 0 {
continue;
}
return Err(err());
}
if kind == FileKind::Mapping && s == "*" {
continue;
}
if !valid_segment(s) {
return Err(err());
}
}
if array_op && !segs.last().is_some_and(|s| s.starts_with('@')) {
return Err(err());
}
if matches!(kind, FileKind::Schema | FileKind::TypeLib)
&& !named_def
&& segs.first().is_some_and(|s| s == "re")
{
return Err(err());
}
Ok(())
}
fn split_slash(p: &str) -> Vec<&str> {
let b = p.as_bytes();
let mut segs = Vec::new();
let mut in_quote = false;
let mut start = 0;
let mut i = 0;
while i < b.len() {
match b[i] {
b'"' => {
if in_quote && b.get(i + 1) == Some(&b'"') {
i += 1;
} else {
in_quote = !in_quote;
}
}
b'/' if !in_quote => {
segs.push(&p[start..i]);
start = i + 1;
}
_ => {}
}
i += 1;
}
segs.push(&p[start..]);
segs
}
fn valid_array_path(p: &str) -> bool {
let segs = split_slash(p.strip_prefix('/').unwrap_or(p));
let Some((last, rest)) = segs.split_last() else {
return false;
};
last.starts_with('@')
&& valid_segment(last)
&& rest.iter().all(|s| !s.is_empty() && valid_segment(s))
}
fn valid_ns_path(p: &str) -> bool {
let Some(rest) = p.strip_prefix('/') else {
return false;
};
let segs = split_slash(rest);
!segs.is_empty() && segs.iter().all(|s| !s.is_empty() && valid_segment(s))
}
fn valid_bare_name(s: &str) -> bool {
let b = s.as_bytes();
!b.is_empty()
&& (b[0].is_ascii_alphabetic() || b[0] == b'_')
&& b[1..]
.iter()
.all(|c| c.is_ascii_alphanumeric() || *c == b'_')
}
fn valid_segment(seg: &str) -> bool {
let s = seg.strip_prefix('@').unwrap_or(seg);
let s = s.strip_prefix('.').unwrap_or(s);
if s.is_empty() {
return false; }
if let Some(rest) = s.strip_prefix('"') {
return rest
.strip_suffix('"')
.is_some_and(|inner| !inner.is_empty());
}
let bs = s.as_bytes();
if bs.iter().all(|b| b.is_ascii_digit()) {
return bs.len() == 1 || bs[0] != b'0';
}
(bs[0].is_ascii_alphabetic() || bs[0] == b'_')
&& bs[1..]
.iter()
.all(|b| b.is_ascii_alphanumeric() || *b == b'_')
}
#[cfg(test)]
mod tests {
use super::*;
fn one(kind: FileKind, line: &str) -> Result<(), LexError> {
let text = format!("{line}\n");
lex(text.as_bytes(), kind).map(|_| ()).map_err(|e| e.error)
}
#[test]
fn leading_zero_index_is_not_canonical() {
assert_eq!(
one(FileKind::Data, "!str'/@a/00::x=1"),
Err(LexError::InvalidKey)
);
assert!(one(FileKind::Data, "!str'/@a/0::x=1").is_ok());
assert!(one(FileKind::Data, "!str'/@a/10::x=1").is_ok());
}
#[test]
fn apostrophe_in_authored_key_is_invalid() {
assert_eq!(one(FileKind::Data, "it's=1"), Err(LexError::InvalidKey));
assert!(one(FileKind::Data, "\"it's\"=1").is_ok());
assert!(one(FileKind::Data, "!str'::host=1").is_ok());
}
#[test]
fn section_and_ns_open_paths_validated() {
assert!(one(FileKind::Data, "[/@servers]").is_ok());
assert_eq!(one(FileKind::Data, "[/servers]"), Err(LexError::InvalidKey));
assert_eq!(one(FileKind::Data, "[/@bad-name]"), Err(LexError::InvalidKey));
assert!(one(FileKind::Data, "(/server)").is_ok());
assert!(one(FileKind::Data, "(/server/backup)").is_ok());
assert_eq!(one(FileKind::Data, "(server)"), Err(LexError::InvalidKey));
assert_eq!(one(FileKind::Data, "( )"), Err(LexError::InvalidKey));
assert_eq!(one(FileKind::Data, "(/a b)"), Err(LexError::InvalidKey));
assert!(one(FileKind::Data, "(/a schema:acme/x)").is_ok());
assert!(one(FileKind::Data, "(/\"my ns\")").is_ok());
assert!(one(FileKind::Data, "[/@\"my arr\"]").is_ok());
}
#[test]
fn all_inventory_keywords_accepted() {
for l in [
".!maiv",
".!maiv 1",
".!msaiv 1 corp/c",
".!source hub/s",
".!target hub/t",
".!via acme/m",
".!drop /a::b",
".!bind:pat sch",
".!unique:pat /a::b",
".!fk:pat /a::b",
] {
assert!(one(FileKind::Data, l).is_ok(), "{l}");
}
assert_eq!(
one(FileKind::Data, ".!bogus x"),
Err(LexError::InvalidDirective)
);
assert_eq!(
one(FileKind::Data, ".!maiv 1 acme/m"),
Err(LexError::InvalidVersion)
);
}
#[test]
fn malformed_constraints_are_lexer_detected() {
assert_eq!(
one(FileKind::Data, "!int[1;2]"),
Err(LexError::InvalidConstraint)
);
assert_eq!(
one(FileKind::Schema, "{red,green"),
Err(LexError::InvalidConstraint)
);
assert_eq!(
one(FileKind::Schema, "[1,2"),
Err(LexError::InvalidConstraint)
);
assert_eq!(
one(FileKind::Schema, "/unterminated"),
Err(LexError::InvalidConstraint)
);
}
#[test]
fn version_overflow_and_trailing_junk() {
assert_eq!(
one(FileKind::Data, ".!kaiv 99999999999999999999"),
Err(LexError::UnsupportedVersion)
);
assert_eq!(
one(FileKind::Data, ".!kaiv 1 oops"),
Err(LexError::InvalidVersion)
);
assert!(one(FileKind::Data, ".!kaiv 1").is_ok());
}
#[test]
fn canonical_provenance_lines_are_content() {
let text = "!int?sensor1@20250115T093000Z#req-42'/readings::temp=100\n";
let lines = lex(text.as_bytes(), FileKind::Data).unwrap();
assert!(matches!(
lines[0].kind,
LineKind::Content {
left: "!int?sensor1@20250115T093000Z#req-42'/readings::temp",
value: "100"
}
));
}
#[test]
fn re_literal_lines_and_reservation() {
assert!(one(FileKind::Schema, "!str re~^https://a/b=c~").is_ok());
assert!(one(FileKind::TypeLib, "re%[0-9]+% ..num\n&digits=").is_ok());
assert!(one(FileKind::Schema, "re:=a:").is_ok());
assert_eq!(
one(FileKind::Schema, "re:=a"),
Err(LexError::InvalidConstraint)
);
assert_eq!(
one(FileKind::Schema, "!str re:^a$"),
Err(LexError::InvalidConstraint)
);
assert_eq!(one(FileKind::Schema, "re=x"), Err(LexError::InvalidKey));
assert_eq!(one(FileKind::Schema, "re?=x"), Err(LexError::InvalidKey));
assert_eq!(
one(FileKind::Schema, "re/sub::f=x"),
Err(LexError::InvalidKey)
);
assert!(one(FileKind::Schema, "\"re\"=x").is_ok());
assert!(one(FileKind::Schema, "/re::f=x").is_ok()); assert!(one(FileKind::TypeLib, "&re=").is_ok());
assert!(one(FileKind::Data, "re=5").is_ok());
assert!(one(FileKind::Data, "/re:=host=a").is_ok());
assert_eq!(one(FileKind::Data, "server:=a=1"), Err(LexError::InvalidKey));
}
#[test]
fn bare_name_keys() {
for l in [
"host=x",
"/server/api::port=1",
"/@ports+=80",
"/@tags;=a;b",
"/server:=a=1|b=2",
"/@servers+:=h=a|p=1",
".var=x",
"@.tags;=a;b",
"/.tpl:=a=1",
"\"server name\"=x",
"\"weird\"\"name\"=x",
"\"a=b\"=v",
"app/\"dark-mode\"::enabled=1",
"/@a::0=x",
"!str'::host=x", ] {
assert_eq!(one(FileKind::Data, l), Ok(()), "{l}");
}
assert_eq!(one(FileKind::Schema, "timeout?=30"), Ok(()));
assert_eq!(one(FileKind::TypeLib, "&distance_km="), Ok(()));
for l in [
"retry-count=3", "9port=1", "a.b=1", "\"\"=x", "a//b::f=1", "caf\u{e9}=1", ] {
assert_eq!(one(FileKind::Data, l), Err(LexError::InvalidKey), "{l}");
}
}
}