From 2e6ba255980d1664392a8a6793db2e6c9f2d5473 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 14:19:22 -0800 Subject: [PATCH] Commands: parameter schemas cover every route, bound hostile values and translate their errors Check a new call's params against the schema in execute_spec, after the key check and before the parser, and check every command.batch step before the first one runs. menucmds::run_raw and the two other direct runs go through the same check. Saved steps keep keys the schema does not name and have the values it names checked. Limit nesting to 16 levels, cut error text and paths to a fixed length, and copy nothing before it has passed. Treat a whole number written with a fraction as an integer. Check declared defaults and duplicate names once at first use. Give the five schema errors rows in the 22 complete-message catalogs. --- crates/apischema/src/conformance.rs | 174 +++++++++ crates/apischema/src/lib.rs | 446 ++++++++++++++++++---- crates/apischema/src/tests.rs | 330 +++++++++++++++- crates/engine/src/cmd.rs | 8 + crates/engine/src/cmd/brushsym/brushes.rs | 2 +- crates/engine/src/cmd/menucmds.rs | 5 +- crates/engine/src/cmd/params.rs | 34 +- crates/engine/src/cmd/views.rs | 53 +++ crates/engine/src/cmd/xform.rs | 2 +- crates/engine/src/schemas.rs | 237 ++++++++++-- crates/engine/src/session/execute.rs | 5 +- crates/engine/src/tests/build.rs | 7 + crates/ui-egui/src/i18n/ca.tsv | 5 + crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fi.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/hi.tsv | 5 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/ka.tsv | 5 + crates/ui-egui/src/i18n/ko.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/sv.tsv | 5 + crates/ui-egui/src/i18n/tests.rs | 41 ++ crates/ui-egui/src/i18n/tr.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/vi.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + docs/mcp.md | 22 +- docs/target-app-parity.md | 2 +- 37 files changed, 1346 insertions(+), 132 deletions(-) create mode 100644 crates/apischema/src/conformance.rs diff --git a/crates/apischema/src/conformance.rs b/crates/apischema/src/conformance.rs new file mode 100644 index 000000000..878359846 --- /dev/null +++ b/crates/apischema/src/conformance.rs @@ -0,0 +1,174 @@ +//! For tests: a check of JSON Schema 2020-12 documents, and a validator of instances against them, +//! written from the keyword rules of the Core and Validation specifications +//! (). It reads only the JSON and shares no code with the +//! writer it checks. It covers the keywords the writer uses and reports any other keyword that +//! isn't an `x-` annotation. + +use serde_json::Value; + +const TYPES: &[&str] = &["null", "boolean", "object", "array", "number", "string", "integer"]; + +fn unique(items: &[Value]) -> bool { + items.iter().enumerate().all(|(i, x)| items.iter().take(i).all(|y| y != x)) +} + +fn schema_list(items: &Value, at: &str, key: &str) -> Result<(), String> { + let list = items.as_array().filter(|l| !l.is_empty()).ok_or_else(|| format!("{at}: `{key}` must be a nonempty array"))?; + for (i, x) in list.iter().enumerate() { + check_schema(x, &format!("{at}/{key}/{i}"))?; + } + Ok(()) +} + +/// Whether `schema` follows the 2020-12 rules for the keywords it uses, with where it doesn't. +pub fn check_schema(schema: &Value, at: &str) -> Result<(), String> { + let object = match schema { + Value::Bool(_) => return Ok(()), + Value::Object(o) => o, + other => return Err(format!("{at}: a schema must be an object or a boolean, not {other}")), + }; + for (key, value) in object { + let number = || if value.is_number() { Ok(()) } else { Err(format!("{at}: `{key}` must be a number")) }; + match key.as_str() { + "$schema" => { + let uri = value.as_str().ok_or_else(|| format!("{at}: `$schema` must be a string"))?; + if !uri.contains("://") || uri.ends_with('#') { + return Err(format!("{at}: `$schema` must be a normalized URI")); + } + } + "type" => { + let known = |v: &Value| v.as_str().is_some_and(|s| TYPES.contains(&s)); + let ok = known(value) || value.as_array().is_some_and(|l| !l.is_empty() && unique(l) && l.iter().all(known)); + if !ok { + return Err(format!("{at}: `type` must name JSON types, not {value}")); + } + } + "enum" => { + if !value.as_array().is_some_and(|l| !l.is_empty() && unique(l)) { + return Err(format!("{at}: `enum` must be a nonempty array of unique values")); + } + } + "properties" => { + let props = value.as_object().ok_or_else(|| format!("{at}: `properties` must be an object"))?; + for (name, x) in props { + check_schema(x, &format!("{at}/properties/{name}"))?; + } + } + "required" => { + if !value.as_array().is_some_and(|l| unique(l) && l.iter().all(Value::is_string)) { + return Err(format!("{at}: `required` must be an array of unique strings")); + } + } + "additionalProperties" | "items" => check_schema(value, &format!("{at}/{key}"))?, + "prefixItems" | "anyOf" => schema_list(value, at, key)?, + "minimum" | "maximum" | "exclusiveMinimum" | "exclusiveMaximum" => number()?, + "minItems" => { + if value.as_u64().is_none() { + return Err(format!("{at}: `minItems` must be a non-negative integer")); + } + } + "description" => { + if !value.is_string() { + return Err(format!("{at}: `description` must be a string")); + } + } + "deprecated" => { + if !value.is_boolean() { + return Err(format!("{at}: `deprecated` must be a boolean")); + } + } + "default" => { + if !instance_valid(schema, value) { + return Err(format!("{at}: `default` {value} does not validate against its schema")); + } + } + k if k.starts_with("x-") => {} + other => return Err(format!("{at}: unexpected keyword `{other}`")), + } + } + Ok(()) +} + +fn has_type(v: &Value, name: &str) -> bool { + match name { + "null" => v.is_null(), + "boolean" => v.is_boolean(), + "object" => v.is_object(), + "array" => v.is_array(), + "string" => v.is_string(), + "number" => v.is_number(), + "integer" => v.is_i64() || v.is_u64() || v.as_f64().is_some_and(|x| x.fract() == 0.0), + _ => false, + } +} + +/// Whether the instance `v` is valid against `schema`. +pub fn instance_valid(schema: &Value, v: &Value) -> bool { + let s = match schema { + Value::Bool(b) => return *b, + Value::Object(o) => o, + _ => return false, + }; + let type_ok = match s.get("type") { + None => true, + Some(Value::String(t)) => has_type(v, t), + Some(Value::Array(l)) => l.iter().filter_map(Value::as_str).any(|t| has_type(v, t)), + Some(_) => false, + }; + if !type_ok { + return false; + } + if let Some(list) = s.get("enum").and_then(Value::as_array) + && !list.contains(v) + { + return false; + } + if let Some(any) = s.get("anyOf").and_then(Value::as_array) + && !any.iter().any(|x| instance_valid(x, v)) + { + return false; + } + if let Some(x) = v.as_f64() { + let bound = |key: &str| s.get(key).and_then(Value::as_f64); + if bound("minimum").is_some_and(|m| x < m) + || bound("maximum").is_some_and(|m| x > m) + || bound("exclusiveMinimum").is_some_and(|m| x <= m) + || bound("exclusiveMaximum").is_some_and(|m| x >= m) + { + return false; + } + } + if let Some(items) = v.as_array() { + if s.get("minItems").and_then(Value::as_u64).is_some_and(|n| (items.len() as u64) < n) { + return false; + } + let prefix = s.get("prefixItems").and_then(Value::as_array).map_or(&[][..], Vec::as_slice); + if items.iter().zip(prefix).any(|(x, p)| !instance_valid(p, x)) { + return false; + } + if let Some(rest) = s.get("items") + && items.iter().skip(prefix.len()).any(|x| !instance_valid(rest, x)) + { + return false; + } + } + if let Some(object) = v.as_object() { + let required = s.get("required").and_then(Value::as_array).map_or(&[][..], Vec::as_slice); + if required.iter().filter_map(Value::as_str).any(|k| !object.contains_key(k)) { + return false; + } + let props = s.get("properties").and_then(Value::as_object); + for (key, x) in object { + match props.and_then(|p| p.get(key)) { + Some(p) if !instance_valid(p, x) => return false, + Some(_) => {} + None => { + if s.get("additionalProperties").is_some_and(|a| !instance_valid(a, x)) { + return false; + } + } + } + } + } + true +} diff --git a/crates/apischema/src/lib.rs b/crates/apischema/src/lib.rs index 98f39f64f..cb8f75e5c 100644 --- a/crates/apischema/src/lib.rs +++ b/crates/apischema/src/lib.rs @@ -5,12 +5,17 @@ //! //! - [`ParamSchema::validate`], which checks a params object and names the parameter of the first //! problem it finds ([`ParamError`]), and fills in the defaults of omitted values; +//! - [`ParamSchema::definition_problem`], which checks the schema itself; //! - [`ParamSchema::params_text`], the params text a command lists; //! - [`ParamSchema::to_json`], the schema as a JSON object in the shape of a JSON Schema 2020-12 //! object schema (), with VectorCraft's own fields as //! `x-` keywords; //! - [`hash`], a hash of a set of command schemas, which changes whenever any of them changes. //! +//! Checking a value walks the schema, so its depth is the schema's, at most [`MAX_DEPTH`]. An error +//! shows a bounded part of a refused value and of a refused key, and the value is not written out +//! first, so a huge or deeply nested value costs no more to refuse than a small one. +//! //! The crate knows no command and depends on no other VectorCraft crate. #![forbid(unsafe_code)] @@ -21,6 +26,19 @@ use serde_json::{Map, Value, json}; /// The JSON Schema dialect [`ParamSchema::to_json`] names in `$schema`. pub const DIALECT: &str = "https://json-schema.org/draft/2020-12/schema"; +/// How many objects and lists may nest inside a schema's params object. +pub const MAX_DEPTH: usize = 16; + +/// The largest object id [`Type::Id`] takes: 2^53 - 1, the largest integer every JSON reader holds +/// exactly. +pub const MAX_ID: u64 = (1 << 53) - 1; + +/// How many characters of a refused value an error shows. +const SHOWN_CHARS: usize = 40; + +/// How many characters of a key an error shows. +const KEY_CHARS: usize = 64; + /// One end of a number's range. #[derive(Clone, Copy, Debug, PartialEq)] pub enum Bound { @@ -35,7 +53,8 @@ pub enum Bound { pub enum Type { /// A JSON number in the range the bounds give, measured in `unit` (`"pt"`, `"deg"`) when set. Number { min: Option, max: Option, unit: Option<&'static str> }, - /// A JSON number without a fraction (written without a decimal point), in the range given. + /// A JSON number with no fractional part, in the range given. [`ParamSchema::validate`] gives + /// the command `2.0` as `2`. Integer { min: Option, max: Option }, /// `true` or `false`. Boolean, @@ -43,7 +62,7 @@ pub enum Type { String, /// One of the strings listed. Enum(&'static [&'static str]), - /// An object id: a non-negative integer. + /// An object id: an integer from 0 to [`MAX_ID`], read as [`Type::Integer`] reads one. Id, /// A list of object ids. Ids, @@ -116,7 +135,8 @@ pub struct Param { /// The API level the parameter was deprecated at. pub deprecated: Option, /// A value of the wrong type or out of range is not refused here: the command reads it as it - /// did before it had a schema. + /// did before it had a schema. [`ParamSchema::to_json`] still writes the documented type, so a + /// JSON Schema validator refuses a value this passes. pub lenient: bool, } @@ -207,19 +227,38 @@ impl ParamSchema { /// or, when it has none, `params` with the defaults of its omitted values filled in (`None` /// when no default was filled in, so `params` is used as it is). Null params read as `{}`. pub fn validate(&self, params: &Value) -> Result, ParamError> { + self.validate_as(params, false) + } + + /// [`ParamSchema::validate`] for saved steps recorded by this version or an older one: a key + /// the schema doesn't name passes, at any depth, and the command does not read it. Values of + /// the parameters the schema names are checked as `validate` checks them. + pub fn validate_saved(&self, params: &Value) -> Result, ParamError> { + self.validate_as(params, true) + } + + fn validate_as(&self, params: &Value, saved: bool) -> Result, ParamError> { static EMPTY: std::sync::OnceLock> = std::sync::OnceLock::new(); let given = match params { Value::Object(o) => o, Value::Null => EMPTY.get_or_init(Map::new), v => return Err(ParamError { path: String::new(), problem: Problem::NotAnObject { got: shown(v) } }), }; - Ok(check_object("", self.params, given)?.map(Value::Object)) + Ok(check_object(&At::Root, 0, self.params, given, saved)?.map(Value::Object)) + } + + /// What is wrong with the schema itself, or `None`: a parameter name that is empty, repeated + /// or holds `.`, `[` or `]`; a required parameter with a default; a default that isn't JSON or + /// doesn't pass its parameter's type; an enum with no names or a repeated name; a range with + /// an end that isn't finite or that no value can be in; nesting past [`MAX_DEPTH`]. + pub fn definition_problem(&self) -> Option { + object_problem("", self.params, 0) } /// The params text: the params object (`{name?: string, zoom: number > 0}`), then the summary, /// then `→` and what the command returns. pub fn params_text(&self) -> String { - let mut s = object_text(self.params); + let mut s = object_text(self.params, 0); if !self.summary.is_empty() { s.push(' '); s.push_str(self.summary); @@ -234,9 +273,11 @@ impl ParamSchema { /// The schema as a JSON Schema 2020-12 object schema. Defaults written as JSON go in /// `default`, a parameter that accepts null as omitted is an `anyOf` with `{"type": "null"}`, /// and VectorCraft's own fields are `x-since`, `x-deprecatedSince`, `x-lenient`, `x-unit`, - /// `x-kind`, `x-cost`, `x-writes` and `x-returns`. + /// `x-kind`, `x-cost`, `x-writes` and `x-returns`. A JSON Schema validator accepts the values + /// [`ParamSchema::validate`] accepts, except that an `x-lenient` parameter's schema states only + /// the documented type. pub fn to_json(&self) -> Value { - let mut o = object_schema(self.params); + let mut o = object_schema(self.params, 0); o.insert("$schema".into(), DIALECT.into()); if !self.summary.is_empty() { o.insert("description".into(), self.summary.into()); @@ -274,6 +315,8 @@ pub enum Problem { Missing, /// The value is not of the parameter's type: `expected` says what it must be. WrongType { expected: String, got: String }, + /// The schema nests deeper than [`MAX_DEPTH`] here ([`ParamSchema::definition_problem`]). + TooDeep, } impl fmt::Display for ParamError { @@ -285,6 +328,7 @@ impl fmt::Display for ParamError { Problem::Unknown { keys } => write!(f, "unknown parameter `{path}`; it takes: {}", keys.join(", ")), Problem::Missing => write!(f, "missing `{path}`"), Problem::WrongType { expected, got } => write!(f, "`{path}` must be {expected}, not {got}"), + Problem::TooDeep => write!(f, "`{path}` is nested too deeply"), } } } @@ -350,44 +394,161 @@ pub fn hash<'a>(schemas: impl IntoIterator) - }) } -/// A value as an error message shows it: its JSON, cut to 40 characters. +/// `key` as an error message shows it: cut to [`KEY_CHARS`] characters. +pub fn shown_key(key: &str) -> String { + let mut chars = key.chars(); + let head: String = chars.by_ref().take(KEY_CHARS).collect(); + if chars.next().is_some() { format!("{head}…") } else { head } +} + +/// The JSON text of a value, stopping when its characters run out. +struct Shown { + text: String, + left: usize, +} + +impl Shown { + /// Adds `s`; false when it didn't fit. + fn push(&mut self, s: &str) -> bool { + for c in s.chars() { + if self.left == 0 { + return false; + } + self.left -= 1; + self.text.push(c); + } + true + } + + /// Adds the JSON text of string `s`, reading no more of it than can be shown. + fn push_string(&mut self, s: &str) -> bool { + let head: String = s.chars().take(self.left + 1).collect(); + self.push(&Value::from(head).to_string()) + } + + /// Adds the JSON text of `v`; false when it didn't fit. Each object or list writes a bracket, + /// so the characters left bound the recursion. + fn push_value(&mut self, v: &Value) -> bool { + match v { + Value::String(s) => self.push_string(s), + Value::Array(items) => { + self.push("[") && items.iter().enumerate().all(|(i, x)| (i == 0 || self.push(",")) && self.push_value(x)) && self.push("]") + } + Value::Object(o) => { + self.push("{") + && o.iter() + .enumerate() + .all(|(i, (k, x))| (i == 0 || self.push(",")) && self.push_string(k) && self.push(":") && self.push_value(x)) + && self.push("}") + } + other => self.push(&other.to_string()), + } + } +} + +/// A value as an error message shows it: its JSON, cut to [`SHOWN_CHARS`] characters. Only what is +/// shown is read, however large the value is. fn shown(v: &Value) -> String { - let s = v.to_string(); - if s.chars().count() > 40 { format!("{}…", s.chars().take(40).collect::()) } else { s } + let mut out = Shown { text: String::new(), left: SHOWN_CHARS }; + if !out.push_value(v) { + out.text.push('…'); + } + out.text } fn join(path: &str, key: &str) -> String { if path.is_empty() { key.to_string() } else { format!("{path}.{key}") } } -/// The object `given` checked against `params` (unknown keys first, then each parameter in -/// order): with the defaults of omitted values filled in when there are any. -fn check_object(path: &str, params: &'static [Param], given: &Map) -> Result>, ParamError> { - if let Some(key) = given.keys().find(|k| !params.iter().any(|p| p.name == k.as_str())) { - return Err(ParamError { path: join(path, key), problem: Problem::Unknown { keys: params.iter().map(|p| p.name).collect() } }); +/// Where a value is, from the value up to the params object. The path's text is written only for +/// an error. +#[derive(Clone, Copy)] +enum At<'a> { + Root, + Key(&'a At<'a>, &'a str), + Index(&'a At<'a>, usize), +} + +impl At<'_> { + /// The path, `edits[1].name`, with each key as [`shown_key`] shows it. + fn text(&self) -> String { + let mut steps = Vec::new(); + let mut at = self; + while let At::Key(up, _) | At::Index(up, _) = at { + steps.push(at); + at = up; + } + let mut out = String::new(); + for step in steps.into_iter().rev() { + match step { + At::Key(_, key) => { + if !out.is_empty() { + out.push('.'); + } + out.push_str(&shown_key(key)); + } + At::Index(_, i) => out.push_str(&format!("[{i}]")), + At::Root => {} + } + } + out } - let mut filled: Option> = None; +} + +/// Whether `v` nests no deeper than [`MAX_DEPTH`] counted from `depth`. The walk stops at the +/// limit, so it recurses at most [`MAX_DEPTH`] levels and allocates nothing. +fn nests_within(v: &Value, depth: usize) -> bool { + match v { + Value::Array(a) => depth < MAX_DEPTH && a.iter().all(|x| nests_within(x, depth + 1)), + Value::Object(o) => depth < MAX_DEPTH && o.values().all(|x| nests_within(x, depth + 1)), + _ => true, + } +} + +/// The object `given` checked against `params` (unknown keys first, then each parameter in +/// order): with the defaults of omitted values filled in when there are any. `saved` keeps a key +/// the schema doesn't name, unless its value nests past [`MAX_DEPTH`]. +fn check_object( + at: &At<'_>, + depth: usize, + params: &'static [Param], + given: &Map, + saved: bool, +) -> Result>, ParamError> { + let refused = |key: &String, v: &Value| !params.iter().any(|p| p.name == key.as_str()) && (!saved || !nests_within(v, depth)); + if let Some((key, _)) = given.iter().find(|(k, v)| refused(k, v)) { + return Err(ParamError { path: At::Key(at, key).text(), problem: Problem::Unknown { keys: params.iter().map(|p| p.name).collect() } }); + } + // The changes are applied once every value has passed, so no refused value is ever copied. + let mut changes: Vec<(&'static str, Value)> = Vec::new(); for p in params { - let at = || join(path, p.name); + let here = At::Key(at, p.name); match given.get(p.name) { Some(Value::Null) if p.null_omitted => {} Some(v) => { - if let Some(changed) = check_value(&at(), &p.ty, v, p.lenient)? { - filled.get_or_insert_with(|| given.clone()).insert(p.name.to_string(), changed); + if let Some(changed) = check_value(&here, depth, &p.ty, v, p.lenient, saved)? { + changes.push((p.name, changed)); } continue; } - None if p.required => return Err(ParamError { path: at(), problem: Problem::Missing }), + None if p.required => return Err(ParamError { path: here.text(), problem: Problem::Missing }), None => {} } // Omitted, or null read as omitted. if let Omitted::Value(json) = p.omitted && let Ok(v) = serde_json::from_str::(json) { - filled.get_or_insert_with(|| given.clone()).insert(p.name.to_string(), v); + changes.push((p.name, v)); } } - Ok(filled) + if changes.is_empty() { + return Ok(None); + } + let mut filled = given.clone(); + for (name, v) in changes { + filled.insert(name.to_string(), v); + } + Ok(Some(filled)) } /// What a value of type `ty` must be, as an error message says it. @@ -450,68 +611,181 @@ fn in_range(x: f64, min: Option<&Bound>, max: Option<&Bound>) -> bool { low && high } -fn is_id(v: &Value) -> bool { - v.as_u64().is_some() +/// The integer a JSON number with no fractional part stands for, as an integer value when it fits +/// in one (`2.0` is `2`); `None` for anything else. +fn whole(v: &Value) -> Option { + if v.is_i64() || v.is_u64() { + return Some(v.clone()); + } + const TWO_63: f64 = 9_223_372_036_854_775_808.0; + const TWO_64: f64 = 18_446_744_073_709_551_616.0; + let x = v.as_f64().filter(|x| x.fract() == 0.0)?; + Some(if (-TWO_63..TWO_63).contains(&x) { + json!(x as i64) + } else if (0.0..TWO_64).contains(&x) { + json!(x as u64) + } else { + v.clone() + }) +} + +/// Whether the [`whole`] value `w` is in the range. +fn whole_in_range(w: &Value, min: Option, max: Option) -> bool { + match (w.as_i64(), w.as_u64(), w.as_f64()) { + (Some(x), _, _) => min.is_none_or(|m| x >= m) && max.is_none_or(|m| x <= m), + // Past i64::MAX: above any i64 bound. + (None, Some(_), _) => max.is_none(), + // Past what u64 holds: above any i64 bound. + (None, None, Some(x)) if x > 0.0 => max.is_none(), + // Below what i64 holds: under any i64 bound, so it meets every maximum and no minimum. + (None, None, Some(_)) => min.is_none(), + _ => false, + } } fn is_number(v: &Value) -> bool { v.as_f64().is_some_and(f64::is_finite) } -/// `v` checked against `ty` at `path`: with the defaults of omitted values inside it filled in -/// when there are any. A lenient value of the wrong type passes as it is. -fn check_value(path: &str, ty: &Type, v: &Value, lenient: bool) -> Result, ParamError> { - let wrong = || ParamError { path: path.to_string(), problem: Problem::WrongType { expected: expected(ty), got: shown(v) } }; - let ok = match ty { - Type::Number { min, max, .. } => v.as_f64().is_some_and(|x| x.is_finite() && in_range(x, min.as_ref(), max.as_ref())), - Type::Integer { min, max } => match (v.as_i64(), v.as_u64()) { - (Some(x), _) => min.is_none_or(|m| x >= m) && max.is_none_or(|m| x <= m), - // Past i64::MAX: above any i64 bound. - (None, Some(_)) => max.is_none(), - (None, None) => false, - }, - Type::Boolean => v.is_boolean(), - Type::String => v.is_string(), - Type::Enum(names) => v.as_str().is_some_and(|s| names.contains(&s)), - Type::Id => is_id(v), - Type::Ids => v.as_array().is_some_and(|a| a.iter().all(is_id)), - Type::Color => match v { - Value::String(_) | Value::Object(_) => true, - Value::Array(a) => a.len() >= 3 && a.iter().all(is_number), - _ => false, - }, - Type::Point => v.as_array().is_some_and(|a| a.len() == 2 && a.iter().all(is_number)), - Type::Object(params) => { - return match v { - Value::Object(o) => Ok(check_object(path, params, o)?.map(Value::Object)), - _ if lenient => Ok(None), - _ => Err(wrong()), - }; - } - Type::Array(item) => { - let Some(items) = v.as_array() else { return if lenient { Ok(None) } else { Err(wrong()) } }; - let mut filled: Option> = None; - for (i, x) in items.iter().enumerate() { - if let Some(changed) = check_value(&format!("{path}[{i}]"), item, x, lenient)? { - let all = filled.get_or_insert_with(|| items.clone()); - if let Some(slot) = all.get_mut(i) { - *slot = changed; - } - } - } - return Ok(filled.map(Value::Array)); +/// `v` checked against `ty` at `at`: with the defaults of omitted values inside it filled in, and +/// whole numbers written with a fraction made integers, when there are any. A lenient value of +/// the wrong type passes as it is, unless it nests past [`MAX_DEPTH`]. +fn check_value(at: &At<'_>, depth: usize, ty: &Type, v: &Value, lenient: bool, saved: bool) -> Result, ParamError> { + let done = |ok: bool| { + if ok || (lenient && nests_within(v, depth)) { + Ok(None) + } else { + Err(ParamError { path: at.text(), problem: Problem::WrongType { expected: expected(ty), got: shown(v) } }) } }; - if ok || lenient { Ok(None) } else { Err(wrong()) } + let too_deep = || ParamError { path: at.text(), problem: Problem::TooDeep }; + if matches!(ty, Type::Ids | Type::Color | Type::Point) && v.is_array() && depth >= MAX_DEPTH { + return Err(too_deep()); + } + match ty { + Type::Number { min, max, .. } => done(v.as_f64().is_some_and(|x| x.is_finite() && in_range(x, min.as_ref(), max.as_ref()))), + Type::Integer { min, max } => match whole(v).filter(|w| whole_in_range(w, *min, *max)) { + Some(w) => Ok((w != *v).then_some(w)), + None => done(false), + }, + Type::Boolean => done(v.is_boolean()), + Type::String => done(v.is_string()), + Type::Enum(names) => done(v.as_str().is_some_and(|s| names.contains(&s))), + Type::Id => match whole(v).filter(|w| w.as_u64().is_some_and(|x| x <= MAX_ID)) { + Some(w) => Ok((w != *v).then_some(w)), + None => done(false), + }, + Type::Ids => match v.as_array() { + Some(items) => check_items(at, depth, &Type::Id, items, lenient, saved), + None => done(false), + }, + Type::Color => done(match v { + Value::String(_) => true, + Value::Object(_) => nests_within(v, depth), + Value::Array(a) => a.len() >= 3 && a.iter().all(is_number), + _ => false, + }), + Type::Point => done(v.as_array().is_some_and(|a| a.len() == 2 && a.iter().all(is_number))), + Type::Object(params) => match v { + Value::Object(_) if depth >= MAX_DEPTH => Err(too_deep()), + Value::Object(o) => Ok(check_object(at, depth + 1, params, o, saved)?.map(Value::Object)), + _ => done(false), + }, + Type::Array(item) => match v.as_array() { + Some(_) if depth >= MAX_DEPTH => Err(too_deep()), + Some(items) => check_items(at, depth + 1, item, items, lenient, saved), + None => done(false), + }, + } } -/// `{a: number, b?: bool}`: the params object of a params text. -fn object_text(params: &[Param]) -> String { - format!("{{{}}}", params.iter().map(param_text).collect::>().join(", ")) +/// Each of `items` checked against `item`: the list with what [`check_value`] changed, when it +/// changed anything. +fn check_items(at: &At<'_>, depth: usize, item: &Type, items: &[Value], lenient: bool, saved: bool) -> Result, ParamError> { + let mut changes: Vec<(usize, Value)> = Vec::new(); + for (i, x) in items.iter().enumerate() { + if let Some(changed) = check_value(&At::Index(at, i), depth, item, x, lenient, saved)? { + changes.push((i, changed)); + } + } + if changes.is_empty() { + return Ok(None); + } + let mut filled = items.to_vec(); + for (i, v) in changes { + if let Some(slot) = filled.get_mut(i) { + *slot = v; + } + } + Ok(Some(Value::Array(filled))) } -fn param_text(p: &Param) -> String { - let mut s = format!("{}{}: {}", p.name, if p.required { "" } else { "?" }, type_text(&p.ty)); +/// What is wrong with `params`, the object at `path` nested `depth` objects and lists deep. +fn object_problem(path: &str, params: &[Param], depth: usize) -> Option { + if depth > MAX_DEPTH { + return Some(format!("`{path}` nests deeper than {MAX_DEPTH} levels")); + } + for (i, p) in params.iter().enumerate() { + let at = join(path, p.name); + if p.name.is_empty() || p.name.contains(['.', '[', ']']) { + return Some(format!("the parameter name `{at}` is empty or holds a path character")); + } + if params.iter().take(i).any(|q| q.name == p.name) { + return Some(format!("`{at}` is listed twice")); + } + if p.required && p.omitted != Omitted::Nothing { + return Some(format!("`{at}` is required and has a default")); + } + if let Some(problem) = type_problem(&at, &p.ty, depth) { + return Some(problem); + } + if let Omitted::Value(text) = p.omitted { + let Ok(v) = serde_json::from_str::(text) else { return Some(format!("the default of `{at}` isn't JSON: {text}")) }; + if let Err(e) = check_value(&At::Root, 0, &p.ty, &v, false, false) { + return Some(format!("the default of `{at}` doesn't pass its type: {e}")); + } + } + } + None +} + +fn type_problem(at: &str, ty: &Type, depth: usize) -> Option { + let bound = |b: &Bound| match *b { + Bound::Inclusive(x) => (x, false), + Bound::Exclusive(x) => (x, true), + }; + match ty { + Type::Number { min, max, .. } => { + let (low, high) = (min.as_ref().map(bound), max.as_ref().map(bound)); + if low.iter().chain(high.iter()).any(|(x, _)| !x.is_finite()) { + return Some(format!("the range of `{at}` has an end that isn't finite")); + } + match (low, high) { + (Some((a, open_a)), Some((b, open_b))) if a > b || (a == b && (open_a || open_b)) => { + Some(format!("no value can be in the range of `{at}`")) + } + _ => None, + } + } + Type::Integer { min: Some(a), max: Some(b) } if a > b => Some(format!("no value can be in the range of `{at}`")), + Type::Enum([]) => Some(format!("the enum of `{at}` has no names")), + Type::Enum(names) if names.iter().enumerate().any(|(i, n)| names.iter().take(i).any(|m| m == n)) => { + Some(format!("the enum of `{at}` repeats a name")) + } + Type::Object(params) => object_problem(at, params, depth + 1), + Type::Array(_) if depth >= MAX_DEPTH => Some(format!("`{at}` nests deeper than {MAX_DEPTH} levels")), + Type::Array(item) => type_problem(&format!("{at}[]"), item, depth + 1), + _ => None, + } +} + +/// `{a: number, b?: bool}`: the params object of a params text, `depth` objects and lists deep. +fn object_text(params: &[Param], depth: usize) -> String { + format!("{{{}}}", params.iter().map(|p| param_text(p, depth)).collect::>().join(", ")) +} + +fn param_text(p: &Param, depth: usize) -> String { + let mut s = format!("{}{}: {}", p.name, if p.required { "" } else { "?" }, type_text(&p.ty, depth)); match p.omitted { Omitted::Value(v) | Omitted::Note(v) => s.push_str(&format!(" (default: {v})")), Omitted::Nothing => {} @@ -525,8 +799,9 @@ fn param_text(p: &Param) -> String { s } -fn type_text(ty: &Type) -> String { +fn type_text(ty: &Type, depth: usize) -> String { match ty { + Type::Object(_) | Type::Array(_) if depth >= MAX_DEPTH => "…".into(), Type::Number { min, max, unit } => { format!("number{}{}", range_text(min.as_ref(), max.as_ref()), unit.map(|u| format!(" {u}")).unwrap_or_default()) } @@ -540,16 +815,16 @@ fn type_text(ty: &Type) -> String { Type::Ids => "[id…]".into(), Type::Color => "color".into(), Type::Point => "[x, y]".into(), - Type::Object(params) => object_text(params), - Type::Array(item) => format!("[{}…]", type_text(item)), + Type::Object(params) => object_text(params, depth + 1), + Type::Array(item) => format!("[{}…]", type_text(item, depth + 1)), } } /// `{"type": "object", "properties": …, "required": […], "additionalProperties": false}`. -fn object_schema(params: &[Param]) -> Map { +fn object_schema(params: &[Param], depth: usize) -> Map { let mut o = Map::new(); o.insert("type".into(), "object".into()); - o.insert("properties".into(), Value::Object(params.iter().map(|p| (p.name.to_string(), param_schema(p))).collect())); + o.insert("properties".into(), Value::Object(params.iter().map(|p| (p.name.to_string(), param_schema(p, depth))).collect())); let required: Vec = params.iter().filter(|p| p.required).map(|p| p.name.into()).collect(); if !required.is_empty() { o.insert("required".into(), required.into()); @@ -558,8 +833,8 @@ fn object_schema(params: &[Param]) -> Map { o } -fn param_schema(p: &Param) -> Value { - let inner = type_schema(&p.ty); +fn param_schema(p: &Param, depth: usize) -> Value { + let inner = type_schema(&p.ty, depth); let mut o = if p.null_omitted { let mut o = Map::new(); o.insert("anyOf".into(), json!([inner, {"type": "null"}])); @@ -611,8 +886,9 @@ fn bound_keywords(o: &mut Map, min: Option<&Bound>, max: Option<& } } -fn type_schema(ty: &Type) -> Value { +fn type_schema(ty: &Type, depth: usize) -> Value { match ty { + Type::Object(_) | Type::Array(_) if depth >= MAX_DEPTH => Value::Bool(true), Type::Number { min, max, unit } => { let mut o = Map::new(); o.insert("type".into(), "number".into()); @@ -636,8 +912,8 @@ fn type_schema(ty: &Type) -> Value { Type::Boolean => json!({"type": "boolean"}), Type::String => json!({"type": "string"}), Type::Enum(names) => json!({"enum": names}), - Type::Id => json!({"type": "integer", "minimum": 0, "x-kind": "id"}), - Type::Ids => json!({"type": "array", "items": {"type": "integer", "minimum": 0, "x-kind": "id"}}), + Type::Id => json!({"type": "integer", "minimum": 0, "maximum": MAX_ID, "x-kind": "id"}), + Type::Ids => json!({"type": "array", "items": {"type": "integer", "minimum": 0, "maximum": MAX_ID, "x-kind": "id"}}), Type::Color => json!({ "anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "number"}, "minItems": 3}, {"type": "object"}], "x-kind": "color", @@ -649,10 +925,12 @@ fn type_schema(ty: &Type) -> Value { "minItems": 2, "x-kind": "point", }), - Type::Object(params) => Value::Object(object_schema(params)), - Type::Array(item) => json!({"type": "array", "items": type_schema(item)}), + Type::Object(params) => Value::Object(object_schema(params, depth + 1)), + Type::Array(item) => json!({"type": "array", "items": type_schema(item, depth + 1)}), } } +#[cfg(test)] +mod conformance; #[cfg(test)] mod tests; diff --git a/crates/apischema/src/tests.rs b/crates/apischema/src/tests.rs index aac237dc0..74a246fc8 100644 --- a/crates/apischema/src/tests.rs +++ b/crates/apischema/src/tests.rs @@ -43,10 +43,333 @@ fn numbers_are_checked_against_their_range() { fn integers_take_no_fraction_and_keep_their_range() { let any = Type::Integer { min: None, max: None }; assert!(passes(any, json!(-4)) && passes(any, json!(u64::MAX))); - assert!(!passes(any, json!(1.5)) && !passes(any, json!(2.0)) && !passes(any, json!("2"))); + assert!(!passes(any, json!(1.5)) && !passes(any, json!("2"))); let small = Type::Integer { min: Some(1), max: Some(25) }; assert!(passes(small, json!(1)) && passes(small, json!(25))); assert!(!passes(small, json!(0)) && !passes(small, json!(26)) && !passes(small, json!(u64::MAX))); + assert!(passes(small, json!(25.0)) && !passes(small, json!(26.0)) && !passes(small, json!(1e300)) && !passes(small, json!(-1e300))); + let negative = Type::Integer { min: None, max: Some(-1_000_000_000_000_000_000) }; + assert!(passes(negative, json!(-1e20)) && !passes(negative, json!(1e20))); + let lowest = Type::Integer { min: Some(i64::MIN), max: None }; + assert!(!passes(lowest, json!(-1e20)) && passes(lowest, json!(1e20))); +} + +/// JSON Schema counts a number with no fractional part as an integer: `2.0` passes, and the +/// command gets `2`. +#[test] +fn a_whole_number_written_with_a_fraction_is_an_integer() { + let any = Type::Integer { min: None, max: None }; + assert_eq!(one(any).validate(&json!({"x": 2.0})).unwrap(), Some(json!({"x": 2}))); + assert_eq!(one(any).validate(&json!({"x": 2})).unwrap(), None); + assert!(passes(any, json!(1e300))); + assert_eq!(one(Type::Id).validate(&json!({"x": 7.0})).unwrap(), Some(json!({"x": 7}))); + assert_eq!(one(Type::Ids).validate(&json!({"x": [1, 2.0]})).unwrap(), Some(json!({"x": [1, 2]}))); + assert!(passes(Type::Id, json!(MAX_ID)) && !passes(Type::Id, json!(MAX_ID + 1)) && !passes(Type::Id, json!(1e300))); + assert_eq!(one(Type::Ids).validate(&json!({"x": [1, "2"]})).unwrap_err().path, "x[1]"); +} + +/// A value with no finite number in it reads as null in JSON, and a number refuses it; the largest +/// finite numbers pass a number with no range. +#[test] +fn numbers_at_the_edges() { + assert!(!passes(Type::NUMBER, json!(f64::NAN)) && !passes(Type::NUMBER, json!(f64::INFINITY))); + assert!(passes(Type::NUMBER, json!(f64::MAX)) && passes(Type::NUMBER, json!(f64::MIN))); + let unit = Type::Number { min: Some(Bound::Inclusive(0.0)), max: Some(Bound::Inclusive(1.0)), unit: None }; + assert!(passes(unit, json!(0.0)) && passes(unit, json!(1.0)) && !passes(unit, json!(-1e-300)) && !passes(unit, json!(1.0000001))); +} + +/// A list too long to name one path per entry costs one pass, and the error names the entry. +#[test] +fn a_huge_list_is_checked_once_and_names_the_entry() { + let mut ids: Vec = (0..200_000u64).map(Value::from).collect(); + assert_eq!(one(Type::Ids).validate(&json!({ "x": ids })).unwrap(), None); + ids.push(json!("no")); + let e = one(Type::Ids).validate(&json!({ "x": ids })).unwrap_err(); + assert_eq!(e.path, "x[200000]"); + assert!(e.to_string().chars().count() < 120, "{e}"); +} + +/// A list nested far past anything the schema can take: dropping or writing it out recurses per +/// level, which is why the test leaks it rather than dropping it. +fn nested_lists(levels: usize) -> Value { + (0..levels).fold(json!(1), |inner, _| Value::Array(vec![inner])) +} + +/// A refused value is shown from its start only, however deep or long it is, so an error costs +/// the same for a hostile value as for a small one. +#[test] +fn a_refused_value_is_read_only_as_far_as_it_is_shown() { + let deep = nested_lists(500_000); + let params = json!({}); + let mut map = params.as_object().unwrap().clone(); + map.insert("x".into(), deep); + let params = Value::Object(map); + let e = one(Type::String).validate(¶ms).unwrap_err(); + assert!(e.to_string().starts_with("`x` must be a string, not [[[[") && e.to_string().ends_with('…'), "{e}"); + assert!(e.to_string().chars().count() <= 100, "{e}"); + std::mem::forget(params); + + let huge = "x".repeat(5_000_000); + let e = one(Type::Boolean).validate(&json!({ "x": huge })).unwrap_err(); + assert!(e.to_string().chars().count() <= 100, "{}", e.to_string().len()); + assert!(e.to_string().ends_with('…')); + + let e = one(Type::Boolean).validate(&json!({"x": {"a": 1, "b": [1, 2]}})).unwrap_err(); + assert_eq!(e.to_string(), "`x` must be true or false, not {\"a\":1,\"b\":[1,2]}"); + let e = ParamSchema::new(&[], "").validate(&nested_lists(3)).unwrap_err(); + assert_eq!(e.to_string(), "the params must be an object, not [[[1]]]"); +} + +/// A list entry made an integer, and a default filled in, don't copy a value that is still to be +/// checked: copying recurses per level. +#[test] +fn a_list_with_a_change_is_not_copied_before_all_of_it_is_checked() { + let params = Value::Object([("x".to_string(), Value::Array(vec![json!(2.0), nested_lists(500_000)]))].into_iter().collect()); + assert_eq!(one(Type::Ids).validate(¶ms).unwrap_err().path, "x[1]"); + std::mem::forget(params); +} + +#[test] +fn an_object_with_a_default_is_not_copied_before_all_of_it_is_checked() { + const FILLED_FIRST: &[Param] = &[Param::new("a", Type::NUMBER).defaults_to("0"), Param::new("b", Type::String)]; + let params = Value::Object([("b".to_string(), nested_lists(500_000))].into_iter().collect()); + assert_eq!(ParamSchema::new(FILLED_FIRST, "").validate(¶ms).unwrap_err().path, "b"); + std::mem::forget(params); +} + +/// A value the schema doesn't look inside is measured before a default copies the object it is in. +const WITH_DEFAULT: &[Param] = &[Param::new("a", Type::NUMBER).defaults_to("0"), Param::new("b", Type::Color).optional()]; +const LENIENT_AFTER_DEFAULT: &[Param] = &[Param::new("a", Type::NUMBER).defaults_to("0"), Param::new("b", Type::Boolean).optional().lenient()]; + +fn holding(key: &str, v: Value) -> Value { + Value::Object([(key.to_string(), v)].into_iter().collect()) +} + +/// The lists of fixed shape count toward the nesting limit as the other lists do. +#[test] +fn id_lists_points_and_color_lists_count_toward_the_nesting_limit() { + let check = |depth, ty: &Type, v: &Value| check_value(&At::Root, depth, ty, v, false, false); + for (ty, v) in [(Type::Ids, json!([1])), (Type::Point, json!([1, 2])), (Type::Color, json!([1, 0, 0]))] { + assert!(check(MAX_DEPTH - 1, &ty, &v).is_ok(), "{ty:?}"); + assert_eq!(check(MAX_DEPTH, &ty, &v).unwrap_err().problem, Problem::TooDeep, "{ty:?}"); + } + assert!(check(MAX_DEPTH, &Type::Color, &json!("#ff0000")).is_ok()); +} + +#[test] +fn a_saved_retired_key_past_the_nesting_limit_is_refused() { + let params = holding("older", nested_lists(500_000)); + let e = ParamSchema::new(WITH_DEFAULT, "").validate_saved(¶ms).unwrap_err(); + assert_eq!(e.path, "older"); + assert!(matches!(e.problem, Problem::Unknown { .. })); + std::mem::forget(params); + let kept = ParamSchema::new(WITH_DEFAULT, "").validate_saved(&holding("older", nested_lists(MAX_DEPTH))).unwrap().unwrap(); + assert_eq!(kept["a"], json!(0)); + assert_eq!(kept["older"], nested_lists(MAX_DEPTH)); + assert!(ParamSchema::new(WITH_DEFAULT, "").validate_saved(&holding("older", nested_lists(MAX_DEPTH + 1))).is_err()); +} + +#[test] +fn a_color_object_past_the_nesting_limit_is_refused() { + let params = holding("b", holding("k", nested_lists(500_000))); + let e = ParamSchema::new(WITH_DEFAULT, "").validate(¶ms).unwrap_err(); + assert_eq!(e.path, "b"); + assert!(matches!(e.problem, Problem::WrongType { .. })); + std::mem::forget(params); + assert!(ParamSchema::new(WITH_DEFAULT, "").validate(&json!({"b": {"r": 1, "g": 0, "b": 0}})).is_ok()); +} + +#[test] +fn a_lenient_value_past_the_nesting_limit_is_refused() { + let params = holding("b", nested_lists(500_000)); + let e = ParamSchema::new(LENIENT_AFTER_DEFAULT, "").validate(¶ms).unwrap_err(); + assert_eq!(e.path, "b"); + assert!(matches!(e.problem, Problem::WrongType { .. })); + std::mem::forget(params); + assert_eq!(ParamSchema::new(LENIENT_AFTER_DEFAULT, "").validate(&json!({"b": [1]})).unwrap().unwrap()["b"], json!([1])); +} + +/// A key is cut in the error, and an unknown key is refused whatever it holds. +#[test] +fn a_hostile_key_is_cut_and_refused() { + let key = "k".repeat(1_000_000); + let e = one(Type::Boolean).validate(&json!({ key: true, "x": true })).unwrap_err(); + assert_eq!(e.path.chars().count(), KEY_CHARS + 1); + assert!(e.path.ends_with('…') && e.to_string().chars().count() < 200, "{}", e.path.len()); + for key in ["", "a.b", "a[0]", "`", "\u{0}", "名前"] { + let e = one(Type::Boolean).validate(&json!({ key: true, "x": true })).unwrap_err(); + assert_eq!(e.path, key); + assert!(matches!(e.problem, Problem::Unknown { .. })); + } + assert_eq!(shown_key("short"), "short"); +} + +/// A schema that refers to itself, which only a mistake can write: checking values stops at the +/// nesting limit, and the writers finish. +static ENDLESS: Type = Type::Array(&ENDLESS); + +#[test] +fn a_schema_that_refers_to_itself_stops_at_the_nesting_limit() { + let s = one(ENDLESS); + let e = s.validate(&json!({ "x": nested_lists(MAX_DEPTH + 2) })).unwrap_err(); + assert_eq!(e.problem, Problem::TooDeep); + assert!(e.path.starts_with("x[0][0]")); + assert!(s.definition_problem().is_some_and(|p| p.contains("nests deeper"))); + assert!(s.params_text().contains('…')); + assert!(s.to_json()["properties"]["x"].is_object()); + assert_ne!(hash([("a", &s)]), 0); +} + +const BAD_DEFAULT: &[Param] = &[Param::new("x", Type::NUMBER).defaults_to("\"zero\"")]; +const NOT_JSON: &[Param] = &[Param::new("x", Type::NUMBER).defaults_to("{")]; +const TWICE: &[Param] = &[Param::new("x", Type::NUMBER), Param::new("x", Type::String)]; +const EMPTY_NAME: &[Param] = &[Param::new("", Type::NUMBER)]; +const PATH_NAME: &[Param] = &[Param::new("a.b", Type::NUMBER)]; +const NO_NAMES: &[Param] = &[Param::new("x", Type::Enum(&[]))]; +const SAME_NAMES: &[Param] = &[Param::new("x", Type::Enum(&["a", "a"]))]; +const EMPTY_RANGE: &[Param] = &[Param::new("x", Type::Number { min: Some(Bound::Exclusive(1.0)), max: Some(Bound::Inclusive(1.0)), unit: None })]; +const BACKWARD: &[Param] = &[Param::new("x", Type::Integer { min: Some(2), max: Some(1) })]; +const NOT_FINITE: &[Param] = &[Param::new("x", Type::Number { min: None, max: Some(Bound::Inclusive(f64::INFINITY)), unit: None })]; +const REQUIRED_DEFAULT: &[Param] = &[Param { required: true, omitted: Omitted::Value("1"), ..Param::new("x", Type::NUMBER) }]; +const DEEP_FAULT: &[Param] = &[Param::new("edits", Type::Array(&Type::Object(TWICE)))]; + +#[test] +fn a_schema_that_is_wrong_in_itself_is_reported() { + for (params, why) in [ + (BAD_DEFAULT, "doesn't pass its type"), + (NOT_JSON, "isn't JSON"), + (TWICE, "listed twice"), + (EMPTY_NAME, "empty or holds"), + (PATH_NAME, "empty or holds"), + (NO_NAMES, "no names"), + (SAME_NAMES, "repeats"), + (EMPTY_RANGE, "no value can be"), + (BACKWARD, "no value can be"), + (NOT_FINITE, "isn't finite"), + (REQUIRED_DEFAULT, "required"), + (DEEP_FAULT, "`edits[].x` is listed twice"), + ] { + let problem = ParamSchema::new(params, "").definition_problem(); + assert!(problem.as_deref().is_some_and(|p| p.contains(why)), "{params:?}: {problem:?}"); + } + assert_eq!(ParamSchema::new(VIEW, "").definition_problem(), None); + assert_eq!(ParamSchema::new(&[], "").definition_problem(), None); +} + +/// Saved steps keep keys the schema doesn't name, at any depth; the values it names are checked. +#[test] +fn saved_steps_pass_retired_keys_and_check_their_values() { + let edits = one(Type::Array(&Type::Object(EDIT))); + let retired = json!({"x": [{"name": "A", "old": 1}], "older": true}); + assert!(edits.validate(&retired).is_err()); + let filled = edits.validate_saved(&retired).unwrap().unwrap(); + assert_eq!(filled["x"][0]["delete"], json!(false), "defaults are filled in"); + assert_eq!(filled["older"], json!(true), "the keys stay"); + assert_eq!(edits.validate_saved(&json!({"x": [{"name": 5}], "older": true})).unwrap_err().path, "x[0].name"); + assert_eq!(edits.validate_saved(&json!({"older": true})).unwrap_err().problem, Problem::Missing); +} + +/// Every type's JSON form follows the 2020-12 rules, and an independent validator of those rules +/// agrees with [`ParamSchema::validate`] on each value. +#[test] +fn the_json_form_and_the_validator_agree_with_the_2020_12_rules() { + const NULLABLE: &[Param] = &[Param::new("a", Type::String).optional().null_omitted(), Param::new("b", Type::Boolean).defaults_to("true")]; + let types = [ + Type::NUMBER, + Type::Number { min: Some(Bound::Exclusive(0.0)), max: Some(Bound::Inclusive(1.0)), unit: Some("pt") }, + Type::Number { min: Some(Bound::Inclusive(-1.0)), max: Some(Bound::Exclusive(1.0)), unit: None }, + Type::Integer { min: None, max: None }, + Type::Integer { min: Some(1), max: Some(25) }, + Type::Boolean, + Type::String, + Type::Enum(&["a", "b"]), + Type::Id, + Type::Ids, + Type::Color, + Type::Point, + Type::Object(EDIT), + Type::Object(NULLABLE), + Type::Object(&[]), + Type::Array(&Type::NUMBER), + Type::Array(&Type::Object(EDIT)), + ]; + let values = [ + json!(null), + json!(true), + json!(0), + json!(1), + json!(2.0), + json!(25.5), + json!(-1), + json!(-1.0), + json!(1e300), + json!(MAX_ID), + json!(MAX_ID + 1), + json!(u64::MAX), + json!(""), + json!("a"), + json!("c"), + json!([]), + json!([1, 2]), + json!([1, 2, 3]), + json!([1, "2"]), + json!([[1]]), + json!({}), + json!({"name": "A"}), + json!({"name": "A", "delete": false}), + json!({"name": "A", "delete": null}), + json!({"name": "A", "other": 1}), + json!({"a": null, "b": false}), + json!({"a": 5}), + json!([{"name": "A"}]), + json!([{"name": 1}]), + ]; + for ty in types { + let schema = one(ty); + let written = schema.to_json(); + conformance::check_schema(&written, "#").unwrap_or_else(|e| panic!("{ty:?}: {e}\n{written:#}")); + assert_eq!(written["$schema"], "https://json-schema.org/draft/2020-12/schema"); + for v in &values { + let params = json!({ "x": v }); + let local = schema.validate(¶ms).is_ok(); + assert_eq!(local, conformance::instance_valid(&written, ¶ms), "{ty:?} on {v}: {written}"); + } + } + for params in [VIEW, EDIT, NULLABLE] { + let schema = ParamSchema::new(params, "").deprecated(1); + conformance::check_schema(&schema.to_json(), "#").unwrap(); + } +} + +/// The independent checker refuses what the 2020-12 rules refuse, so its passes above mean +/// something. +#[test] +fn the_2020_12_checker_refuses_invalid_schemas() { + for bad in [ + json!(5), + json!({"type": "float"}), + json!({"enum": []}), + json!({"enum": [1, 1]}), + json!({"required": ["a", "a"]}), + json!({"required": [1]}), + json!({"properties": []}), + json!({"properties": {"a": 5}}), + json!({"items": 1}), + json!({"prefixItems": []}), + json!({"anyOf": []}), + json!({"minimum": "0"}), + json!({"minItems": -1}), + json!({"description": 5}), + json!({"deprecated": "yes"}), + json!({"type": "number", "default": "zero"}), + json!({"$schema": "not a uri"}), + json!({"minimun": 0}), + ] { + assert!(conformance::check_schema(&bad, "#").is_err(), "{bad}"); + } + assert!(conformance::instance_valid(&json!({"type": "integer"}), &json!(2.0))); + assert!(!conformance::instance_valid(&json!({"type": "integer"}), &json!(2.5))); + assert!(!conformance::instance_valid(&json!({"prefixItems": [{"type": "number"}], "items": false}), &json!([1, 2]))); } #[test] @@ -135,6 +458,11 @@ fn lenient_parameters_pass_values_of_another_type_to_the_command() { assert_eq!(s.validate(&json!({"on": "yes", "size": "12"})).unwrap(), None); // Unknown keys are still refused. assert!(s.validate(&json!({"of": true})).is_err()); + // The JSON form states the documented type, so a validator of that form refuses what passes here. + let written = s.to_json(); + assert_eq!(written["properties"]["on"]["x-lenient"], true); + assert!(!conformance::instance_valid(&written, &json!({"on": "yes"}))); + assert!(conformance::instance_valid(&written, &json!({"on": true}))); } #[test] diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 06842799f..4042f0a13 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -384,6 +384,14 @@ pub(crate) fn run_spec(s: &mut Session, spec: &CommandSpec, p: &Value) -> Result if plugin::is_command_id(spec.id) { plugin::run_command(s, spec.id, p) } else { (spec.run)(s, p) } } +/// [`run_spec`] for a command run outside [`Session::execute`]: with params checked against the +/// command's schema, and the defaults of omitted values filled in, as `execute` does +/// ([`crate::schemas::check`]). +pub(crate) fn run_checked(s: &mut Session, spec: &CommandSpec, p: &Value) -> Result { + let checked = crate::schemas::check(spec, p, s.replaying())?; + run_spec(s, spec, checked.as_ref().unwrap_or(p)) +} + // ---------- param helpers ---------- pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError { diff --git a/crates/engine/src/cmd/brushsym/brushes.rs b/crates/engine/src/cmd/brushsym/brushes.rs index 9cc52c82c..cf5dedb6b 100644 --- a/crates/engine/src/cmd/brushsym/brushes.rs +++ b/crates/engine/src/cmd/brushsym/brushes.rs @@ -510,7 +510,7 @@ pub(super) fn expand_brush(s: &mut Session, p: &Value) -> Result { pub(super) fn brush_freehand(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "brush").map(str::to_string).filter(|n| s.doc().is_ok_and(|st| brush::find(&st.doc, n).is_some())); let spec = find_command("path.freehand").ok_or_else(|| EngineError::UnknownCommand("path.freehand".into()))?; - let r = (spec.run)(s, p)?; + let r = crate::cmd::run_checked(s, spec, p)?; let (Some(name), Some(id)) = (name, id_param(&r, "id")) else { return Ok(r) }; s.edit("Paintbrush", |d, _| { if let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut()) { diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 6d8e82e8d..c41e9ce29 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -203,10 +203,11 @@ pub(crate) fn whole_or(p: &Value, key: &str, default: u64, cmd: &str) -> Result< } } -/// Run a command's implementation without the enablement check or journaling. +/// Run a command's implementation without the enablement check or journaling. A command with a +/// parameter schema runs with its params checked ([`super::run_checked`]). pub(crate) fn run_raw(s: &mut Session, id: &str, p: &Value) -> Result { let c = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; - super::run_spec(s, c, p) + super::run_checked(s, c, p) } /// Merge the undo entries pushed since the history had `from` entries into one labelled step. diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index 7d58a9043..c9f51e065 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -18,13 +18,24 @@ //! `effect.setParams` answer a key the effect doesn't take, or a value of another type than its //! default, with a parameter error ([`check_effect_params`]). //! +//! A command with a parameter schema (`CommandSpec::schema`) is also checked against the schema +//! before it runs ([`crate::schemas::check`]): the schema refuses a value of the wrong type or out +//! of range, a missing required parameter and an unknown key at any depth, and fills in declared +//! defaults, so the command runs with the filled-in params. The check here still runs first for +//! such a command, so a misspelled top-level key gets the same error from either, and a +//! `command.batch` or an `action.batch` checks each step's schema before the first step runs. The +//! keys of commands without a schema, and the options of exports and effects, are checked only as +//! this module describes. +//! //! Saved steps play through [`Session::replay`] instead: a journal or an action recorded by an older //! version, with a key a command no longer reads or an effect parameter it no longer takes, still -//! plays. Even there, an effect's `color` that the effect can't read and a Levels or Curves +//! plays. A key a schema doesn't name passes there too, and the values of the parameters it names +//! are still checked. Even there, an effect's `color` that the effect can't read and a Levels or Curves //! `channel` that the effect doesn't have are refused by `effect.apply` and `effect.setParams` //! themselves ([`check_effect_color`], [`check_effect_channel`]). use serde_json::Value; +use vectorcraft_apischema::shown_key; use super::{bad, command_specs, find_command}; use crate::Result; @@ -239,9 +250,11 @@ pub fn check_keys(id: &str, doc: &'static str, params: &Value) -> Result<()> { /// The parameter error of command `id` for `key`, which isn't one of `keys`: with the nearest of /// them, or all of them. pub(crate) fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError { + let near = nearest(key, keys); + let key = shown_key(key); bad( id, - match nearest(key, keys) { + match near { Some(near) => format!("unknown parameter `{key}`; did you mean `{near}`?"), None if keys.is_empty() => format!("unknown parameter `{key}`; the command takes no parameters"), None => format!("unknown parameter `{key}`; the command takes: {}", keys.join(", ")), @@ -522,6 +535,7 @@ fn check_option_tree(id: &str, within: &str, given: &serde_json::Map format!("did you mean `{within}{near}`?"), None => format!("`{}` takes: {}", within.trim_end_matches('.'), keys.join(", ")), }; + let key = shown_key(key); return Err(bad(id, format!("unknown parameter `{within}{key}`; {why}"))); } (Some(Value::Object(inner)), Value::Object(value)) => check_option_tree(id, &format!("{within}{key}."), value, inner)?, @@ -538,9 +552,11 @@ fn check_option_tree(id: &str, within: &str, given: &serde_json::Map, keys: &[&str], why_not: &str, trees: &Value) -> Result<()> { for (key, value) in given { if !keys.contains(&key.as_str()) { + let near = nearest(key, keys); + let key = shown_key(key); return Err(bad( id, - match nearest(key, keys) { + match near { Some(near) => format!("unknown parameter `{within}{key}`; did you mean `{within}{near}`?"), None => format!("unknown parameter `{within}{key}`; {why_not}"), }, @@ -647,6 +663,7 @@ fn check_screen_rows(id: &str, within: &str, p: &serde_json::Map) Some(near) => format!("did you mean `{within}settings.{near}`?"), None => format!("`{within}settings` takes: {}", formats.join(", ")), }; + let format = shown_key(format); return Err(bad(id, format!("unknown parameter `{within}settings.{format}`; {why}"))); } if let Value::Object(options) = options { @@ -758,10 +775,13 @@ fn check_params_in(id: &str, params: &Value, in_batch: bool) -> Result<()> { return Ok(()); } check_keys(id, spec.params, params)?; + if in_batch { + crate::schemas::check(spec, params, false)?; + } if matches!(id, "command.batch" | "action.batch") && !in_batch { for step in params.get("commands").and_then(Value::as_array).into_iter().flatten() { - if let (Some(step_id), Some(step_params)) = (step.get("command").and_then(Value::as_str), step.get("params")) { - check_params_in(step_id, step_params, true)?; + if let Some(step_id) = step.get("command").and_then(Value::as_str) { + check_params_in(step_id, step.get("params").unwrap_or(&Value::Null), true)?; } } } @@ -778,8 +798,8 @@ pub(crate) fn nearest_command(id: &str) -> Option<&'static str> { /// The error for `id`, which names no command: with the nearest command id, when one is near. pub(crate) fn unknown_command(id: &str) -> crate::EngineError { match nearest_command(id) { - Some(near) => crate::EngineError::UnknownCommandNear(id.to_string(), near.to_string()), - None => crate::EngineError::UnknownCommand(id.to_string()), + Some(near) => crate::EngineError::UnknownCommandNear(shown_key(id), near.to_string()), + None => crate::EngineError::UnknownCommand(shown_key(id)), } } diff --git a/crates/engine/src/cmd/views.rs b/crates/engine/src/cmd/views.rs index 5bc3b85a3..90f2656d7 100644 --- a/crates/engine/src/cmd/views.rs +++ b/crates/engine/src/cmd/views.rs @@ -387,6 +387,59 @@ mod tests { } } + /// A command run by another command's implementation has its params checked against its schema + /// too, with nothing changed when they are refused. + #[test] + fn a_raw_run_is_checked_against_the_schema() { + let mut s = session_with_views(&[]); + let e = super::super::menucmds::run_raw(&mut s, "view.saved.new", &json!({"center": [0, 0], "zoom": -1})).unwrap_err().to_string(); + assert!(e.contains("view.saved.new") && e.contains("`zoom` must be a number > 0, not -1"), "{e}"); + assert!(s.doc().unwrap().doc.views.is_empty()); + assert!(s.doc().unwrap().history.undo.is_empty()); + super::super::menucmds::run_raw(&mut s, "view.saved.new", &json!({"center": [0, 0], "zoom": 1})).unwrap(); + assert_eq!(s.doc().unwrap().doc.views.len(), 1); + } + + /// An edit list with nothing in it and an edit that leaves a view as it is add no undo step; + /// names with path characters are only names; a key with one is refused and named. + #[test] + fn edit_views_edges() { + let mut s = session_with_views(&["a.b[0]", "plain"]); + let steps = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("view.saved.edit", &json!({"edits": []})).unwrap()["views"].as_array().map(Vec::len), Some(2)); + s.execute("view.saved.edit", &json!({"name": "plain", "newName": "plain"})).unwrap(); + s.execute("view.saved.edit", &json!({"edits": [{"name": "plain", "newName": "plain"}]})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + s.execute("view.saved.edit", &json!({"name": "a.b[0]", "newName": "c[1].d"})).unwrap(); + assert_eq!(names(&mut s), ["c[1].d", "plain"]); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + let e = s.execute("view.saved.edit", &json!({"a.b": 1})).unwrap_err().to_string(); + assert!(e.contains("`a.b`"), "{e}"); + let e = s.execute("view.saved.edit", &json!({"edits": [{"name": "plain", "a.b": 1}]})).unwrap_err().to_string(); + assert!(e.contains("`edits[0].a.b`; it takes: name, newName, delete"), "{e}"); + // Repeated and unknown names keep the command's own errors. + let e = s.execute("view.saved.edit", &json!({"edits": [{"name": "plain", "newName": "c[1].d"}]})).unwrap_err().to_string(); + assert!(e.contains("exists"), "{e}"); + let e = s.execute("view.saved.edit", &json!({"name": "", "delete": true})).unwrap_err().to_string(); + assert!(e.contains("no view named"), "{e}"); + } + + /// A refused call during a drag leaves the drag running and adds no undo step; canceling the + /// drag then leaves the history as it was. + #[test] + fn a_refused_call_leaves_an_interaction_alone() { + let mut s = session_with_views(&["A"]); + let steps = s.doc().unwrap().history.undo.len(); + s.begin_interaction("Move").unwrap(); + assert!(s.execute("view.saved.new", &json!({"name": 5, "center": [0, 0], "zoom": 1})).is_err()); + assert!(s.doc().unwrap().interaction.is_some()); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + s.cancel_interaction().unwrap(); + assert!(s.doc().unwrap().interaction.is_none()); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + assert_eq!(names(&mut s), ["A"]); + } + /// Edit Views… is off until the document has a view. #[test] fn edit_views_needs_a_saved_view() { diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 7af7cf1f8..903859865 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -368,7 +368,7 @@ fn sample_color(s: &mut Session, p: &Value) -> Result { q["ids"] = ids.clone(); } let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; - let result = (spec.run)(s, &q)?; + let result = super::run_checked(s, spec, &q)?; if clear_stroke { s.paint.stroke = Paint::None; } diff --git a/crates/engine/src/schemas.rs b/crates/engine/src/schemas.rs index 114d37fa0..d2cca1e19 100644 --- a/crates/engine/src/schemas.rs +++ b/crates/engine/src/schemas.rs @@ -2,21 +2,28 @@ //! belong to. //! //! A command with a schema (`CommandSpec::schema`) has its params checked against it by -//! [`crate::Session::execute`] before it runs ([`check`]): a value of the wrong type, out of range or -//! not in its list, a missing required parameter or an unknown key is refused with a parameter +//! [`crate::Session::execute`] before it runs ([`check`]), and by the steps that run a command's +//! implementation directly ([`crate::cmd::run_checked`]): a value of the wrong type, out of range +//! or not in its list, a missing required parameter or an unknown key is refused with a parameter //! error naming the parameter, and the command does not run, so the document and its undo history -//! stay as they were. An omitted value with a default takes it. The command's `params` text is the -//! text its schema writes, and `engine.commands` lists the schema of each command that has one. +//! stay as they were. An omitted value with a default takes it. The steps of a `command.batch` or +//! an `action.batch` are checked before the first one runs. Saved steps +//! ([`crate::Session::replay`]) keep their keys a schema doesn't name, and their values are checked. +//! The command's `params` text is the text its schema writes, and `engine.commands` lists the schema +//! of each command that has one. //! -//! [`API_VERSION`] goes up by one whenever a schema changes, and [`SCHEMA_HASHES`] gains a row with -//! the new level and [`schema_hash`]: a test fails while the newest row does not match the schemas. +//! [`API_VERSION`] goes up by one whenever a schema of a built-in command changes, and +//! [`SCHEMA_HASHES`] gains a row with the new level and [`schema_hash`]: a test fails while the +//! newest row does not match the schemas. Commands that plug-ins add have no schema. + +use std::sync::OnceLock; use serde_json::Value; -use vectorcraft_apischema::Problem; +use vectorcraft_apischema::{ParamError, Problem}; -use crate::Result; use crate::cmd::params::unknown; use crate::cmd::{CommandSpec, bad, command_specs}; +use crate::{EngineError, Result}; /// The API level of the commands' parameters. pub const API_VERSION: u32 = 1; @@ -24,31 +31,61 @@ pub const API_VERSION: u32 = 1; /// [`schema_hash`] at each API level, oldest first; the last row is [`API_VERSION`]'s. pub const SCHEMA_HASHES: &[(u32, u64)] = &[(1, 0x7552_6395_fcbb_1b90)]; -/// The hash of every command's schema ([`vectorcraft_apischema::hash`]). +/// The hash of every built-in command's schema ([`vectorcraft_apischema::hash`]). pub fn schema_hash() -> u64 { vectorcraft_apischema::hash(command_specs().iter().filter_map(|c| Some((c.id, c.schema?)))) } +/// Each built-in schema that is wrong in itself, with why ([`vectorcraft_apischema::ParamSchema::definition_problem`]). +/// Worked out once, the first time a command with a schema runs. +fn definition_problems() -> &'static [(&'static str, String)] { + static PROBLEMS: OnceLock> = OnceLock::new(); + PROBLEMS.get_or_init(|| command_specs().iter().filter_map(|c| Some((c.id, c.schema?.definition_problem()?))).collect()) +} + /// `params` checked against the schema of `spec`: a parameter error under the command's id for the /// first problem, or the params with the defaults of omitted values filled in (`None` when the -/// command has no schema or nothing was filled in, so `params` is used as it is). An unknown -/// top-level key is reported as the commands without a schema report it, with the nearest key. -pub(crate) fn check(spec: &CommandSpec, params: &Value) -> Result> { +/// command has no schema or nothing was filled in, so `params` is used as it is). A command with no +/// schema costs one branch. `saved`: the params of a saved step, whose keys the schema doesn't name +/// pass. A schema that is wrong in itself is an internal error, and its command does not run. +pub(crate) fn check(spec: &CommandSpec, params: &Value, saved: bool) -> Result> { let Some(schema) = spec.schema else { return Ok(None) }; - schema.validate(params).map_err(|e| match &e.problem { - Problem::Unknown { keys } if !e.path.contains(['.', '[']) => unknown(spec.id, &e.path, keys), - _ => bad(spec.id, e.to_string()), - }) + if let Some((_, why)) = definition_problems().iter().find(|(id, _)| *id == spec.id) { + return Err(EngineError::Internal { cmd: spec.id.into(), msg: why.clone() }); + } + let checked = if saved { schema.validate_saved(params) } else { schema.validate(params) }; + checked.map_err(|e| param_error(spec.id, &e)) } +/// The parameter error of command `id` for `e`, in the words the message catalogs translate. An +/// unknown top-level key is reported as the commands without a schema report it, with the nearest +/// key. +fn param_error(id: &str, e: &ParamError) -> EngineError { + let path = &e.path; + match &e.problem { + Problem::NotAnObject { got } => bad(id, format!("the params must be an object, not {got}")), + Problem::Unknown { keys } if !path.contains(['.', '[']) => unknown(id, path, keys), + Problem::Unknown { keys } if keys.is_empty() => bad(id, format!("unknown parameter `{path}`; the object takes no keys")), + Problem::Unknown { keys } => bad(id, format!("unknown parameter `{path}`; it takes: {keys}", keys = keys.join(", "))), + Problem::Missing => bad(id, format!("missing `{path}`")), + Problem::WrongType { expected, got } => bad(id, format!("`{path}` must be {expected}, not {got}")), + Problem::TooDeep => EngineError::Internal { cmd: id.into(), msg: e.to_string() }, + } +} + +#[cfg(test)] +#[path = "../../apischema/src/conformance.rs"] +mod conformance; + #[cfg(test)] mod tests { use proptest::prelude::*; use serde_json::{Map, Value, json}; use vectorcraft_apischema::{Bound, Omitted, Param, ParamSchema, Type}; + use super::conformance::{check_schema, instance_valid}; use super::*; - use crate::{EngineError, Session}; + use crate::Session; fn schemas() -> Vec<(&'static str, &'static ParamSchema)> { command_specs().iter().filter_map(|c| Some((c.id, c.schema?))).collect() @@ -75,29 +112,36 @@ mod tests { assert!(SCHEMA_HASHES.windows(2).all(|w| w[0].0 < w[1].0), "the API levels go up"); } - /// Every default written as JSON parses and passes its parameter's type, and no parameter or - /// command is newer than the API level. + /// Every schema is sound in itself ([`ParamSchema::definition_problem`]), so no command is + /// refused for its schema, and no parameter or command is newer than the API level. #[test] - fn defaults_parse_and_levels_are_known() { + fn schemas_are_sound_and_levels_are_known() { fn walk(id: &str, params: &[Param]) { for p in params { assert!(p.since >= 1 && p.since <= API_VERSION, "{id}.{}", p.name); - if let Omitted::Value(text) = p.omitted { - let v: Value = serde_json::from_str(text).unwrap_or_else(|e| panic!("{id}.{}: {e}", p.name)); - let one = ParamSchema::new(Box::leak(Box::new([Param::new(p.name, p.ty)])), ""); - one.validate(&json!({ p.name: v })).unwrap_or_else(|e| panic!("{id}: {e}")); - } if let Type::Object(inner) | Type::Array(&Type::Object(inner)) = p.ty { walk(id, inner); } } } + assert_eq!(definition_problems(), &[]); for (id, schema) in schemas() { assert!(schema.since >= 1 && schema.since <= API_VERSION, "{id}"); walk(id, schema.params); } } + /// Each command's defaults are of its parameter's type (an omitted value takes them), with + /// which ones are filled in. + #[test] + fn declared_defaults_are_filled_in() { + let new_view = command_specs().iter().find(|c| c.id == "view.saved.new").and_then(|c| c.schema).unwrap(); + let filled = new_view.validate(&json!({"center": [1, 2], "zoom": 1})).unwrap().unwrap(); + assert_eq!(filled["rotation"], json!(0)); + assert!(filled.get("name").is_none(), "the command works out the name"); + assert!(new_view.param("rotation").is_some_and(|p| matches!(p.omitted, Omitted::Value("0")))); + } + /// A refusal from the schema names the parameter, and the command does not run. #[test] fn a_refused_value_names_the_parameter_under_the_command() { @@ -111,6 +155,149 @@ mod tests { assert!(s.doc().unwrap().history.undo.is_empty()); } + /// The key check runs before the schema, so a misspelled key is the error even when a value is + /// bad too, and the schema reports the value when the keys are right. + #[test] + fn a_misspelled_key_is_reported_before_a_bad_value() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let e = s.execute("view.saved.new", &json!({"center": [0, 0], "zoom": -1, "rotaton": 5})).unwrap_err().to_string(); + assert!(e.contains("did you mean `rotation`?"), "{e}"); + let e = s.execute("view.saved.new", &json!({"center": "here", "zoom": -1})).unwrap_err().to_string(); + assert!(e.contains("`center` must be [x, y] (two numbers), not \"here\""), "{e}"); + } + + /// Saved steps keep a retired key, at the top level and inside `edits`, and a bad value is + /// still refused, with no undo step; the journal keeps the params as given. + #[test] + fn saved_steps_keep_retired_keys_and_check_values() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let retired = json!({"name": "A", "center": [1, 2], "zoom": 2, "fitArt": true}); + assert!(s.execute("view.saved.new", &retired).is_err()); + s.replay("view.saved.new", &retired).unwrap(); + assert_eq!(s.doc().unwrap().doc.views[0].name, "A"); + let last = s.journal.last().unwrap(); + assert_eq!(last.1, retired, "the journal has the params as given, with no default filled in"); + s.replay("view.saved.edit", &json!({"edits": [{"name": "A", "newName": "B", "was": 1}]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.views[0].name, "B"); + assert!(s.execute("view.saved.edit", &json!({"edits": [{"name": "B", "newName": "C", "was": 1}]})).is_err()); + let steps = s.doc().unwrap().history.undo.len(); + let e = s.replay("view.saved.new", &json!({"center": [1, 2], "zoom": 0, "fitArt": true})).unwrap_err().to_string(); + assert!(e.contains("`zoom`"), "{e}"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + // A frontend that replays through its own path sets the flag, and gets the same rule. + let outer = s.set_replaying(true); + s.execute("view.saved.new", &json!({"name": "C", "center": [1, 2], "zoom": 2, "fitArt": true})).unwrap(); + s.set_replaying(outer); + } + + /// A batch step a schema refuses stops the batch before its first step runs. + #[test] + fn a_batch_is_checked_against_the_schemas_before_its_first_step() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let step = |p: Value| json!({"command": "view.saved.new", "params": p}); + for (last, named) in [ + (step(json!({"center": [0, 0], "zoom": 0})), "`zoom`"), + (step(json!({"center": [0, 0]})), "`zoom`"), + (json!({"command": "view.saved.new"}), "`center`"), + ] { + let batch = json!({"commands": [step(json!({"name": "First", "center": [0, 0], "zoom": 1})), last]}); + let e = s.execute("command.batch", &batch).unwrap_err().to_string(); + assert!(e.contains("view.saved.new") && e.contains(named), "{e}"); + assert!(s.doc().unwrap().doc.views.is_empty(), "the first step did not run: {e}"); + assert!(s.doc().unwrap().history.undo.is_empty()); + } + } + + /// Values and keys far larger than any a command takes are refused with a short error and no + /// panic. Nested values are leaked because dropping one recurses per level. + #[test] + fn hostile_values_are_refused_with_a_short_error() { + fn deep(levels: usize) -> Value { + (0..levels).fold(json!(1), |inner, _| Value::Array(vec![inner])) + } + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let huge = "z".repeat(2_000_000); + for params in [ + json!({ huge.as_str(): 1, "center": [0, 0], "zoom": 1 }), + json!({"center": [0, 0], "zoom": huge}), + json!({"center": huge, "zoom": 1}), + json!({"center": [0, 0], "zoom": 1, "name": [huge]}), + json!({"center": [0, 0], "zoom": f64::MAX, "rotation": f64::INFINITY}), + ] { + let e = s.execute("view.saved.new", ¶ms).unwrap_err().to_string(); + assert!(e.chars().count() < 400, "{} chars", e.chars().count()); + } + let e = s.execute("view.saved.edit", &json!({"edits": [{"name": "A", huge.as_str(): 1}]})).unwrap_err().to_string(); + assert!(e.chars().count() < 400, "{} chars", e.chars().count()); + let nested = json!({"center": [0, 0]}); + let Value::Object(mut map) = nested else { panic!() }; + map.insert("zoom".into(), deep(500_000)); + let params = Value::Object(map); + let e = s.execute("view.saved.new", ¶ms).unwrap_err().to_string(); + assert!(e.contains("`zoom`") && e.chars().count() < 400, "{e}"); + std::mem::forget(params); + assert!(s.doc().unwrap().doc.views.is_empty()); + assert!(s.doc().unwrap().history.undo.is_empty()); + } + + /// Each command's JSON schema follows the 2020-12 rules, and a validator of those rules, which + /// shares no code with the writer, gives the same answer as the schema check. None of these + /// schemas has a lenient parameter. + #[test] + fn the_listed_schemas_follow_2020_12_and_agree_with_the_check() { + let cases: &[(&str, Value)] = &[ + ("view.capsLock", json!({})), + ("view.capsLock", json!({"on": true})), + ("view.capsLock", json!({"on": "yes"})), + ("view.capsLock", json!({"on": null})), + ("view.capsLock", json!({"of": true})), + ("view.saved.new", json!({"center": [0, 0], "zoom": 1})), + ("view.saved.new", json!({"center": [0.5, -2], "zoom": 0.001, "rotation": -30, "name": "A"})), + ("view.saved.new", json!({"center": [0, 0], "zoom": 1, "name": null})), + ("view.saved.new", json!({"center": [0, 0], "zoom": 1, "rotation": null})), + ("view.saved.new", json!({"center": [0, 0], "zoom": 0})), + ("view.saved.new", json!({"center": [0, 0], "zoom": -1})), + ("view.saved.new", json!({"center": [0], "zoom": 1})), + ("view.saved.new", json!({"center": [0, 0, 0], "zoom": 1})), + ("view.saved.new", json!({"center": [0, "0"], "zoom": 1})), + ("view.saved.new", json!({"zoom": 1})), + ("view.saved.new", json!({"center": [0, 0]})), + ("view.saved.new", json!({"center": [0, 0], "zoom": 1, "name": 5})), + ("view.saved.edit", json!({})), + ("view.saved.edit", json!({"name": "A", "newName": "B"})), + ("view.saved.edit", json!({"name": null, "delete": null})), + ("view.saved.edit", json!({"edits": []})), + ("view.saved.edit", json!({"edits": null})), + ("view.saved.edit", json!({"edits": [{"name": "A", "delete": true}, {"name": "B", "newName": null}]})), + ("view.saved.edit", json!({"edits": [{"newName": "B"}]})), + ("view.saved.edit", json!({"edits": [{"name": "A", "delete": 1}]})), + ("view.saved.edit", json!({"edits": [{"name": "A", "x": 1}]})), + ("view.saved.edit", json!({"edits": {"name": "A"}})), + ("view.saved.list", json!({})), + ("view.saved.list", json!({"all": true})), + ("view.transparencyGrid", json!({})), + ("view.transparencyGrid", json!({"on": null})), + ("view.transparencyGrid", json!({"on": false})), + ("view.transparencyGrid", json!({"on": 1})), + ("view.snapToGlyph.options", json!({"bounds": false, "angular": true})), + ("view.snapToGlyph.options", json!({"bounds": null})), + ]; + assert_eq!(schemas().len(), 6); + for (id, schema) in schemas() { + let written = schema.to_json(); + check_schema(&written, id).unwrap_or_else(|e| panic!("{e}\n{written:#}")); + assert!(cases.iter().any(|(c, _)| *c == id), "{id} has no cases"); + } + for (id, params) in cases { + let schema = schemas().into_iter().find(|(c, _)| c == id).unwrap().1; + assert_eq!(schema.validate(params).is_ok(), instance_valid(&schema.to_json(), params), "{id} {params}"); + } + } + /// The engine's own listing carries the schema of each command that has one. #[test] fn engine_commands_list_the_schemas() { diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 57037d946..81c839068 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -61,8 +61,9 @@ impl Session { self.check_params(id, params)?; } // A command with a parameter schema runs only with params that pass it, and with the - // defaults of omitted values filled in; the journal keeps the params as given. - let checked = crate::schemas::check(spec, params)?; + // defaults of omitted values filled in; the journal keeps the params as given. Saved steps + // keep their keys the schema doesn't name, as above. + let checked = crate::schemas::check(spec, params, self.replaying)?; let run_params = checked.as_ref().unwrap_or(params); // Objects given as `id` (where it stands for `ids` of one) or as `ids` beside it are checked // before anything else: a malformed one, or one naming no object, is an error under this diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 467dd4052..158cf21ad 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -823,6 +823,13 @@ fn shape_builder_applies_a_merge_before_what_comes_next() { // No files: the command fails after the merge. assert!(s.execute("text.addFontFiles", &json!({"files": []})).is_err()); }), + ("a call its schema refuses", |s, _, _| { + // The refused call adds no undo step of its own: the merge's is the last one. + let e = s.execute("view.saved.new", &json!({"center": [0, 0], "zoom": 0})).unwrap_err().to_string(); + assert!(e.contains("`zoom`"), "{e}"); + assert!(s.doc().unwrap().doc.views.is_empty()); + assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Shape Builder")); + }), ]; let _proof = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let dir = vectorcraft_testkit::temp_dir("shape-builder-merge-first"); diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 53ebd517f..bc984eec5 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5854,6 +5854,11 @@ digits Malayalam Malaiàlam @msg unknown parameter `{key}`; did you mean `{near}`? paràmetre desconegut `{key}`; volies dir `{near}`? @msg unknown parameter `{key}`; the command takes no parameters paràmetre desconegut `{key}`; l’ordre no admet cap paràmetre @msg unknown parameter `{key}`; the command takes: {keys} paràmetre desconegut `{key}`; l’ordre admet: {keys} +@msg the params must be an object, not {got} els paràmetres han de ser un objecte, no {got} +@msg unknown parameter `{path}`; the object takes no keys paràmetre desconegut `{path}`; l’objecte no admet claus +@msg unknown parameter `{path}`; it takes: {keys} paràmetre desconegut `{path}`; admet: {keys} +@msg missing `{path}` falta `{path}` +@msg `{path}` must be {expected}, not {got} `{path}` ha de ser {expected}, no {got} @msg unknown preference `{key}` preferència desconeguda `{key}` @msg unknown preset (one of {_1}) preajust desconegut (un de {_1}) @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) preajust desconegut `{name}` (high, medium, low o un de desat: consulta flattener.presets.list) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3c9090be2..fc357028f 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5595,6 +5595,11 @@ mark-style Japanese Japonské @msg unknown parameter `{key}`; did you mean `{near}`? neznámý parametr `{key}`; mysleli jste `{near}`? @msg unknown parameter `{key}`; the command takes no parameters neznámý parametr `{key}`; příkaz nemá žádné parametry @msg unknown parameter `{key}`; the command takes: {keys} neznámý parametr `{key}`; příkaz přijímá: {keys} +@msg the params must be an object, not {got} parametry musí být objekt, nikoli {got} +@msg unknown parameter `{path}`; the object takes no keys neznámý parametr `{path}`; objekt nepřijímá žádné klíče +@msg unknown parameter `{path}`; it takes: {keys} neznámý parametr `{path}`; přijímá: {keys} +@msg missing `{path}` chybí `{path}` +@msg `{path}` must be {expected}, not {got} `{path}` musí být {expected}, nikoli {got} Load EPS into Clipboard Načíst EPS do schránky @msg an image of {w} × {h} pixels is too large to draw obraz o velikosti {w} × {h} obrazových bodů je příliš velký na vykreslení @msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: není obraz PNG, JPEG, WebP, TIFF ani BMP diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6c6c48238..b66afbe45 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5162,6 +5162,11 @@ lighting Light Licht @msg unknown parameter `{key}`; did you mean `{near}`? unbekannter Parameter `{key}`; meinten Sie `{near}`? @msg unknown parameter `{key}`; the command takes no parameters unbekannter Parameter `{key}`; der Befehl hat keine Parameter @msg unknown parameter `{key}`; the command takes: {keys} unbekannter Parameter `{key}`; der Befehl akzeptiert: {keys} +@msg the params must be an object, not {got} die Parameter müssen ein Objekt sein, nicht {got} +@msg unknown parameter `{path}`; the object takes no keys unbekannter Parameter `{path}`; das Objekt akzeptiert keine Schlüssel +@msg unknown parameter `{path}`; it takes: {keys} unbekannter Parameter `{path}`; akzeptiert werden: {keys} +@msg missing `{path}` `{path}` fehlt +@msg `{path}` must be {expected}, not {got} `{path}` muss {expected} sein, nicht {got} Load EPS into Clipboard EPS in die Zwischenablage laden @msg an image of {w} × {h} pixels is too large to draw ein Bild mit {w} × {h} Pixeln ist zu groß, um gezeichnet zu werden @msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: kein PNG-, JPEG-, WebP-, TIFF- oder BMP-Bild diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index b2d4f2a6e..632a6feec 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4956,6 +4956,11 @@ digits Malayalam Μαλαγιάλαμ @msg unknown parameter `{key}`; did you mean `{near}`? άγνωστη παράμετρος `{key}`· μήπως εννοείτε `{near}`; @msg unknown parameter `{key}`; the command takes no parameters άγνωστη παράμετρος `{key}`· η εντολή δεν δέχεται παραμέτρους @msg unknown parameter `{key}`; the command takes: {keys} άγνωστη παράμετρος `{key}`· η εντολή δέχεται: {keys} +@msg the params must be an object, not {got} οι παράμετροι πρέπει να είναι αντικείμενο, όχι {got} +@msg unknown parameter `{path}`; the object takes no keys άγνωστη παράμετρος `{path}`· το αντικείμενο δεν δέχεται κλειδιά +@msg unknown parameter `{path}`; it takes: {keys} άγνωστη παράμετρος `{path}`· δέχεται: {keys} +@msg missing `{path}` λείπει το `{path}` +@msg `{path}` must be {expected}, not {got} Το `{path}` πρέπει να είναι {expected}, όχι {got} 1 Layer 1 επίπεδο 1 bit 1 bit 16 Colors 16 χρώματα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9d5059411..3369528a1 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5536,6 +5536,11 @@ mark-style Japanese Japonesas @msg unknown parameter `{key}`; did you mean `{near}`? parámetro desconocido `{key}`; ¿quiso decir `{near}`? @msg unknown parameter `{key}`; the command takes no parameters parámetro desconocido `{key}`; el comando no tiene parámetros @msg unknown parameter `{key}`; the command takes: {keys} parámetro desconocido `{key}`; el comando acepta: {keys} +@msg the params must be an object, not {got} los parámetros deben ser un objeto, no {got} +@msg unknown parameter `{path}`; the object takes no keys parámetro desconocido `{path}`; el objeto no acepta claves +@msg unknown parameter `{path}`; it takes: {keys} parámetro desconocido `{path}`; acepta: {keys} +@msg missing `{path}` falta `{path}` +@msg `{path}` must be {expected}, not {got} `{path}` debe ser {expected}, no {got} Load EPS into Clipboard Cargar EPS en el portapapeles @msg an image of {w} × {h} pixels is too large to draw una imagen de {w} × {h} píxeles es demasiado grande para dibujarse @msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: no es una imagen PNG, JPEG, WebP, TIFF ni BMP diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index dbdf5d111..1ba0cff27 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5479,6 +5479,11 @@ mark-style Japanese Japanilainen @msg unknown parameter `{key}`; did you mean `{near}`? tuntematon parametri `{key}`; tarkoititko `{near}`? @msg unknown parameter `{key}`; the command takes no parameters tuntematon parametri `{key}`; komento ei ota parametreja @msg unknown parameter `{key}`; the command takes: {keys} tuntematon parametri `{key}`; komennon parametrit: {keys} +@msg the params must be an object, not {got} parametrien on oltava objekti, ei {got} +@msg unknown parameter `{path}`; the object takes no keys tuntematon parametri `{path}`; objekti ei ota avaimia +@msg unknown parameter `{path}`; it takes: {keys} tuntematon parametri `{path}`; parametrit: {keys} +@msg missing `{path}` `{path}` puuttuu +@msg `{path}` must be {expected}, not {got} `{path}` on oltava {expected}, ei {got} Load EPS into Clipboard Lataa EPS leikepöydälle @msg an image of {w} × {h} pixels is too large to draw kuva, jonka koko on {w} × {h} pikseliä, on liian suuri piirrettäväksi @msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: ei ole PNG-, JPEG-, WebP-, TIFF- tai BMP-kuva diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 7c6d1fb0e..6a7817aea 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5166,6 +5166,11 @@ lighting Light Lumière @msg unknown parameter `{key}`; did you mean `{near}`? paramètre inconnu `{key}` ; vouliez-vous dire `{near}` ? @msg unknown parameter `{key}`; the command takes no parameters paramètre inconnu `{key}` ; la commande n’a aucun paramètre @msg unknown parameter `{key}`; the command takes: {keys} paramètre inconnu `{key}` ; la commande accepte : {keys} +@msg the params must be an object, not {got} les paramètres doivent être un objet, pas {got} +@msg unknown parameter `{path}`; the object takes no keys paramètre inconnu `{path}` ; l’objet n’accepte aucune clé +@msg unknown parameter `{path}`; it takes: {keys} paramètre inconnu `{path}` ; il accepte : {keys} +@msg missing `{path}` `{path}` manquant +@msg `{path}` must be {expected}, not {got} `{path}` doit être {expected}, pas {got} 1 Layer 1 calque 1 bit 1 bit 16 Colors 16 couleurs diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 1dac03dec..1d5571d2d 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -7794,6 +7794,11 @@ antiAlias Sharp शार्प @msg unknown parameter `{key}`; did you mean `{near}`? अज्ञात पैरामीटर `{key}`; क्या आपको `{near}` का मतलब है? @msg unknown parameter `{key}`; the command takes no parameters अज्ञात पैरामीटर `{key}`; कमांड कोई पैरामीटर नहीं लेता है @msg unknown parameter `{key}`; the command takes: {keys} अज्ञात पैरामीटर `{key}`; कमांड लेता है: {keys} +@msg the params must be an object, not {got} पैरामीटर एक ऑब्जेक्ट होने चाहिए, {got} नहीं +@msg unknown parameter `{path}`; the object takes no keys अज्ञात पैरामीटर `{path}`; ऑब्जेक्ट कोई कुंजी नहीं लेता +@msg unknown parameter `{path}`; it takes: {keys} अज्ञात पैरामीटर `{path}`; यह लेता है: {keys} +@msg missing `{path}` लापता `{path}` +@msg `{path}` must be {expected}, not {got} `{path}` {expected} होना चाहिए, {got} नहीं @msg unknown PDF preset `{name}` (presets: {_1}) अज्ञात PDF प्रीसेट `{name}` (presets: {_1}) @msg unknown pieLegend `{v}` (none, standard or wedges) अज्ञात pieLegend `{v}` (one, मानक या wedges) @msg unknown piePosition `{v}` (even, ratio or stacked) अज्ञात piePosition `{v}` (यहां तक कि अनुपात या स्टैक्ड) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 87842df37..92d7822e4 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5578,6 +5578,11 @@ imageTrace Outline Körvonal @msg unknown parameter `{key}`; did you mean `{near}`? ismeretlen paraméter: `{key}`; erre gondolt: `{near}`? @msg unknown parameter `{key}`; the command takes no parameters ismeretlen paraméter: `{key}`; a parancsnak nincs paramétere @msg unknown parameter `{key}`; the command takes: {keys} ismeretlen paraméter: `{key}`; a parancs ezeket fogadja el: {keys} +@msg the params must be an object, not {got} a paramétereknek objektumnak kell lenniük, nem ez: {got} +@msg unknown parameter `{path}`; the object takes no keys ismeretlen paraméter: `{path}`; az objektum nem fogad el kulcsokat +@msg unknown parameter `{path}`; it takes: {keys} ismeretlen paraméter: `{path}`; ezeket fogadja el: {keys} +@msg missing `{path}` hiányzik: `{path}` +@msg `{path}` must be {expected}, not {got} `{path}`: {expected} kell, nem ez: {got} Load EPS into Clipboard EPS betöltése a vágólapra @msg an image of {w} × {h} pixels is too large to draw egy {w} × {h} pixeles kép túl nagy a kirajzoláshoz @msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: nem PNG, JPEG, WebP, TIFF vagy BMP kép diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f10e1f272..8f710a566 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5204,6 +5204,11 @@ lighting Light Luce @msg unknown parameter `{key}`; did you mean `{near}`? parametro sconosciuto `{key}`; forse intendevate `{near}`? @msg unknown parameter `{key}`; the command takes no parameters parametro sconosciuto `{key}`; il comando non ha parametri @msg unknown parameter `{key}`; the command takes: {keys} parametro sconosciuto `{key}`; il comando accetta: {keys} +@msg the params must be an object, not {got} i parametri devono essere un oggetto, non {got} +@msg unknown parameter `{path}`; the object takes no keys parametro sconosciuto `{path}`; l'oggetto non accetta chiavi +@msg unknown parameter `{path}`; it takes: {keys} parametro sconosciuto `{path}`; accetta: {keys} +@msg missing `{path}` manca `{path}` +@msg `{path}` must be {expected}, not {got} `{path}` deve essere {expected}, non {got} Load EPS into Clipboard Carica EPS negli appunti @msg an image of {w} × {h} pixels is too large to draw un'immagine di {w} × {h} pixel è troppo grande per essere disegnata @msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: non è un'immagine PNG, JPEG, WebP, TIFF o BMP diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b2c2b37a0..6403b1e8a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5246,6 +5246,11 @@ lighting Light ライト @msg unknown parameter `{key}`; did you mean `{near}`? `{key}` は不明なパラメーターです。`{near}` のことですか? @msg unknown parameter `{key}`; the command takes no parameters `{key}` は不明なパラメーターです。このコマンドにパラメーターはありません @msg unknown parameter `{key}`; the command takes: {keys} `{key}` は不明なパラメーターです。このコマンドで使えるパラメーター: {keys} +@msg the params must be an object, not {got} パラメーターはオブジェクトにしてください ({got} は使えません) +@msg unknown parameter `{path}`; the object takes no keys `{path}` は不明なパラメーターです。このオブジェクトにはキーを指定できません +@msg unknown parameter `{path}`; it takes: {keys} `{path}` は不明なパラメーターです。使えるパラメーター: {keys} +@msg missing `{path}` `{path}` が指定されていません +@msg `{path}` must be {expected}, not {got} `{path}` は {expected} にしてください ({got} は使えません) Load EPS into Clipboard EPS をクリップボードに読み込む @msg an image of {w} × {h} pixels is too large to draw {w} × {h} ピクセルの画像は大きすぎて描画できません @msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: PNG、JPEG、WebP、TIFF、BMP のいずれの画像でもありません diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index a625c7bf5..0b6755253 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4967,6 +4967,11 @@ font-technology Variable ცვლადი @msg unknown parameter `{key}`; did you mean `{near}`? უცნობი პარამეტრი `{key}`; იქნებ `{near}` იგულისხმეთ? @msg unknown parameter `{key}`; the command takes no parameters უცნობი პარამეტრი `{key}`; ბრძანება პარამეტრებს არ იღებს @msg unknown parameter `{key}`; the command takes: {keys} უცნობი პარამეტრი `{key}`; ბრძანება იღებს: {keys} +@msg the params must be an object, not {got} პარამეტრები უნდა იყოს ობიექტი და არა {got} +@msg unknown parameter `{path}`; the object takes no keys უცნობი პარამეტრი `{path}`; ობიექტი გასაღებებს არ იღებს +@msg unknown parameter `{path}`; it takes: {keys} უცნობი პარამეტრი `{path}`; იღებს: {keys} +@msg missing `{path}` აკლია `{path}` +@msg `{path}` must be {expected}, not {got} `{path}` უნდა იყოს {expected} და არა {got} file-filter All readable files ყველა წაკითხვადი ფაილი file-filter All placeable files ყველა განთავსებადი ფაილი file-filter Swatch libraries ფერის ნიმუშების ბიბლიოთეკები diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e281e8316..64844bf7c 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5165,6 +5165,11 @@ lighting Light 광원 @msg unknown parameter `{key}`; did you mean `{near}`? 알 수 없는 매개변수 `{key}`: `{near}`을(를) 의미하셨습니까? @msg unknown parameter `{key}`; the command takes no parameters 알 수 없는 매개변수 `{key}`: 이 명령에는 매개변수가 없습니다 @msg unknown parameter `{key}`; the command takes: {keys} 알 수 없는 매개변수 `{key}`: 이 명령이 받는 매개변수: {keys} +@msg the params must be an object, not {got} 매개변수는 객체여야 합니다({got} 사용 불가) +@msg unknown parameter `{path}`; the object takes no keys 알 수 없는 매개변수 `{path}`: 이 객체는 키를 받지 않습니다 +@msg unknown parameter `{path}`; it takes: {keys} 알 수 없는 매개변수 `{path}`: 받는 매개변수: {keys} +@msg missing `{path}` `{path}`이(가) 없습니다 +@msg `{path}` must be {expected}, not {got} `{path}`은(는) {expected}이어야 합니다({got} 사용 불가) Load EPS into Clipboard EPS를 클립보드로 불러오기 @msg an image of {w} × {h} pixels is too large to draw {w} × {h}픽셀 이미지는 너무 커서 그릴 수 없습니다 @msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: PNG, JPEG, WebP, TIFF 또는 BMP 이미지가 아닙니다 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8d1be1679..dd33ebd05 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5434,6 +5434,11 @@ mark-style Japanese Japońskie @msg unknown parameter `{key}`; did you mean `{near}`? nieznany parametr `{key}`; czy chodziło o `{near}`? @msg unknown parameter `{key}`; the command takes no parameters nieznany parametr `{key}`; polecenie nie przyjmuje parametrów @msg unknown parameter `{key}`; the command takes: {keys} nieznany parametr `{key}`; polecenie przyjmuje: {keys} +@msg the params must be an object, not {got} parametry muszą być obiektem, a nie {got} +@msg unknown parameter `{path}`; the object takes no keys nieznany parametr `{path}`; obiekt nie przyjmuje kluczy +@msg unknown parameter `{path}`; it takes: {keys} nieznany parametr `{path}`; przyjmuje: {keys} +@msg missing `{path}` brak `{path}` +@msg `{path}` must be {expected}, not {got} `{path}` musi być {expected}, a nie {got} Load EPS into Clipboard Wczytaj EPS do schowka @msg an image of {w} × {h} pixels is too large to draw obraz o wymiarach {w} × {h} pikseli jest zbyt duży, aby go narysować @msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: to nie jest obraz PNG, JPEG, WebP, TIFF ani BMP diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ebe44a68c..a80de1e0c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4505,6 +4505,11 @@ axis Both Ambos @msg unknown parameter `{key}`; did you mean `{near}`? parâmetro desconhecido `{key}`; você quis dizer `{near}`? @msg unknown parameter `{key}`; the command takes no parameters parâmetro desconhecido `{key}`; o comando não tem parâmetros @msg unknown parameter `{key}`; the command takes: {keys} parâmetro desconhecido `{key}`; o comando aceita: {keys} +@msg the params must be an object, not {got} os parâmetros devem ser um objeto, não {got} +@msg unknown parameter `{path}`; the object takes no keys parâmetro desconhecido `{path}`; o objeto não aceita chaves +@msg unknown parameter `{path}`; it takes: {keys} parâmetro desconhecido `{path}`; aceita: {keys} +@msg missing `{path}` falta `{path}` +@msg `{path}` must be {expected}, not {got} `{path}` deve ser {expected}, não {got} Load EPS into Clipboard Carregar EPS na Área de Transferência Drawer menu Menu da gaveta Toolbar: Show Controls Barra de ferramentas: Mostrar controles diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index dc6402e83..2ddc692a3 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5198,6 +5198,11 @@ lighting Light Свет @msg unknown parameter `{key}`; did you mean `{near}`? неизвестный параметр `{key}`; возможно, имелся в виду `{near}`? @msg unknown parameter `{key}`; the command takes no parameters неизвестный параметр `{key}`; у команды нет параметров @msg unknown parameter `{key}`; the command takes: {keys} неизвестный параметр `{key}`; команда принимает: {keys} +@msg the params must be an object, not {got} параметры должны быть объектом, а не {got} +@msg unknown parameter `{path}`; the object takes no keys неизвестный параметр `{path}`; объект не принимает ключей +@msg unknown parameter `{path}`; it takes: {keys} неизвестный параметр `{path}`; принимает: {keys} +@msg missing `{path}` отсутствует `{path}` +@msg `{path}` must be {expected}, not {got} `{path}` должно быть {expected}, а не {got} Load EPS into Clipboard Загрузить EPS в буфер обмена @msg an image of {w} × {h} pixels is too large to draw изображение размером {w} × {h} пикселей слишком велико для отрисовки @msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: это не изображение PNG, JPEG, WebP, TIFF или BMP diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index a8ce1904d..7a0e3f39b 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5070,6 +5070,11 @@ lighting Light Ljus @msg unknown parameter `{key}`; did you mean `{near}`? okänd parameter `{key}`; menade du `{near}`? @msg unknown parameter `{key}`; the command takes no parameters okänd parameter `{key}`; kommandot tar inga parametrar @msg unknown parameter `{key}`; the command takes: {keys} okänd parameter `{key}`; kommandot tar: {keys} +@msg the params must be an object, not {got} parametrarna måste vara ett objekt, inte {got} +@msg unknown parameter `{path}`; the object takes no keys okänd parameter `{path}`; objektet tar inga nycklar +@msg unknown parameter `{path}`; it takes: {keys} okänd parameter `{path}`; den tar: {keys} +@msg missing `{path}` `{path}` saknas +@msg `{path}` must be {expected}, not {got} `{path}` måste vara {expected}, inte {got} 1 Layer 1 lager 1 bit 1 bit 16 Colors 16 färger diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 7c4df674d..fd842dd34 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1998,6 +1998,47 @@ fn dataset_import_repeated_column_error_is_translated() { } } +/// The errors of a command's parameter schema are translated in every language that translates +/// every message, with the parameter's path and the refused value kept. +#[test] +fn parameter_schema_errors_are_translated() { + use serde_json::json; + use vectorcraft_engine::Session; + let scanned = message_literals(); + for template in [ + "the params must be an object, not {got}", + "unknown parameter `{path}`; the object takes no keys", + "unknown parameter `{path}`; it takes: {keys}", + "missing `{path}`", + "`{path}` must be {expected}, not {got}", + ] { + assert!(scanned.contains(template), "{template}"); + } + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let errors = [ + (s.execute("view.saved.new", &json!({"center": [0, 0], "zoom": -1})).unwrap_err().to_string(), "`zoom`", "must be", "-1"), + (s.execute("view.saved.new", &json!({"center": [0, 0]})).unwrap_err().to_string(), "`zoom`", "missing", ""), + (s.execute("view.saved.list", &json!(5)).unwrap_err().to_string(), "5", "must be", ""), + ]; + s.execute("view.saved.new", &json!({"name": "A", "center": [0, 0], "zoom": 1})).unwrap(); + let nested = [ + (s.execute("view.saved.edit", &json!({"edits": [{"name": "A"}, {"name": "A", "delete": 1}]})).unwrap_err().to_string(), "`edits[1].delete`"), + (s.execute("view.saved.edit", &json!({"edits": [{"name": "A", "x": 1}]})).unwrap_err().to_string(), "`edits[0].x`"), + ]; + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + for (e, kept, english, value) in &errors { + let shown = message(lang, e); + assert!(shown.contains(kept) && shown.contains(value) && !shown.contains(english), "{code}: {shown}"); + } + for (e, kept) in &nested { + let shown = message(lang, e); + assert!(shown.contains(kept) && !shown.contains("unknown parameter") && !shown.contains("must be"), "{code}: {shown}"); + } + } +} + /// The warning for a PDF/X file that leaves out the document's bleed is translated in every catalog, /// alone and in the status bar's note. #[test] diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c2804006c..67b958ebd 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4866,6 +4866,11 @@ lighting Light Işık @msg unknown parameter `{key}`; did you mean `{near}`? bilinmeyen parametre `{key}`; `{near}` mi demek istediniz? @msg unknown parameter `{key}`; the command takes no parameters bilinmeyen parametre `{key}`; komut hiçbir parametre almaz @msg unknown parameter `{key}`; the command takes: {keys} bilinmeyen parametre `{key}`; komut şunları alır: {keys} +@msg the params must be an object, not {got} parametreler bir nesne olmalıdır, {got} değil +@msg unknown parameter `{path}`; the object takes no keys bilinmeyen parametre `{path}`; nesne anahtar almaz +@msg unknown parameter `{path}`; it takes: {keys} bilinmeyen parametre `{path}`; şunları alır: {keys} +@msg missing `{path}` `{path}` eksik +@msg `{path}` must be {expected}, not {got} `{path}` {expected} olmalıdır, {got} değil 1 Layer 1 Katman 1 bit 1 bit 16 Colors 16 Renk diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0108c96a5..67050653e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5184,6 +5184,11 @@ lighting Light Світло @msg unknown parameter `{key}`; did you mean `{near}`? невідомий параметр `{key}`; можливо, ви мали на увазі `{near}`? @msg unknown parameter `{key}`; the command takes no parameters невідомий параметр `{key}`; команда не має параметрів @msg unknown parameter `{key}`; the command takes: {keys} невідомий параметр `{key}`; команда приймає: {keys} +@msg the params must be an object, not {got} параметри мають бути об'єктом, а не {got} +@msg unknown parameter `{path}`; the object takes no keys невідомий параметр `{path}`; об'єкт не приймає ключів +@msg unknown parameter `{path}`; it takes: {keys} невідомий параметр `{path}`; приймає: {keys} +@msg missing `{path}` відсутнє `{path}` +@msg `{path}` must be {expected}, not {got} `{path}` має бути {expected}, а не {got} Load EPS into Clipboard Завантажити EPS у буфер обміну @msg an image of {w} × {h} pixels is too large to draw зображення розміром {w} × {h} пікселів завелике для відображення @msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: це не зображення PNG, JPEG, WebP, TIFF чи BMP diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 5b979b003..7c0279792 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5119,6 +5119,11 @@ theme Appearance Giao diện @msg unknown parameter `{key}`; did you mean `{near}`? tham số không xác định `{key}`; ý bạn là `{near}`? @msg unknown parameter `{key}`; the command takes no parameters tham số không xác định `{key}`; lệnh này không nhận tham số @msg unknown parameter `{key}`; the command takes: {keys} tham số không xác định `{key}`; lệnh nhận: {keys} +@msg the params must be an object, not {got} các tham số phải là một đối tượng, không phải {got} +@msg unknown parameter `{path}`; the object takes no keys tham số không xác định `{path}`; đối tượng không nhận khóa nào +@msg unknown parameter `{path}`; it takes: {keys} tham số không xác định `{path}`; nhận: {keys} +@msg missing `{path}` thiếu `{path}` +@msg `{path}` must be {expected}, not {got} `{path}` phải là {expected}, không phải {got} @msg unknown preference `{key}` tùy chọn không xác định `{key}` @msg unknown preset (one of {_1}) thiết lập sẵn không xác định (một trong {_1}) @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) thiết lập sẵn không xác định `{name}` (high, medium, low hoặc thiết lập đã lưu: xem flattener.presets.list) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d7b40b95d..b7b8f6561 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5465,6 +5465,11 @@ lighting Light 光源 @msg unknown parameter `{key}`; did you mean `{near}`? 未知参数 `{key}`;您是否想输入 `{near}`? @msg unknown parameter `{key}`; the command takes no parameters 未知参数 `{key}`;该命令没有参数 @msg unknown parameter `{key}`; the command takes: {keys} 未知参数 `{key}`;该命令接受:{keys} +@msg the params must be an object, not {got} 参数必须是对象,而不是 {got} +@msg unknown parameter `{path}`; the object takes no keys 未知参数 `{path}`;该对象不接受任何键 +@msg unknown parameter `{path}`; it takes: {keys} 未知参数 `{path}`;它接受:{keys} +@msg missing `{path}` 缺少 `{path}` +@msg `{path}` must be {expected}, not {got} `{path}` 必须是 {expected},而不是 {got} Load EPS into Clipboard 将 EPS 载入剪贴板 @msg an image of {w} × {h} pixels is too large to draw {w} × {h} 像素的图像过大,无法绘制 @msg the JPEG XL file is cut short JPEG XL 文件不完整 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9e27d9c7b..328cbb1f8 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5291,6 +5291,11 @@ lighting Light 光源 @msg unknown parameter `{key}`; did you mean `{near}`? 未知的參數 `{key}`;您是指 `{near}` 嗎? @msg unknown parameter `{key}`; the command takes no parameters 未知的參數 `{key}`;此指令沒有參數 @msg unknown parameter `{key}`; the command takes: {keys} 未知的參數 `{key}`;此指令接受:{keys} +@msg the params must be an object, not {got} 參數必須是物件,而不是 {got} +@msg unknown parameter `{path}`; the object takes no keys 未知的參數 `{path}`;此物件不接受任何鍵 +@msg unknown parameter `{path}`; it takes: {keys} 未知的參數 `{path}`;它接受:{keys} +@msg missing `{path}` 未提供 `{path}` +@msg `{path}` must be {expected}, not {got} `{path}` 必須是 {expected},而不是 {got} Load EPS into Clipboard 將 EPS 載入剪貼簿 @msg an image of {w} × {h} pixels is too large to draw {w} × {h} 像素的影像過大,無法繪製 @msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}:不是 PNG、JPEG、WebP、TIFF 或 BMP 影像 diff --git a/docs/mcp.md b/docs/mcp.md index fec62b00d..e7f71d208 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1388,16 +1388,20 @@ Some commands have a machine-readable parameter schema: at present `view.capsLoc `view.saved.edit`, `view.saved.list`, `view.transparencyGrid` and `view.snapToGlyph.options`. The engine's `engine.commands` list gives such a command a `schema` field: an object schema in the shape of JSON Schema 2020-12 (https://json-schema.org/draft/2020-12), with `type`, `properties`, `required`, `additionalProperties: false`, -`minimum` and `exclusiveMinimum` for ranges, `enum`, and `default` for a default the engine fills in. VectorCraft's own +`minimum`, `maximum`, `exclusiveMinimum` and `exclusiveMaximum` for ranges, `enum`, and `default` for a default the engine fills in. VectorCraft's own fields are `x-since` (the API level that added the command or the parameter), `x-deprecatedSince`, `x-lenient`, `x-unit`, `x-kind` (`id`, `color` or `point`), `x-default` (a default the command works out, such as `toggle`), `x-cost`, `x-writes` (`nothing`, `session`, `preferences` or `document`) and `x-returns`. A parameter that reads null -as left out is an `anyOf` with `{"type": "null"}`. The command's `params` text is written from the same schema. +as left out is an `anyOf` with `{"type": "null"}`. A number with no fractional part, such as `2.0`, is an integer, as +JSON Schema defines one, and the command receives `2`. An `x-lenient` parameter's schema states the documented type, +and the engine also passes a value of another type to the command. The command's `params` text is written from the same +schema. -Every caller, the desktop app and a step of `command.batch` included, has the params of such a command checked against -its schema before the command runs. A value of the wrong type, out of range or not in its list, a missing required -parameter or an unknown key is refused with a parameter error that names the parameter, with the path to a value -inside a list or an object, and the document and its undo history stay as they were: +The engine checks the params of such a command against its schema before the command runs, for every new call: from +the desktop app, the control channel, MCP or the CLI, and when one command runs another. The steps of `command.batch` +and `action.batch` are checked before the first step runs. A value of the wrong type, out of range or not in its list, +a missing required parameter or an unknown key is refused with a parameter error that names the parameter, with the +path to a value inside a list or an object, and the document and its undo history stay as they were: ```json {"id": "view.saved.new", "params": {"center": [0, 0], "zoom": 0}} @@ -1407,8 +1411,10 @@ inside a list or an object, and the document and its undo history stay as they w ``` An omitted value with a `default` takes it (`rotation` 0 in `view.saved.new`); the journal keeps the params as they -were given. The schemas belong to API level 1 (`vectorcraft_engine::schemas::API_VERSION`); a change to any schema -raises the level. +were given. Saved steps played from a journal or an action keep a key the schema does not name, unless its value nests +more than 16 levels deep, and the values of the parameters it names are checked as in a new call. The schemas of built-in commands belong to API level 1 +(`vectorcraft_engine::schemas::API_VERSION`); a change to any of them raises the level. Commands that plug-ins add have +no schema here. ## Checking scripts written for the reference app diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cf633ef45..031c380b4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -664,7 +664,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Linux file associations:** the desktop entry lists the MIME type of every format File › Open reads but PSD, PSB, `.ps` PostScript, Targa, PCX, Pixar, JPEG 2000, HEIC/HEIF and JPEG XL, `.ai`, `.ait` and EPS included, and the MIME package declares the types shared-mime-info lacks (VectorCraft documents and templates, `.ait`, Affinity), so file managers list VectorCraft under Open With for them, as Finder does on macOS. `the_linux_desktop_entry_opens_every_readable_format` in `apps/vectorcraft/src/tests.rs` checks both files against `OPEN_EXTS`. On Debian 13, Ubuntu 24.04 and Fedora 42, `desktop-file-validate` accepts the desktop entry, `update-mime-database` accepts the MIME package, and `gio` registers the app for every one of these types. - **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction. -- **Parameter schemas:** a command can carry a machine-readable parameter schema (the new `vectorcraft-apischema` crate): each parameter's type, range, unit, whether it is required, its default, whether null counts as left out, and the API levels it was added and deprecated at. `engine.commands` lists the schema in the shape of a JSON Schema 2020-12 object schema, the command's `params` text is written from it, and every caller has the params checked against it before the command runs, with an error naming the parameter and no change to the document. The View commands `view.capsLock`, `view.saved.new`, `view.saved.edit`, `view.saved.list`, `view.transparencyGrid` and `view.snapToGlyph.options` have schemas. The schemas belong to API level 1 (`vectorcraft_engine::schemas::API_VERSION`), and a test fails when a schema changes without a new level. Tests: `crates/apischema/src/tests.rs`, `schemas::tests` and `cmd::views::tests::each_view_command_refuses_a_bad_value_naming_the_parameter` in `vectorcraft-engine`. +- **Parameter schemas:** a command can carry a machine-readable parameter schema (the new `vectorcraft-apischema` crate): each parameter's type, range, unit, whether it is required, its default, whether null counts as left out, and the API levels it was added and deprecated at. `engine.commands` lists the schema in the shape of a JSON Schema 2020-12 object schema, the command's `params` text is written from it, and a new call has the params checked against it before the command runs (batch steps before the first step), with an error naming the parameter and no change to the document; saved steps keep a key the schema does not name, and their values are checked. The View commands `view.capsLock`, `view.saved.new`, `view.saved.edit`, `view.saved.list`, `view.transparencyGrid` and `view.snapToGlyph.options` have schemas. The schemas belong to API level 1 (`vectorcraft_engine::schemas::API_VERSION`), and a test fails when a schema changes without a new level. Tests: `crates/apischema/src/tests.rs` (with `conformance.rs`, a separate check of the JSON form against the JSON Schema 2020-12 rules), `schemas::tests` and `cmd::views::tests::each_view_command_refuses_a_bad_value_naming_the_parameter` in `vectorcraft-engine`. - **Automation:** - Variables: Update Data Set writes what the art holds into the active data set for the variables that data set holds, and keeps the data set's value for a text, image or graph variable whose objects are locked or inside a locked layer or group; `dataset.update {all: true}` also adds the other variables that the art reports a value for. - Variables: `dataset.select`, `dataset.next` and `dataset.prev` rewrite type and apply the other values on hidden objects and in hidden layers and groups, which stay hidden unless a visibility value shows them, so a layer shown later holds the selected data set's text; objects that are locked or in a locked layer or group are skipped, except by a visibility value.