use crate::compat::{format, String, Vec};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq)]
pub struct ProjectNode {
pub widget: String,
pub path: Vec<usize>,
pub key: String,
pub properties: Vec<(String, Value)>,
pub wire_declaration: Option<Value>,
pub marker_declarations: Vec<(String, Value)>,
pub children: Vec<usize>,
}
impl ProjectNode {
pub fn scalar_properties(&self) -> Vec<(String, Value)> {
self.properties
.iter()
.filter(|(_, value)| !matches!(value, Value::Array(_) | Value::Object(_)))
.cloned()
.collect()
}
pub fn text(&self) -> Option<String> {
for name in ["text", "title"] {
if let Some(value) = self.property(name) {
if let Some(s) = value.as_str() {
if !s.is_empty() {
return Some(String::from(s));
}
}
}
}
None
}
pub fn property(&self, name: &str) -> Option<&Value> {
self.properties.iter().find(|(key, _)| key == name).map(|(_, value)| value)
}
pub fn declared_handlers(&self) -> Vec<(String, String)> {
let Some(value) = self.wire_declaration.as_ref() else {
return Vec::new();
};
let Some(map) = value.as_object() else {
return Vec::new();
};
let mut out: Vec<(String, String)> = map
.iter()
.filter_map(|(event, handler)| {
handler.as_str().map(|h| (String::from(event.as_str()), String::from(h)))
})
.collect();
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
pub fn declared_marker_handlers(&self) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = self
.marker_declarations
.iter()
.filter_map(|(key, value)| value.as_str().map(|h| (key.clone(), String::from(h))))
.collect();
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct JsonProject {
pub root_widget: String,
nodes: Vec<ProjectNode>,
}
impl JsonProject {
pub fn parse(json: &str) -> Result<Self, String> {
let value: Value = serde_json::from_str(json)
.map_err(|e| format!("layout JSON ({} bytes) could not be parsed: {e}", json.len()))?;
let root = value.as_object().ok_or_else(|| {
String::from("JSON root must be an object of the form { \"<widget>\": { .. } }")
})?;
if root.len() != 1 {
return Err(format!(
"JSON root must have exactly one widget key, found {}: {}",
root.len(),
root.keys().cloned().collect::<Vec<_>>().join(", ")
));
}
let (widget, body) = root.iter().next().expect("checked len == 1");
let root_widget = widget.to_lowercase();
let mut nodes = Vec::new();
collect_preorder(&root_widget, body, &mut Vec::new(), 0, &mut nodes)?;
Ok(Self { root_widget, nodes })
}
pub fn child_of(&self, node: &ProjectNode, index: usize) -> Option<&ProjectNode> {
self.nodes.get(*node.children.get(index)?)
}
pub fn root(&self) -> Option<&ProjectNode> {
self.nodes.first()
}
pub fn node(&self, path: &[usize]) -> Option<&ProjectNode> {
self.nodes.iter().find(|n| n.path.as_slice() == path)
}
pub fn walk(&self) -> impl Iterator<Item = &ProjectNode> {
self.nodes.iter()
}
pub fn len(&self) -> usize {
self.nodes.len()
}
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
pub fn descendants(&self, path: &[usize]) -> Vec<Vec<usize>> {
self.nodes
.iter()
.filter(|node| node.path.len() > path.len() && node.path.starts_with(path))
.map(|node| node.path.clone())
.collect()
}
pub fn layout_declaration(
&self,
) -> Option<(crate::json::DeclarativeLayoutKind, Vec<Vec<usize>>)> {
let root = self.root()?;
let layout = root.property("layout")?;
let kind = crate::json::parse_layout_kind(layout).ok()?;
let placed: Vec<Vec<usize>> = root
.children
.iter()
.filter_map(|index| self.nodes.get(*index))
.map(|n| n.path.clone())
.collect();
Some((kind, placed))
}
}
pub const MAX_DEPTH: u32 = 64;
fn collect_preorder(
widget: &str,
body: &Value,
path: &mut Vec<usize>,
depth: u32,
nodes: &mut Vec<ProjectNode>,
) -> Result<(), String> {
if depth > MAX_DEPTH {
return Err(format!(
"widget tree is nested {depth} levels deep, which exceeds the maximum of {MAX_DEPTH}; \
flatten the layout to generate it"
));
}
let obj = body.as_object().ok_or_else(|| format!("`{widget}` value must be a JSON object"))?;
let own_index = nodes.len();
nodes.push(ProjectNode {
widget: String::from(widget),
path: path.clone(),
key: key_from_path(path),
properties: collect_properties(obj),
wire_declaration: obj.get(crate::json::EVENTS_KEY).cloned(),
marker_declarations: crate::json::MARKER_KEYS
.iter()
.filter_map(|(key, _)| obj.get(*key).map(|v| (String::from(*key), v.clone())))
.collect(),
children: Vec::new(),
});
let mut children: Vec<usize> = Vec::new();
let mut child_arrays: Vec<&Value> = Vec::new();
if let Some(direct) = obj.get("children") {
child_arrays.push(direct);
}
if let Some(layout_children) =
obj.get("layout").and_then(|l| l.as_object()).and_then(|l| l.get("children"))
{
child_arrays.push(layout_children);
}
for child_array in child_arrays {
let Some(child_values) = child_array.as_array() else {
continue;
};
for (child_index, child) in child_values.iter().enumerate() {
let Some(child_obj) = child.as_object() else {
continue;
};
if child_obj.len() != 1 {
continue;
}
let (child_widget, child_body) = child_obj.iter().next().expect("checked len == 1");
path.push(child_index);
let before = nodes.len();
collect_preorder(&child_widget.to_lowercase(), child_body, path, depth + 1, nodes)?;
path.pop();
children.push(before);
}
}
nodes[own_index].children = children;
Ok(())
}
fn key_from_path(path: &[usize]) -> String {
if path.is_empty() {
return String::from("root");
}
let mut key = String::from("n");
for index in path {
key.push('_');
key.push_str(&format!("{index}"));
}
key
}
fn collect_properties(obj: &serde_json::Map<String, Value>) -> Vec<(String, Value)> {
let mut out: Vec<(String, Value)> = Vec::new();
for (key, value) in obj {
if matches!(key.as_str(), "id" | "children") {
continue;
}
if crate::json::is_marker_key(key) || key == crate::json::EVENTS_KEY {
continue;
}
out.push((String::from(key.as_str()), value.clone()));
}
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
#[cfg(test)]
mod tests {
use super::*;
fn simple() -> &'static str {
r#"{"window":{"id":"w","title":"T","layout":{"type":"vbox","children":[
{"button":{"id":"b","text":"Go"}},
{"label":{"text":"Hi"}}
]}}}"#
}
#[test]
fn a_document_parses_into_a_pre_order_tree() {
let project = JsonProject::parse(simple()).expect("valid document");
assert_eq!(project.root_widget, "window");
assert_eq!(project.len(), 3, "window + button + label");
assert_eq!(project.root().unwrap().path, Vec::<usize>::new());
let paths: Vec<Vec<usize>> = project.walk().map(|n| n.path.clone()).collect();
assert_eq!(paths, vec![vec![], vec![0], vec![1]]);
}
#[test]
fn children_are_renumbered_to_point_at_the_reordered_nodes() {
let project = JsonProject::parse(simple()).expect("valid document");
let root = project.root().unwrap();
assert_eq!(root.children.len(), 2);
assert_eq!(project.nodes[root.children[0]].widget, "button");
assert_eq!(project.nodes[root.children[1]].widget, "label");
}
#[test]
fn the_id_key_is_the_node_key_and_not_a_property() {
let project = JsonProject::parse(simple()).expect("valid document");
let button = project.node(&[0]).unwrap();
assert_eq!(button.key, "n_0", "the key is path-derived so two runs agree");
assert!(button.property("id").is_none(), "`id` is structure, not a property");
}
#[test]
fn wire_keys_are_not_properties() {
let json =
r#"{"button":{"id":"b","text":"Go","events":{"clicked":"on_go"},"on_close":"on_x"}}"#;
let project = JsonProject::parse(json).expect("valid document");
let button = project.root().unwrap();
assert!(button.property("events").is_none());
assert!(button.property("on_close").is_none());
assert_eq!(button.property("text").and_then(|v| v.as_str()), Some("Go"));
}
#[test]
fn declared_handlers_are_sorted_by_published_name() {
let json = r#"{"button":{"events":{"pressed":"on_p","clicked":"on_c"}}}"#;
let project = JsonProject::parse(json).expect("valid document");
assert_eq!(
project.root().unwrap().declared_handlers(),
vec![
(String::from("clicked"), String::from("on_c")),
(String::from("pressed"), String::from("on_p")),
],
"sorted so the generated file is a function of the document"
);
}
#[test]
fn text_reads_text_then_title() {
let project = JsonProject::parse(r#"{"window":{"title":"Win"}}"#).expect("valid");
assert_eq!(project.root().unwrap().text(), Some(String::from("Win")));
}
#[test]
fn an_empty_text_does_not_become_a_constructor_argument() {
let project = JsonProject::parse(r#"{"button":{"text":""}}"#).expect("valid");
assert_eq!(project.root().unwrap().text(), None);
}
#[test]
fn a_non_scalar_property_is_not_a_scalar_property() {
let json = r#"{"window":{"items":[1,2],"layout":{"type":"vbox"}}}"#;
let project = JsonProject::parse(json).expect("valid");
let scalars = project.root().unwrap().scalar_properties();
assert!(
!scalars.iter().any(|(name, _)| name == "items"),
"an array is structure, not a property value"
);
}
#[test]
fn the_layout_declaration_names_the_kind_and_its_children() {
let project = JsonProject::parse(simple()).expect("valid document");
let (kind, children) = project.layout_declaration().expect("`vbox` must resolve");
assert!(
matches!(kind, crate::json::DeclarativeLayoutKind::VBox { .. }),
"`vbox` must resolve to the VBox kind, got {kind:?}"
);
assert_eq!(children, vec![vec![0], vec![1]]);
}
#[test]
fn an_unknown_layout_kind_is_not_reported_as_placed() {
let json = r#"{"window":{"layout":{"type":"nonsense"}}}"#;
let project = JsonProject::parse(json).expect("valid");
assert!(
project.layout_declaration().is_none(),
"an unknown kind must mean `not placed`, never a guess"
);
}
#[test]
fn a_document_without_a_layout_reports_none() {
let project = JsonProject::parse(r#"{"window":{"title":"T"}}"#).expect("valid");
assert!(project.layout_declaration().is_none());
}
#[test]
fn descendants_are_the_nested_paths() {
let project = JsonProject::parse(simple()).expect("valid document");
assert_eq!(project.descendants(&[]), vec![vec![0], vec![1]]);
assert!(project.descendants(&[0]).is_empty());
}
#[test]
fn a_root_with_two_widget_keys_is_refused() {
let error = JsonProject::parse(r#"{"button":{},"label":{}}"#).unwrap_err();
assert!(error.contains("exactly one widget key"), "got: {error}");
}
#[test]
fn malformed_json_reports_the_parse_error_and_the_size() {
let error = JsonProject::parse("not json").unwrap_err();
assert!(error.contains("could not be parsed"), "got: {error}");
}
#[test]
fn a_non_object_node_body_is_refused() {
let error = JsonProject::parse(r#"{"button": 42}"#).unwrap_err();
assert!(error.contains("must be a JSON object"), "got: {error}");
}
#[test]
fn a_deeply_nested_document_is_refused_rather_than_recursed() {
let mut json = String::from(r#"{"window":{}}"#);
for _ in 0..(MAX_DEPTH + 2) {
json = format!(r#"{{"window":{{"children":[{json}]}}}}"#);
}
let error = JsonProject::parse(&json).unwrap_err();
assert!(
error.contains("exceeds the maximum") || error.contains("recursion limit"),
"a too-deep document must be refused, got: {error}"
);
}
#[test]
fn a_document_just_under_the_bound_is_accepted() {
let mut json = String::from(r#"{"label":{}}"#);
for _ in 0..8 {
json = format!(r#"{{"window":{{"children":[{json}]}}}}"#);
}
let project = JsonProject::parse(&json).expect("eight levels is well inside the bound");
assert_eq!(project.len(), 9, "each nesting level contributes one node");
}
}