use crate::error::{LexError, LexErrorAt};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileKind {
Data,
Schema,
TypeLib,
UnitLib,
}
#[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),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Line<'a> {
pub no: usize,
pub kind: LineKind<'a>,
}
const DECL_KEYWORDS: &[&str] = &[
"kaiv",
"kaivschema",
"kaivtype",
"kaivunit",
"schema",
"types",
"units",
"registry",
"provenance",
"ref",
"compose",
];
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)
}
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 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.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(')') {
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 });
}
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, '!' | '?' | '&' | '/' | '{' | '.' | '[')
}
};
if !meta_ok {
return Err(LexErrorAt {
error: LexError::MissingOperator,
line: no,
});
}
if matches!(kind, FileKind::Schema | FileKind::TypeLib)
&& (first == '!' || first == '&')
&& crate::anno::parse_annotation(s).is_none()
{
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()
}
}
}
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" | "kaivschema" | "kaivtype" | "kaivunit"
) {
let rest = body[word.len()..].trim_start_matches([' ', '\t']);
let version: &str = rest.split([' ', '\t']).next().unwrap_or("");
let major = check_version(version).ok_or(LexErrorAt {
error: LexError::InvalidVersion,
line: no,
})?;
if major != 1 {
return Err(LexErrorAt {
error: LexError::UnsupportedVersion,
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;
}
}
major.parse().ok()
}
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) => &left[t + 1..],
None => left,
};
let named_def = key.starts_with('&');
if kind != FileKind::Data {
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())
};
}
}
}
key = key
.strip_suffix("+:")
.or_else(|| key.strip_suffix(':').filter(|k| !k.is_empty()))
.or_else(|| key.strip_suffix('+'))
.or_else(|| key.strip_suffix(';'))
.unwrap_or(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 !valid_segment(s) {
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 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 true; }
(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 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());
}
#[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}");
}
}
}