use std::collections::HashSet;
use oxc_allocator::Allocator;
use oxc_ast::ast::{BindingPattern, BindingProperty, ObjectPattern, PropertyKey, Statement};
use oxc_parser::Parser;
use oxc_span::SourceType;
use serde_json::Value;
use crate::error::{Error, Result};
use crate::funnel::{DottedPath, TemplatePlaceholder, TemplateToken};
use crate::parse::{Element, Node, SlotKind};
use super::json::read_field;
#[derive(Debug, Clone, Copy)]
pub struct BindSource<'a> {
pub file: &'a str,
pub source: &'a str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DestructureBind {
pub name: String,
pub path: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BindDecl {
None,
Named(String),
Destructure(Vec<DestructureBind>),
}
impl BindDecl {
#[must_use]
pub fn scope_names(&self) -> HashSet<&str> {
match self {
Self::None => HashSet::new(),
Self::Named(name) => HashSet::from([name.as_str()]),
Self::Destructure(binds) => binds.iter().map(|b| b.name.as_str()).collect(),
}
}
#[cfg(test)]
#[must_use]
pub fn destructure_flat(names: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self::Destructure(
names
.into_iter()
.map(|n| {
let name = n.into();
DestructureBind {
path: vec![name.clone()],
name,
}
})
.collect(),
)
}
}
pub fn parse_bind_decl(raw: Option<&str>) -> std::result::Result<BindDecl, String> {
let Some(raw) = raw.map(str::trim).filter(|s| !s.is_empty()) else {
return Ok(BindDecl::None);
};
let wrapped = format!("const {raw} = null;");
let allocator = Allocator::default();
let ret = Parser::new(&allocator, &wrapped, SourceType::mjs()).parse();
if ret.panicked || !ret.diagnostics.is_empty() || ret.program.body.len() != 1 {
return Err(format!(
"data-bind=`{raw}` is not a JS identifier or destructure `{{a, b}}`"
));
}
let Statement::VariableDeclaration(decl) = &ret.program.body[0] else {
return Err(format!(
"data-bind=`{raw}` is not a JS identifier or destructure `{{a, b}}`"
));
};
if decl.declarations.len() != 1 {
return Err(format!(
"data-bind=`{raw}` is not a JS identifier or destructure `{{a, b}}`"
));
}
match &decl.declarations[0].id {
BindingPattern::BindingIdentifier(id) => Ok(BindDecl::Named(id.name.as_str().to_string())),
BindingPattern::ObjectPattern(obj) => {
let mut binds = Vec::new();
collect_object_pattern(raw, obj, &[], &mut binds)?;
if binds.is_empty() {
return Err("empty destructure `data-bind=\"{}\"`".into());
}
Ok(BindDecl::Destructure(binds))
}
BindingPattern::ArrayPattern(_) => Err(format!(
"data-bind=`{raw}`: array destructure is not supported"
)),
BindingPattern::AssignmentPattern(_) => Err(format!(
"data-bind=`{raw}`: default values are not supported"
)),
}
}
fn collect_object_pattern(
raw: &str,
obj: &ObjectPattern<'_>,
path_prefix: &[String],
out: &mut Vec<DestructureBind>,
) -> std::result::Result<(), String> {
if obj.rest.is_some() {
return Err(format!(
"data-bind=`{raw}`: rest bindings are not supported"
));
}
if obj.properties.is_empty() {
return Err(if path_prefix.is_empty() {
"empty destructure `data-bind=\"{}\"`".into()
} else {
format!(
"data-bind=`{raw}`: empty nested destructure for `{}`",
path_prefix.last().unwrap()
)
});
}
for prop in &obj.properties {
collect_binding_property(raw, prop, path_prefix, out)?;
}
Ok(())
}
fn collect_binding_property(
raw: &str,
prop: &BindingProperty<'_>,
path_prefix: &[String],
out: &mut Vec<DestructureBind>,
) -> std::result::Result<(), String> {
if prop.computed {
return Err(format!(
"data-bind=`{raw}`: only plain identifier properties are supported"
));
}
let PropertyKey::StaticIdentifier(key) = &prop.key else {
return Err(format!(
"data-bind=`{raw}`: only plain identifier properties are supported"
));
};
let key_name = key.name.as_str();
let mut path = path_prefix.to_vec();
path.push(key_name.to_string());
match &prop.value {
BindingPattern::BindingIdentifier(id) => {
let bind_name = id.name.as_str();
if bind_name != key_name {
return Err(format!(
"data-bind=`{raw}`: renames are not supported (`{key_name}: {bind_name}`)"
));
}
push_bind(
raw,
out,
DestructureBind {
name: bind_name.to_string(),
path,
},
)
}
BindingPattern::ObjectPattern(nested) => collect_object_pattern(raw, nested, &path, out),
BindingPattern::ArrayPattern(_) => Err(format!(
"data-bind=`{raw}`: array destructure is not supported"
)),
BindingPattern::AssignmentPattern(_) => Err(format!(
"data-bind=`{raw}`: default values are not supported"
)),
}
}
fn push_bind(
raw: &str,
out: &mut Vec<DestructureBind>,
bind: DestructureBind,
) -> std::result::Result<(), String> {
if out.iter().any(|b| b.name == bind.name) {
return Err(format!("data-bind=`{raw}`: duplicate name `{}`", bind.name));
}
out.push(bind);
Ok(())
}
fn read_path<'a>(value: &'a Value, path: &[String]) -> Option<&'a Value> {
let mut cur = value;
for part in path {
cur = read_field(cur, part)?;
}
Some(cur)
}
pub fn bind_context(decl: &BindDecl, value: &Value) -> Value {
match decl {
BindDecl::None => Value::Object(serde_json::Map::new()),
BindDecl::Named(name) => {
let mut map = serde_json::Map::new();
map.insert(name.clone(), value.clone());
Value::Object(map)
}
BindDecl::Destructure(binds) => {
let mut map = serde_json::Map::new();
for bind in binds {
let v = read_path(value, &bind.path).cloned().unwrap_or(Value::Null);
map.insert(bind.name.clone(), v);
}
Value::Object(map)
}
}
}
#[cfg(test)]
pub fn validate_template_binds(
fragment_id: &str,
decl: &BindDecl,
nodes: &[Node],
source: BindSource<'_>,
) -> Result<()> {
validate_template_binds_with_roots(fragment_id, decl, nodes, source, &[])
}
pub fn validate_template_binds_with_roots(
fragment_id: &str,
decl: &BindDecl,
nodes: &[Node],
source: BindSource<'_>,
extra_roots: &[String],
) -> Result<()> {
let scope = decl.scope_names();
let mut scope = scope;
for root in extra_roots {
scope.insert(root.as_str());
}
validate_nodes(fragment_id, &scope, nodes, source)
}
pub fn validate_page_template_binds(
fragment_id: &str,
decl: &BindDecl,
nodes: &[Node],
source: BindSource<'_>,
data_roots: &[String],
) -> Result<()> {
let mut scope = decl.scope_names();
for root in data_roots {
scope.insert(root.as_str());
}
validate_nodes(fragment_id, &scope, nodes, source)?;
validate_mount_nodes(fragment_id, &scope, nodes, source)
}
fn validate_nodes(
fragment_id: &str,
scope: &HashSet<&str>,
nodes: &[Node],
source: BindSource<'_>,
) -> Result<()> {
for node in nodes {
if let Node::Element(el) = node {
validate_element(fragment_id, scope, el, source)?;
}
}
Ok(())
}
fn validate_mount_element(
fragment_id: &str,
scope: &HashSet<&str>,
el: &Element,
source: BindSource<'_>,
) -> Result<()> {
match el.slot_kind() {
Some(SlotKind::FragmentMount(_)) => {
if let Some(each) = el.each_directive() {
validate_data_expr(fragment_id, scope, each.expr(), source)?;
}
}
Some(SlotKind::Named(_) | SlotKind::Default) | None => {}
}
for node in &el.children {
if let Node::Element(child) = node {
validate_mount_element(fragment_id, scope, child, source)?;
}
}
Ok(())
}
fn validate_mount_nodes(
fragment_id: &str,
scope: &HashSet<&str>,
nodes: &[Node],
source: BindSource<'_>,
) -> Result<()> {
for node in nodes {
if let Node::Element(el) = node {
validate_mount_element(fragment_id, scope, el, source)?;
}
}
Ok(())
}
fn validate_element(
fragment_id: &str,
scope: &HashSet<&str>,
el: &Element,
source: BindSource<'_>,
) -> Result<()> {
if let Some(bind) = el.bind_directive() {
let dq = format!("data-bind=\"{bind}\"");
let sq = format!("data-bind='{bind}'");
return Err(Error::at(
source.file,
source.source,
&[&dq, &sq],
"data-bind is only valid on <html> and fragment <template>",
));
}
if let Some(SlotKind::Named(name)) = el.slot_kind() {
let name = name.trim();
if !name.is_empty() {
let dq = format!("name=\"{name}\"");
let sq = format!("name='{name}'");
match DottedPath::parse(name) {
Some(path) => {
ensure_bound(fragment_id, scope, name, path.root(), source, &[&dq, &sq])?;
}
None => return Err(invalid_path_error(source, name, &[&dq, &sq])),
}
}
}
if !el.is_script() && !el.is_style() && !is_statica_link(el) {
for (_k, v) in &el.attrs {
if crate::funnel::has_template_tokens(v) {
for token in crate::funnel::template_tokens(v) {
match token {
TemplateToken::Text(_) => {}
TemplateToken::Placeholder(TemplatePlaceholder::Path(path)) => {
let authored = format!("${{{}}}", path.as_str());
ensure_bound(
fragment_id,
scope,
&authored,
path.root(),
source,
&[&authored],
)?;
}
TemplateToken::Placeholder(TemplatePlaceholder::Expression(expr)) => {
let authored = format!("${{{expr}}}");
return Err(Error::at(
source.file,
source.source,
&[&authored],
format!(
"template placeholder `{authored}` must be a dotted identifier path, not a JS expression"
),
));
}
}
}
}
}
}
validate_nodes(fragment_id, scope, &el.children, source)
}
fn is_statica_link(el: &Element) -> bool {
el.statica_link_rel().is_some()
}
fn validate_data_expr(
fragment_id: &str,
scope: &HashSet<&str>,
expr: &str,
source: BindSource<'_>,
) -> Result<()> {
if expr == "." {
return Ok(());
}
let dq = format!("data-each=\"{expr}\"");
let sq = format!("data-each='{expr}'");
match DottedPath::parse(expr) {
Some(path) => ensure_bound(fragment_id, scope, expr, path.root(), source, &[&dq, &sq]),
None => Err(invalid_path_error(source, expr, &[&dq, &sq])),
}
}
fn ensure_bound(
fragment_id: &str,
scope: &HashSet<&str>,
path: &str,
root: &str,
source: BindSource<'_>,
needles: &[&str],
) -> Result<()> {
if scope.contains(root) {
return Ok(());
}
Err(Error::at(
source.file,
source.source,
needles,
format!(
"fragment `{fragment_id}` uses `{path}` but `{root}` is not bound — declare it in data-bind (e.g. data-bind=\"{root}\" or data-bind=\"{{{root}}}\")"
),
))
}
fn invalid_path_error(source: BindSource<'_>, path: &str, needles: &[&str]) -> Error {
Error::at(
source.file,
source.source,
needles,
format!("`{path}` must be a dotted identifier path, not a JS expression"),
)
}
pub(crate) fn is_identifier(part: &str) -> bool {
let mut chars = part.chars();
matches!(chars.next(), Some(first) if is_identifier_start(first))
&& chars.all(is_identifier_continue)
}
fn is_identifier_start(c: char) -> bool {
c == '_' || c.is_ascii_alphabetic()
}
fn is_identifier_continue(c: char) -> bool {
c == '_' || c.is_ascii_alphanumeric()
}
#[cfg(test)]
mod tests {
use super::*;
use indexmap::IndexMap;
use serde_json::json;
fn el(name: &str, attrs: &[(&str, &str)], children: Vec<Node>) -> Node {
let mut map = IndexMap::new();
for (k, v) in attrs {
map.insert((*k).into(), (*v).into());
}
Node::Element(Element {
name: name.into(),
attrs: map,
children,
void: false,
})
}
fn src(html: &str) -> BindSource<'_> {
BindSource {
file: "ui/button.html",
source: html,
}
}
#[test]
fn parses_named_and_destructure() {
assert_eq!(parse_bind_decl(None).unwrap(), BindDecl::None);
assert_eq!(
parse_bind_decl(Some("button")).unwrap(),
BindDecl::Named("button".into())
);
assert_eq!(
parse_bind_decl(Some("{variant, href}")).unwrap(),
BindDecl::destructure_flat(["variant", "href"])
);
assert_eq!(
parse_bind_decl(Some("{variant: variant, href: href}")).unwrap(),
BindDecl::destructure_flat(["variant", "href"])
);
assert_eq!(
parse_bind_decl(Some("{tag, title, summary: { foo, bar }}")).unwrap(),
BindDecl::Destructure(vec![
DestructureBind {
name: "tag".into(),
path: vec!["tag".into()],
},
DestructureBind {
name: "title".into(),
path: vec!["title".into()],
},
DestructureBind {
name: "foo".into(),
path: vec!["summary".into(), "foo".into()],
},
DestructureBind {
name: "bar".into(),
path: vec!["summary".into(), "bar".into()],
},
])
);
assert!(parse_bind_decl(Some("button.variant")).is_err());
assert!(parse_bind_decl(Some("{}")).is_err());
assert!(parse_bind_decl(Some("{variant: other}")).is_err());
assert!(parse_bind_decl(Some("{summary: {}}")).is_err());
assert!(parse_bind_decl(Some("{foo, nested: { foo }}")).is_err());
}
#[test]
fn named_bind_rejects_magic_fields() {
let html = r#"<a class="button ${variant}" href="${href}"><slot name="label"></slot></a>"#;
let decl = BindDecl::Named("button".into());
let nodes = vec![el(
"a",
&[("class", "button ${variant}"), ("href", "${href}")],
vec![el("slot", &[("name", "label")], vec![])],
)];
let err = validate_template_binds("button", &decl, &nodes, src(html)).unwrap_err();
match err {
Error::Diag(d) => {
assert!(d.message.contains("`variant` is not bound"));
assert_eq!(d.file, "ui/button.html");
assert_eq!((d.line, d.column), (1, 18));
assert!(d.snippet.contains("${variant}"));
assert!(d.snippet.contains('^'));
}
other => panic!("unexpected: {other}"),
}
}
#[test]
fn parses_template_placeholders_as_paths_or_expressions() {
let tokens = crate::funnel::template_tokens("Hi ${item.title} ${a + b} ${ }");
assert!(matches!(
&tokens[1],
TemplateToken::Placeholder(TemplatePlaceholder::Path(path))
if path.as_str() == "item.title"
));
assert!(matches!(
&tokens[3],
TemplateToken::Placeholder(TemplatePlaceholder::Expression(expr))
if expr == "a + b"
));
}
#[test]
fn named_bind_allows_prop_paths() {
let html = r#"<a class="button ${button.variant}" href="${button.href}"></a>"#;
let decl = BindDecl::Named("button".into());
let nodes = vec![el(
"a",
&[
("class", "button ${button.variant}"),
("href", "${button.href}"),
],
vec![],
)];
validate_template_binds("button", &decl, &nodes, src(html)).unwrap();
}
#[test]
fn destructure_allows_listed_names() {
let html = r#"<a class="button ${variant}" href="${href}"><slot name="label"></slot></a>"#;
let decl = BindDecl::destructure_flat(["variant", "href", "label"]);
let nodes = vec![el(
"a",
&[("class", "button ${variant}"), ("href", "${href}")],
vec![el("slot", &[("name", "label")], vec![])],
)];
validate_template_binds("button", &decl, &nodes, src(html)).unwrap();
}
#[test]
fn named_slot_must_be_bound() {
let html = r#"<a class="button ${variant}" href="${href}"><slot name="label"></slot></a>"#;
let decl = BindDecl::destructure_flat(["variant", "href"]);
let nodes = vec![el(
"a",
&[("class", "button ${variant}"), ("href", "${href}")],
vec![el("slot", &[("name", "label")], vec![])],
)];
let err = validate_template_binds("button", &decl, &nodes, src(html)).unwrap_err();
match err {
Error::Diag(d) => {
assert!(d.message.contains("`label` is not bound"));
assert_eq!((d.line, d.column), (1, 51));
assert!(d.snippet.contains("name=\"label\""));
}
other => panic!("unexpected: {other}"),
}
}
#[test]
fn data_t_template_must_be_bound() {
let html = r#"<h1 data-t="${headline}">Fallback</h1>"#;
let decl = BindDecl::destructure_flat(["slug"]);
let nodes = vec![el(
"h1",
&[("data-t", "${headline}")],
vec![Node::Text("Fallback".into())],
)];
let err = validate_template_binds("card", &decl, &nodes, src(html)).unwrap_err();
match err {
Error::Diag(d) => {
assert!(d.message.contains("`headline` is not bound"));
assert_eq!((d.line, d.column), (1, 13));
assert!(d.snippet.contains("${headline}"));
}
other => panic!("unexpected: {other}"),
}
}
#[test]
fn data_t_template_rejects_js_expression() {
let html = r#"<h1 data-t="${a + b}">Fallback</h1>"#;
let decl = BindDecl::destructure_flat(["a", "b"]);
let nodes = vec![el(
"h1",
&[("data-t", "${a + b}")],
vec![Node::Text("Fallback".into())],
)];
let err = validate_template_binds("card", &decl, &nodes, src(html)).unwrap_err();
match err {
Error::Diag(d) => {
assert!(d.message.contains("not a JS expression"));
assert_eq!((d.line, d.column), (1, 13));
assert!(d.snippet.contains("${a + b}"));
}
other => panic!("unexpected: {other}"),
}
}
#[test]
fn bind_context_no_magic_flatten() {
let button = json!({"variant": "primary", "href": "/go"});
let ctx = bind_context(&BindDecl::Named("button".into()), &button);
assert_eq!(ctx, json!({"button": button}));
let destructured = bind_context(
&BindDecl::destructure_flat(["variant", "href"]),
&json!({"variant": "ghost", "href": "/x", "extra": 1}),
);
assert_eq!(destructured, json!({"variant": "ghost", "href": "/x"}));
let nested = bind_context(
&parse_bind_decl(Some("{tag, title, summary: { foo, bar }}")).unwrap(),
&json!({
"tag": "news",
"title": "Hello",
"summary": { "foo": 1, "bar": 2, "extra": 3 },
}),
);
assert_eq!(
nested,
json!({"tag": "news", "title": "Hello", "foo": 1, "bar": 2})
);
}
}