use std::sync::OnceLock;
use regex::Regex;
use serde_json::Value;
fn slot_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"<!--seam:([^:]+(?:\.[^:]+)*):(?:style:[\w-]+|attr:class)-->").expect("valid regex")
})
}
fn resolve_path<'a>(schema: &'a Value, path: &str) -> Option<&'a Value> {
let mut current = schema;
for segment in path.split('.') {
if segment == "$" {
current = current.get("elements")?;
} else {
current = current
.get("properties")
.and_then(|p| p.get(segment))
.or_else(|| current.get("optionalProperties").and_then(|p| p.get(segment)))?;
}
}
Some(current)
}
fn is_open_string(schema: &Value) -> bool {
schema.get("type").and_then(Value::as_str) == Some("string") && schema.get("enum").is_none()
}
pub fn check_slot_types(template: &str, page_schema: &Value) -> Vec<String> {
let re = slot_re();
let mut warnings = Vec::new();
for cap in re.captures_iter(template) {
let full = cap.get(0).expect("capture group exists").as_str();
let path = &cap[1];
let Some(field_schema) = resolve_path(page_schema, path) else {
continue;
};
if !is_open_string(field_schema) {
continue;
}
let context = if full.contains(":style:") {
let prop =
full.rsplit(":style:").next().expect("style delimiter present").trim_end_matches("-->");
format!("style property \"{prop}\"")
} else {
"class attribute".to_string()
};
warnings.push(format!(
"slot \"{path}\" is an open string used as {context}\n\
\x20\x20\x20\x20\x20\x20\x20\x20 hint: consider using an enum or numeric type in your schema"
));
}
warnings
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn page_schema() -> Value {
json!({
"properties": {
"user": {
"properties": {
"name": { "type": "string" },
"role": { "type": "string", "enum": ["admin", "member"] },
"age": { "type": "uint32" }
}
},
"repos": {
"elements": {
"properties": {
"language": { "type": "string" },
"stars": { "type": "uint32" },
"color": { "type": "string", "enum": ["red", "blue", "green"] }
}
}
}
}
})
}
#[test]
fn warns_open_string_in_style() {
let template = r#"<!--seam:user.name:style:font-size--><div>text</div>"#;
let warnings = check_slot_types(template, &page_schema());
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("user.name"));
assert!(warnings[0].contains("style property \"font-size\""));
}
#[test]
fn warns_open_string_in_class() {
let template = r#"<!--seam:user.name:attr:class--><div>text</div>"#;
let warnings = check_slot_types(template, &page_schema());
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("user.name"));
assert!(warnings[0].contains("class attribute"));
}
#[test]
fn no_warning_for_enum_string() {
let template = r#"<!--seam:user.role:attr:class--><div>text</div>"#;
let warnings = check_slot_types(template, &page_schema());
assert!(warnings.is_empty());
}
#[test]
fn no_warning_for_numeric_type() {
let template = r#"<!--seam:user.age:style:font-size--><div>text</div>"#;
let warnings = check_slot_types(template, &page_schema());
assert!(warnings.is_empty());
}
#[test]
fn warns_array_element_open_string() {
let template = r#"<!--seam:repos.$.language:attr:class--><div>text</div>"#;
let warnings = check_slot_types(template, &page_schema());
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("repos.$.language"));
}
#[test]
fn no_warning_for_array_element_enum() {
let template = r#"<!--seam:repos.$.color:attr:class--><div>text</div>"#;
let warnings = check_slot_types(template, &page_schema());
assert!(warnings.is_empty());
}
#[test]
fn no_warning_for_text_slot() {
let template = r#"<!--seam:user.name--><div>text</div>"#;
let warnings = check_slot_types(template, &page_schema());
assert!(warnings.is_empty());
}
#[test]
fn no_warning_for_href_attr() {
let template = r#"<!--seam:user.name:attr:href--><a>link</a>"#;
let warnings = check_slot_types(template, &page_schema());
assert!(warnings.is_empty());
}
#[test]
fn no_warning_without_schema() {
let template = r#"<!--seam:user.name:style:color--><div>text</div>"#;
let empty = json!({});
let warnings = check_slot_types(template, &empty);
assert!(warnings.is_empty());
}
#[test]
fn multiple_warnings() {
let template =
r#"<!--seam:user.name:style:color--><!--seam:repos.$.language:attr:class--><div>text</div>"#;
let warnings = check_slot_types(template, &page_schema());
assert_eq!(warnings.len(), 2);
}
#[test]
fn resolve_path_basic() {
let schema = page_schema();
let resolved = resolve_path(&schema, "user.name").unwrap();
assert_eq!(resolved, &json!({ "type": "string" }));
}
#[test]
fn resolve_path_array() {
let schema = page_schema();
let resolved = resolve_path(&schema, "repos.$.stars").unwrap();
assert_eq!(resolved, &json!({ "type": "uint32" }));
}
#[test]
fn resolve_path_missing() {
let schema = page_schema();
assert!(resolve_path(&schema, "nonexistent.field").is_none());
}
}