feat(manifest): app manifest + decision crate (strict TOML subset, checked newtypes, capability catalog); docs/apps.md spec
This commit is contained in:
135
crates/manifest/src/toml.rs
Normal file
135
crates/manifest/src/toml.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
//! The strict TOML subset manifests and decisions are written in:
|
||||
//! `[table]` headers, `key = "string"`, `key = ["a", "b"]` on one line,
|
||||
//! `#` comments. Strings are printable ASCII without `"` or `\`.
|
||||
|
||||
use crate::Error;
|
||||
|
||||
/// One `key = value` line, with the table it is in.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Entry<'a> {
|
||||
pub line: usize,
|
||||
pub table: &'a str,
|
||||
pub key: &'a str,
|
||||
pub value: Value<'a>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Value<'a> {
|
||||
Str(&'a str),
|
||||
/// A one-line array of strings, already checked.
|
||||
List(List<'a>),
|
||||
}
|
||||
|
||||
/// The inside of `[...]`: iterate it for its strings.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct List<'a>(&'a str);
|
||||
|
||||
impl<'a> List<'a> {
|
||||
pub fn iter(&self) -> impl Iterator<Item = &'a str> {
|
||||
self.0
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| &s[1..s.len() - 1])
|
||||
}
|
||||
}
|
||||
|
||||
/// A table may be opened only once.
|
||||
const MAX_TABLES: usize = 8;
|
||||
|
||||
/// Calls `entry` with every `key = value` in `text`, in order; stops at
|
||||
/// the first malformed line or the first error `entry` returns.
|
||||
pub fn parse<'a>(
|
||||
text: &'a str,
|
||||
mut entry: impl FnMut(Entry<'a>) -> Result<(), Error>,
|
||||
) -> Result<(), Error> {
|
||||
let mut table = "";
|
||||
let mut seen = [""; MAX_TABLES];
|
||||
let mut tables = 0;
|
||||
for (i, raw) in text.lines().enumerate() {
|
||||
let line = i + 1;
|
||||
let content = strip_comment(raw, line)?.trim();
|
||||
if content.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = content.strip_prefix('[') {
|
||||
let name = name
|
||||
.strip_suffix(']')
|
||||
.filter(|n| is_key(n))
|
||||
.ok_or(Error::Syntax { line })?;
|
||||
if seen[..tables].contains(&name) {
|
||||
return Err(Error::Duplicate { line });
|
||||
}
|
||||
*seen.get_mut(tables).ok_or(Error::Syntax { line })? = name;
|
||||
tables += 1;
|
||||
table = name;
|
||||
continue;
|
||||
}
|
||||
let (key, value) = content.split_once('=').ok_or(Error::Syntax { line })?;
|
||||
let (key, value) = (key.trim(), value.trim());
|
||||
if table.is_empty() || !is_key(key) {
|
||||
return Err(Error::Syntax { line });
|
||||
}
|
||||
entry(Entry {
|
||||
line,
|
||||
table,
|
||||
key,
|
||||
value: value_of(value, line)?,
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The line up to a `#` outside a string.
|
||||
fn strip_comment(raw: &str, line: usize) -> Result<&str, Error> {
|
||||
let mut in_string = false;
|
||||
for (i, c) in raw.char_indices() {
|
||||
match c {
|
||||
'"' => in_string = !in_string,
|
||||
'#' if !in_string => return Ok(&raw[..i]),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if in_string {
|
||||
Err(Error::Syntax { line })
|
||||
} else {
|
||||
Ok(raw)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_key(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
|
||||
}
|
||||
|
||||
fn string(s: &str, line: usize) -> Result<&str, Error> {
|
||||
let inner = s
|
||||
.strip_prefix('"')
|
||||
.and_then(|s| s.strip_suffix('"'))
|
||||
.ok_or(Error::Syntax { line })?;
|
||||
let printable = inner
|
||||
.bytes()
|
||||
.all(|b| (b' '..=b'~').contains(&b) && b != b'"' && b != b'\\');
|
||||
if printable {
|
||||
Ok(inner)
|
||||
} else {
|
||||
Err(Error::Syntax { line })
|
||||
}
|
||||
}
|
||||
|
||||
fn value_of(s: &str, line: usize) -> Result<Value<'_>, Error> {
|
||||
let Some(inner) = s.strip_prefix('[') else {
|
||||
return string(s, line).map(Value::Str);
|
||||
};
|
||||
let inner = inner.strip_suffix(']').ok_or(Error::Syntax { line })?;
|
||||
let items: &str = inner.trim();
|
||||
// A trailing comma is allowed; an empty item is not.
|
||||
let body = items.strip_suffix(',').unwrap_or(items);
|
||||
if !body.trim().is_empty() {
|
||||
for item in body.split(',') {
|
||||
string(item.trim(), line)?;
|
||||
}
|
||||
}
|
||||
Ok(Value::List(List(body)))
|
||||
}
|
||||
Reference in New Issue
Block a user