use serde_json::{Map, Value, json};
use crate::params::{json_type, unknown_keys_error};
const MAX_DOC: usize = 8192;
#[derive(Default)]
struct Scan {
depth: i32,
in_str: bool,
escaped: bool,
}
impl Scan {
fn feed(&mut self, c: char) -> bool {
if self.in_str {
if self.escaped {
self.escaped = false;
} else if c == '\\' {
self.escaped = true;
} else if c == '"' {
self.in_str = false;
}
return false;
}
if c == '"' {
self.in_str = true;
return false;
}
true
}
}
fn pieces(doc: &str) -> Option<Vec<&str>> {
if doc.len() > MAX_DOC {
return None;
}
let body = doc.trim().strip_prefix('{')?;
let mut out = Vec::new();
let mut scan = Scan::default();
let mut start = 0;
fn push<'a>(s: Option<&'a str>, out: &mut Vec<&'a str>) {
if let Some(s) = s.map(str::trim).filter(|s| !s.is_empty()) {
out.push(s);
}
}
for (i, c) in body.char_indices() {
if !scan.feed(c) {
continue;
}
match c {
'{' | '[' | '(' => scan.depth += 1,
']' | ')' => scan.depth -= 1,
'}' if scan.depth == 0 => {
push(body.get(start..i), &mut out);
return Some(out);
}
'}' => scan.depth -= 1,
',' if scan.depth == 0 => {
push(body.get(start..i), &mut out);
start = i + c.len_utf8();
}
_ => {}
}
}
None
}
fn key_and_value(piece: &str) -> (&str, Option<&str>) {
let mut scan = Scan::default();
for (i, c) in piece.char_indices() {
if !scan.feed(c) {
continue;
}
match c {
'{' | '[' | '(' => scan.depth += 1,
'}' | ']' | ')' => scan.depth -= 1,
':' if scan.depth == 0 => return (piece.get(..i).unwrap_or_default(), piece.get(i + 1..)),
_ => {}
}
}
(piece, None)
}
fn piece_keys(key_part: &str) -> Vec<String> {
let mut keys = Vec::new();
let mut depth = 0i32;
let mut start = 0;
let take = |s: &str, keys: &mut Vec<String>| {
let k: String = s.trim().trim_start_matches('"').chars().take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '-').collect();
if !k.is_empty() && !keys.contains(&k) {
keys.push(k);
}
};
for (i, c) in key_part.char_indices() {
match c {
'{' | '[' | '(' => depth += 1,
'}' | ']' | ')' => depth -= 1,
'|' if depth == 0 => {
take(key_part.get(start..i).unwrap_or_default(), &mut keys);
start = i + 1;
}
_ => {}
}
}
take(key_part.get(start..).unwrap_or_default(), &mut keys);
keys
}
pub fn accepted_params(doc: &str) -> Option<Vec<String>> {
let mut keys: Vec<String> = Vec::new();
for piece in pieces(doc)? {
for k in piece_keys(key_and_value(piece).0) {
if !keys.contains(&k) {
keys.push(k);
}
}
}
Some(keys)
}
fn outside_strings(piece: &str) -> String {
let mut scan = Scan::default();
piece.chars().filter(|c| scan.feed(*c) && *c != '"').collect()
}
fn value_schema(value: &str) -> Map<String, Value> {
let mut m = Map::new();
let v = value.trim().trim_end_matches('?').trim();
let v = v.split('=').next().unwrap_or_default().trim();
if v.starts_with('[') {
m.insert("type".into(), "array".into());
} else if v.starts_with('{') {
m.insert("type".into(), "object".into());
} else if let Some(inner) = v.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
m.insert("type".into(), "string".into());
if inner.contains('|') && !inner.contains('…') && !inner.contains("...") {
let values: Vec<&str> = inner.split('|').map(str::trim).filter(|s| !s.is_empty()).collect();
m.insert("enum".into(), values.into());
}
} else {
let t = match v.to_ascii_lowercase().as_str() {
"str" | "string" | "text" | "name" | "path" => Some("string"),
"bool" | "boolean" => Some("boolean"),
"u8" | "u16" | "u32" | "u64" | "usize" | "i8" | "i16" | "i32" | "i64" | "isize" | "int" | "integer" | "n" => Some("integer"),
"f32" | "f64" | "float" | "number" | "num" => Some("number"),
_ => None,
};
if let Some(t) = t {
m.insert("type".into(), t.into());
}
}
m
}
pub(crate) fn schema(doc: &str) -> Value {
let Some(pieces) = pieces(doc) else {
return json!({"type": "object", "description": doc});
};
let mut props = Map::new();
let mut required: Vec<String> = Vec::new();
for piece in pieces {
let (key_part, value) = key_and_value(piece);
let keys = piece_keys(key_part);
let markers = outside_strings(piece);
let optional = keys.len() > 1 || markers.contains('?') || markers.contains('=');
for k in keys {
if !optional && !required.contains(&k) {
required.push(k.clone());
}
let mut p = value.map(value_schema).unwrap_or_default();
p.insert("description".into(), piece.into());
props.insert(k, Value::Object(p));
}
}
json!({"type": "object", "description": doc, "properties": props, "required": required, "additionalProperties": false})
}
pub(crate) fn check(doc: &str, value: &Value, common: &[&str], aliases: &[(&str, &str)]) -> Result<(), String> {
let Some(accepted) = accepted_params(doc) else { return Ok(()) };
let obj = match value {
Value::Null => return Ok(()),
Value::Object(m) => m,
other => return Err(format!("expected an object of parameters, got {}", json_type(other))),
};
let ok = |k: &str| common.contains(&k) || accepted.iter().any(|a| a == k || aliases.iter().any(|(doc, alias)| doc == a && *alias == k));
let unknown: Vec<&str> = obj.keys().map(String::as_str).filter(|k| !ok(k)).collect();
if unknown.is_empty() {
return Ok(());
}
let accepted: Vec<&str> = accepted.iter().map(String::as_str).collect();
Err(format!("{} (doc: {doc})", unknown_keys_error(&unknown, &accepted)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys_of_both_doc_styles() {
let film = r#"{"name":str,"width":u32=1920,"fps":f64=23.976,"mix":"Stereo|Mono|5.1|Adaptive"?,"fromItem":itemId?}"#;
assert_eq!(accepted_params(film).unwrap(), ["name", "width", "fps", "mix", "fromItem"]);
assert_eq!(accepted_params("{layers: [id|name|#n], add?, toggle?}").unwrap(), ["layers", "add", "toggle"]);
assert_eq!(accepted_params("{time? (s) | frame? | timecode?}").unwrap(), ["time", "frame", "timecode"]);
assert_eq!(accepted_params("{}").unwrap(), Vec::<String>::new());
assert_eq!(accepted_params(r#"{"label":"Violet|Iris|…"}"#).unwrap(), ["label"]);
assert_eq!(accepted_params(r#"{"sep":", or }","x":n}"#).unwrap(), ["sep", "x"]);
assert_eq!(accepted_params("free text, no braces"), None);
assert_eq!(accepted_params("{unterminated"), None);
}
#[test]
fn schema_types_and_required() {
let s = schema(r#"{"name":str,"width":u32=1920,"mix":"Stereo|Mono"?,"clips":[id]?}"#);
assert_eq!(s["required"], json!(["name"]));
assert_eq!(s["properties"]["name"]["type"], "string");
assert_eq!(s["properties"]["width"]["type"], "integer");
assert_eq!(s["properties"]["mix"]["enum"], json!(["Stereo", "Mono"]));
assert_eq!(s["properties"]["clips"]["type"], "array");
assert_eq!(s["additionalProperties"], false);
}
}