use crate::compat::{format, String, Vec};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParamKind {
UInt,
Int,
Axis,
}
impl ParamKind {
pub const fn token(self) -> &'static str {
match self {
Self::UInt => "uint",
Self::Int => "int",
Self::Axis => "axis",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LayoutParam {
pub key: &'static str,
pub kind: ParamKind,
pub required: bool,
pub honoured: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LayoutKindSchema {
pub type_token: &'static str,
pub aliases: &'static [&'static str],
pub params: &'static [LayoutParam],
}
impl LayoutKindSchema {
pub fn matches(&self, token: &str) -> bool {
self.type_token == token || self.aliases.contains(&token)
}
pub fn param(&self, key: &str) -> Option<&'static LayoutParam> {
self.params.iter().find(|param| param.key == key)
}
}
const fn param(key: &'static str, kind: ParamKind, required: bool, honoured: bool) -> LayoutParam {
LayoutParam { key, kind, required, honoured }
}
pub static LAYOUT_KIND_SCHEMAS: &[LayoutKindSchema] = &[
LayoutKindSchema {
type_token: "hbox",
aliases: &["HBox", "horizontal"],
params: &[
param("spacing", ParamKind::UInt, false, true),
param("margin", ParamKind::UInt, false, true),
],
},
LayoutKindSchema {
type_token: "vbox",
aliases: &["VBox", "vertical"],
params: &[
param("spacing", ParamKind::UInt, false, true),
param("margin", ParamKind::UInt, false, true),
],
},
LayoutKindSchema {
type_token: "grid",
aliases: &["Grid"],
params: &[
param("columns", ParamKind::UInt, false, true),
param("spacing", ParamKind::UInt, false, true),
param("margin", ParamKind::UInt, false, true),
],
},
LayoutKindSchema {
type_token: "uniform_grid",
aliases: &["uniformGrid", "uniform-grid"],
params: &[
param("rows", ParamKind::UInt, false, true),
param("columns", ParamKind::UInt, false, true),
param("spacing", ParamKind::UInt, false, true),
param("margin", ParamKind::UInt, false, true),
],
},
LayoutKindSchema {
type_token: "stack",
aliases: &["Stack"],
params: &[param("spacing", ParamKind::UInt, false, false)],
},
LayoutKindSchema {
type_token: "splitter",
aliases: &["Splitter"],
params: &[
param("orientation", ParamKind::Axis, false, true),
param("margin", ParamKind::UInt, false, false),
],
},
LayoutKindSchema {
type_token: "form",
aliases: &["Form"],
params: &[
param("spacing", ParamKind::UInt, false, true),
param("margin", ParamKind::UInt, false, true),
],
},
LayoutKindSchema {
type_token: "flow",
aliases: &["Flow"],
params: &[
param("spacing", ParamKind::Int, false, true),
param("padding", ParamKind::Int, false, true),
param("orientation", ParamKind::Axis, false, true),
],
},
LayoutKindSchema {
type_token: "wrap",
aliases: &["Wrap"],
params: &[
param("spacing", ParamKind::Int, false, true),
param("padding", ParamKind::Int, false, true),
param("orientation", ParamKind::Axis, false, true),
],
},
LayoutKindSchema {
type_token: "flex",
aliases: &["Flex"],
params: &[param("gap", ParamKind::UInt, false, true)],
},
];
pub static CHILD_ATTR_SCHEMA: &[LayoutParam] = &[
param("stretch", ParamKind::UInt, false, true),
param("col", ParamKind::UInt, false, false),
param("row", ParamKind::UInt, false, false),
param("col_span", ParamKind::UInt, false, false),
param("row_span", ParamKind::UInt, false, false),
];
pub fn child_attrs_honoured_by(type_token: &str) -> Vec<&'static str> {
let grid =
matches!(type_token, "grid" | "Grid" | "uniform_grid" | "uniformGrid" | "uniform-grid");
CHILD_ATTR_SCHEMA
.iter()
.filter(|attr| attr.honoured || (grid && attr.key != "stretch"))
.map(|attr| attr.key)
.collect()
}
pub fn validate_child_attrs(
obj: &crate::compat::HashMap<String, String>,
type_token: &str,
path: &[usize],
) -> Vec<LayoutIssue> {
let honoured = child_attrs_honoured_by(type_token);
let mut issues = Vec::new();
for (key, value) in obj {
match CHILD_ATTR_SCHEMA.iter().find(|attr| attr.key == key) {
None => issues.push(LayoutIssue {
path: path.to_vec(),
diagnostic: LayoutDiagnostic::UnknownField { key: key.clone() },
}),
Some(attr) if !honoured.contains(&attr.key) => issues.push(LayoutIssue {
path: path.to_vec(),
diagnostic: LayoutDiagnostic::DroppedField {
key: key.clone(),
reason: "the enclosing layout kind places its children itself",
},
}),
Some(attr) => {
if !value_matches_kind(value, attr.kind) {
issues.push(LayoutIssue {
path: path.to_vec(),
diagnostic: LayoutDiagnostic::WrongKind {
key: key.clone(),
expected: attr.kind,
},
});
}
}
}
}
issues
}
pub fn schema_for_type(token: &str) -> Option<&'static LayoutKindSchema> {
LAYOUT_KIND_SCHEMAS.iter().find(|schema| schema.matches(token))
}
pub fn supported_types() -> Vec<&'static str> {
let mut out = Vec::new();
for schema in LAYOUT_KIND_SCHEMAS {
out.push(schema.type_token);
}
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LayoutDiagnostic {
UnknownType {
declared: String,
},
UnknownField {
key: String,
},
DroppedField {
key: String,
reason: &'static str,
},
WrongKind {
key: String,
expected: ParamKind,
},
}
impl LayoutDiagnostic {
pub fn describe(&self) -> String {
match self {
Self::UnknownType { declared } => format!(
"unknown layout type `{declared}`; supported: {}",
supported_types().join(", ")
),
Self::UnknownField { key } => {
format!("unknown layout field `{key}`: it will be ignored")
}
Self::DroppedField { key, reason } => {
format!("layout field `{key}` is not applied: {reason}")
}
Self::WrongKind { key, expected } => {
format!("layout field `{key}` must be a {}", expected.token())
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LayoutIssue {
pub path: Vec<usize>,
pub diagnostic: LayoutDiagnostic,
}
impl LayoutIssue {
pub fn describe(&self) -> String {
if self.path.is_empty() {
self.diagnostic.describe()
} else {
let rendered: Vec<String> = self.path.iter().map(|index| format!("{index}")).collect();
format!("[{}] {}", rendered.join("."), self.diagnostic.describe())
}
}
}
pub fn validate_layout_object(
obj: &crate::compat::HashMap<String, crate::compat::String>,
path: &[usize],
) -> Vec<LayoutIssue> {
let mut issues = Vec::new();
let Some(type_token) = obj.get("type") else {
return issues;
};
let Some(schema) = schema_for_type(type_token) else {
issues.push(LayoutIssue {
path: path.to_vec(),
diagnostic: LayoutDiagnostic::UnknownType { declared: type_token.clone() },
});
return issues;
};
for (key, value) in obj {
if key == "type" {
continue;
}
match schema.param(key) {
None => issues.push(LayoutIssue {
path: path.to_vec(),
diagnostic: LayoutDiagnostic::UnknownField { key: key.clone() },
}),
Some(param) if !param.honoured => issues.push(LayoutIssue {
path: path.to_vec(),
diagnostic: LayoutDiagnostic::DroppedField {
key: key.clone(),
reason: "this build's constructor for the kind takes no such argument",
},
}),
Some(param) => {
if !value_matches_kind(value, param.kind) {
issues.push(LayoutIssue {
path: path.to_vec(),
diagnostic: LayoutDiagnostic::WrongKind {
key: key.clone(),
expected: param.kind,
},
});
}
}
}
}
issues
}
fn value_matches_kind(value: &str, kind: ParamKind) -> bool {
match kind {
ParamKind::UInt => !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()),
ParamKind::Int => {
let digits = value.strip_prefix('-').unwrap_or(value);
!digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit())
}
ParamKind::Axis => is_axis_token(value),
}
}
pub(crate) fn is_axis_token(value: &str) -> bool {
crate::json::layout::AXIS_TOKENS.iter().any(|(token, _)| *token == value)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compat::HashMap;
fn obj(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs.iter().map(|(k, v)| (String::from(*k), String::from(*v))).collect()
}
#[test]
fn every_schema_is_findable_by_its_tokens() {
for schema in LAYOUT_KIND_SCHEMAS {
assert!(
schema_for_type(schema.type_token).is_some(),
"`{}` is not findable",
schema.type_token
);
for alias in schema.aliases {
assert!(
schema_for_type(alias).is_some(),
"alias `{alias}` of `{}` is not findable",
schema.type_token
);
}
}
}
#[test]
fn the_dropped_parameters_are_marked_unhonoured() {
let stack = schema_for_type("stack").expect("stack");
assert!(!stack.param("spacing").expect("spacing").honoured);
let splitter = schema_for_type("splitter").expect("splitter");
assert!(!splitter.param("margin").expect("margin").honoured);
assert!(splitter.param("orientation").expect("orientation").honoured);
}
#[test]
fn a_valid_declaration_reports_nothing() {
let issues = validate_layout_object(&obj(&[("type", "hbox"), ("spacing", "4")]), &[]);
assert!(issues.is_empty(), "{issues:?}");
}
#[test]
fn an_unknown_type_is_reported() {
let issues = validate_layout_object(&obj(&[("type", "spiral")]), &[1]);
assert_eq!(issues.len(), 1);
assert!(matches!(issues[0].diagnostic, LayoutDiagnostic::UnknownType { .. }));
assert!(issues[0].describe().contains("spiral"));
}
#[test]
fn an_unknown_field_is_reported() {
let issues = validate_layout_object(&obj(&[("type", "hbox"), ("spacng", "4")]), &[]);
assert_eq!(issues.len(), 1);
assert!(
matches!(issues[0].diagnostic, LayoutDiagnostic::UnknownField { ref key } if key == "spacng")
);
}
#[test]
fn a_dropped_field_is_reported_as_dropped() {
let issues = validate_layout_object(&obj(&[("type", "stack"), ("spacing", "4")]), &[]);
assert_eq!(issues.len(), 1);
assert!(
matches!(issues[0].diagnostic, LayoutDiagnostic::DroppedField { ref key, .. } if key == "spacing")
);
assert!(issues[0].describe().contains("not applied"));
}
#[test]
fn a_wrong_kind_is_reported() {
let issues = validate_layout_object(&obj(&[("type", "hbox"), ("spacing", "wide")]), &[]);
assert_eq!(issues.len(), 1);
assert!(matches!(
issues[0].diagnostic,
LayoutDiagnostic::WrongKind { ref expected, .. } if *expected == ParamKind::UInt
));
}
#[test]
fn signed_parameters_accept_negative_values() {
assert!(
validate_layout_object(&obj(&[("type", "hbox"), ("spacing", "-1")]), &[]).len() == 1
);
assert!(
validate_layout_object(&obj(&[("type", "flow"), ("spacing", "-1")]), &[]).is_empty()
);
}
#[test]
fn a_declared_child_key_is_read_by_the_parser() {
use crate::json::ChildLayoutAttrs;
for attr in CHILD_ATTR_SCHEMA {
let json = serde_json::json!({ attr.key: if attr.key == "stretch" { 7 } else { 3 } });
let attrs = ChildLayoutAttrs::from_value(&json);
let read = match attr.key {
"stretch" => attrs.stretch == 7,
"col" => attrs.col == Some(3),
"row" => attrs.row == Some(3),
"col_span" => attrs.col_span == Some(3),
"row_span" => attrs.row_span == Some(3),
other => panic!("`{other}` is declared by the schema but this test cannot read it"),
};
assert!(read, "the schema declares `{}` but the parser did not read it", attr.key);
}
}
#[test]
fn only_a_grid_honours_the_cell_keys() {
let grid = child_attrs_honoured_by("grid");
assert!(grid.contains(&"col_span"), "{grid:?}");
let hbox = child_attrs_honoured_by("hbox");
assert_eq!(hbox, vec!["stretch"], "a box layout places its children itself: {hbox:?}");
let mut child = HashMap::new();
child.insert(String::from("col"), String::from("1"));
assert!(validate_child_attrs(&child, "grid", &[0]).is_empty());
let issues = validate_child_attrs(&child, "hbox", &[0]);
assert_eq!(issues.len(), 1, "{issues:?}");
assert!(matches!(issues[0].diagnostic, LayoutDiagnostic::DroppedField { .. }));
assert_eq!(issues[0].path, vec![0usize]);
}
#[test]
fn a_child_key_the_schema_does_not_declare_is_reported() {
let mut child = HashMap::new();
child.insert(String::from("stretc"), String::from("2"));
let issues = validate_child_attrs(&child, "grid", &[]);
assert_eq!(issues.len(), 1, "{issues:?}");
assert!(matches!(issues[0].diagnostic, LayoutDiagnostic::UnknownField { .. }));
let mut negative = HashMap::new();
negative.insert(String::from("stretch"), String::from("-1"));
let issues = validate_child_attrs(&negative, "vbox", &[]);
assert!(matches!(issues[0].diagnostic, LayoutDiagnostic::WrongKind { .. }), "{issues:?}");
}
#[test]
fn the_supported_types_list_is_not_empty_and_sorted_consistently() {
let types = supported_types();
assert!(types.len() >= 10, "only {} kinds are described", types.len());
assert!(types.contains(&"hbox") && types.contains(&"flex"));
}
}