Commands: the View commands check their params against machine-readable schemas

A new standalone crate, vectorcraft-apischema, describes a command's
parameters: type (number with range and unit, integer, boolean, string,
enum, id, id list, color, point, object, array), whether each is
required, the default of an omitted value, whether null counts as
omitted, the API levels it was added and deprecated at, and the lenient,
cost and writes fields. It validates a params object and names the
parameter of the first problem, writes the params text, writes the
schema in the shape of a JSON Schema 2020-12 object schema, and hashes a
set of schemas.

CommandSpec gains an optional schema (both builders: the cmd! macro and
the text style specs). Session::execute checks the params of a command
that has one before anything else, refusing a value of the wrong type,
out of range or not in its list, a missing parameter or an unknown key
with an error naming the parameter, and fills in defaults; the journal
keeps the params as given. engine.commands lists each schema.

The six commands in cmd/views.rs have schemas, and their params text is
the text the schema writes. schemas::API_VERSION is 1, and a test fails
when a schema changes without a new row in SCHEMA_HASHES.

Docs: docs/mcp.md (Parameter schemas), docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-07 10:37:02 -08:00
parent ff9deee46d
commit 74caec60d4
15 changed files with 1233 additions and 13 deletions
Generated
+8
View File
@@ -6139,6 +6139,13 @@ dependencies = [
"ruzstd",
]
[[package]]
name = "vectorcraft-apischema"
version = "0.21.0"
dependencies = [
"serde_json",
]
[[package]]
name = "vectorcraft-brush"
version = "0.21.0"
@@ -6258,6 +6265,7 @@ dependencies = [
"serde_json",
"thiserror 2.0.21",
"vectorcraft-affinity",
"vectorcraft-apischema",
"vectorcraft-brush",
"vectorcraft-cad",
"vectorcraft-color",
+1
View File
@@ -53,6 +53,7 @@ vectorcraft-metafile = { path = "crates/metafile" }
vectorcraft-plugins = { path = "crates/plugins" }
vectorcraft-affinity = { path = "crates/affinity" }
vectorcraft-scriptcheck = { path = "crates/scriptcheck" }
vectorcraft-apischema = { path = "crates/apischema" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "vectorcraft-apischema"
description = "Machine-readable parameter schemas for VectorCraft commands, with a validator and a params text writer"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
serde_json = { workspace = true }
+658
View File
@@ -0,0 +1,658 @@
//! Machine-readable parameter schemas for VectorCraft commands. A [`ParamSchema`] lists what each
//! parameter of a command is ([`Param`], [`Type`]): its type, whether it is required, what an
//! omitted value means, whether null counts as omitted, and the API levels it was added and
//! deprecated at. From one schema come:
//!
//! - [`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::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 (<https://json-schema.org/draft/2020-12>), with VectorCraft's own fields as
//! `x-` keywords;
//! - [`hash`], a hash of a set of command schemas, which changes whenever any of them changes.
//!
//! The crate knows no command and depends on no other VectorCraft crate.
#![forbid(unsafe_code)]
use std::fmt;
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";
/// One end of a number's range.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Bound {
/// The value may equal the bound.
Inclusive(f64),
/// The value must be past the bound.
Exclusive(f64),
}
/// The type of a parameter's value.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Type {
/// A JSON number in the range the bounds give, measured in `unit` (`"pt"`, `"deg"`) when set.
Number { min: Option<Bound>, max: Option<Bound>, unit: Option<&'static str> },
/// A JSON number without a fraction (written without a decimal point), in the range given.
Integer { min: Option<i64>, max: Option<i64> },
/// `true` or `false`.
Boolean,
/// Any string.
String,
/// One of the strings listed.
Enum(&'static [&'static str]),
/// An object id: a non-negative integer.
Id,
/// A list of object ids.
Ids,
/// A color: a string (`"#rrggbb"`, a color key), `[r, g, b]` or an object. The command reads
/// which color it is.
Color,
/// A point: `[x, y]`, two numbers.
Point,
/// An object with the parameters listed and no other key.
Object(&'static [Param]),
/// A list of values of one type.
Array(&'static Type),
}
impl Type {
/// A number with no range and no unit.
pub const NUMBER: Type = Type::Number { min: None, max: None, unit: None };
}
/// What an omitted value means.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Omitted {
/// The parameter is required, or the command describes the omitted case in its summary.
Nothing,
/// The value, as JSON text, that [`ParamSchema::validate`] fills in.
Value(&'static str),
/// A default the command works out itself (`"toggle"`, `"the selected text"`).
Note(&'static str),
}
/// What a command changes when it runs.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Writes {
/// Nothing: the command only reads.
Nothing,
/// State of the running app outside the document and the preferences (view state, the Caps
/// Lock state).
Session,
/// The preferences.
Preferences,
/// The document, with an undo step.
Document,
}
impl Writes {
fn name(self) -> &'static str {
match self {
Writes::Nothing => "nothing",
Writes::Session => "session",
Writes::Preferences => "preferences",
Writes::Document => "document",
}
}
}
/// One parameter of a command.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Param {
pub name: &'static str,
pub ty: Type,
pub required: bool,
pub omitted: Omitted,
/// A null value is read as if the parameter were omitted. Otherwise null is refused as a value
/// of the wrong type.
pub null_omitted: bool,
/// What the params text says about the value, in parentheses after its type.
pub doc: &'static str,
/// The API level the parameter was added at.
pub since: u32,
/// The API level the parameter was deprecated at.
pub deprecated: Option<u32>,
/// 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.
pub lenient: bool,
}
impl Param {
/// A required parameter, added at API level 1.
pub const fn new(name: &'static str, ty: Type) -> Param {
Param { name, ty, required: true, omitted: Omitted::Nothing, null_omitted: false, doc: "", since: 1, deprecated: None, lenient: false }
}
/// The parameter may be omitted.
pub const fn optional(self) -> Param {
Param { required: false, ..self }
}
/// The parameter may be omitted, and then takes the value `json` (JSON text).
pub const fn defaults_to(self, json: &'static str) -> Param {
Param { required: false, omitted: Omitted::Value(json), ..self }
}
/// The parameter may be omitted, and the command then works out the value `note` describes.
pub const fn default_note(self, note: &'static str) -> Param {
Param { required: false, omitted: Omitted::Note(note), ..self }
}
/// A null value reads as an omitted one.
pub const fn null_omitted(self) -> Param {
Param { null_omitted: true, ..self }
}
pub const fn doc(self, doc: &'static str) -> Param {
Param { doc, ..self }
}
pub const fn since(self, level: u32) -> Param {
Param { since: level, ..self }
}
pub const fn deprecated(self, level: u32) -> Param {
Param { deprecated: Some(level), ..self }
}
pub const fn lenient(self) -> Param {
Param { lenient: true, ..self }
}
}
/// The parameters of one command, and what the command does with them.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ParamSchema {
pub params: &'static [Param],
/// What the command does, after the params object in the params text.
pub summary: &'static str,
/// What the command returns, after `→` in the params text; empty when the text leaves it out.
pub returns: &'static str,
/// The API level the command was added at.
pub since: u32,
/// The API level the command was deprecated at.
pub deprecated: Option<u32>,
/// What one run costs, in units of a light command (1), for callers that limit a script's work.
pub cost: u32,
pub writes: Writes,
}
impl ParamSchema {
/// A command added at API level 1, costing 1, that changes the document.
pub const fn new(params: &'static [Param], summary: &'static str) -> ParamSchema {
ParamSchema { params, summary, returns: "", since: 1, deprecated: None, cost: 1, writes: Writes::Document }
}
pub const fn returns(self, returns: &'static str) -> ParamSchema {
ParamSchema { returns, ..self }
}
pub const fn writes(self, writes: Writes) -> ParamSchema {
ParamSchema { writes, ..self }
}
pub const fn cost(self, cost: u32) -> ParamSchema {
ParamSchema { cost, ..self }
}
pub const fn since(self, level: u32) -> ParamSchema {
ParamSchema { since: level, ..self }
}
pub const fn deprecated(self, level: u32) -> ParamSchema {
ParamSchema { deprecated: Some(level), ..self }
}
/// The parameter named `name`.
pub fn param(&self, name: &str) -> Option<&'static Param> {
self.params.iter().find(|p| p.name == name)
}
/// The names of the top-level parameters, in order.
pub fn names(&self) -> Vec<&'static str> {
self.params.iter().map(|p| p.name).collect()
}
/// `params` checked against the schema: the first problem as an error naming the parameter,
/// 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<Option<Value>, ParamError> {
static EMPTY: std::sync::OnceLock<Map<String, Value>> = 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))
}
/// 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);
if !self.summary.is_empty() {
s.push(' ');
s.push_str(self.summary);
}
if !self.returns.is_empty() {
s.push_str(" → ");
s.push_str(self.returns);
}
s
}
/// 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`.
pub fn to_json(&self) -> Value {
let mut o = object_schema(self.params);
o.insert("$schema".into(), DIALECT.into());
if !self.summary.is_empty() {
o.insert("description".into(), self.summary.into());
}
if !self.returns.is_empty() {
o.insert("x-returns".into(), self.returns.into());
}
o.insert("x-since".into(), self.since.into());
if let Some(level) = self.deprecated {
o.insert("deprecated".into(), true.into());
o.insert("x-deprecatedSince".into(), level.into());
}
o.insert("x-cost".into(), self.cost.into());
o.insert("x-writes".into(), self.writes.name().into());
Value::Object(o)
}
}
/// A problem [`ParamSchema::validate`] found, at `path`: the parameter's name, with the keys and
/// indices leading to a value inside one (`edits[1].name`). The path is empty for the params
/// object itself.
#[derive(Clone, Debug, PartialEq)]
pub struct ParamError {
pub path: String,
pub problem: Problem,
}
#[derive(Clone, Debug, PartialEq)]
pub enum Problem {
/// The params are not an object.
NotAnObject { got: String },
/// The key at `path` is not a parameter of the object it is in, which takes `keys`.
Unknown { keys: Vec<&'static str> },
/// A required parameter is missing.
Missing,
/// The value is not of the parameter's type: `expected` says what it must be.
WrongType { expected: String, got: String },
}
impl fmt::Display for ParamError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let path = &self.path;
match &self.problem {
Problem::NotAnObject { got } => write!(f, "the params must be an object, not {got}"),
Problem::Unknown { keys } if keys.is_empty() => write!(f, "unknown parameter `{path}`; the object takes no keys"),
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}"),
}
}
}
impl std::error::Error for ParamError {}
/// FNV-1a over the bytes of `text`, from `h`.
fn fnv1a(mut h: u64, text: &str) -> u64 {
for b in text.bytes() {
h ^= u64::from(b);
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
/// `v` as JSON text with the keys of each object in sorted order, whichever order the map keeps
/// them in.
fn canonical(v: &Value, out: &mut String) {
match v {
Value::Object(o) => {
let mut keys: Vec<&String> = o.keys().collect();
keys.sort();
out.push('{');
for (i, k) in keys.into_iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&Value::from(k.as_str()).to_string());
out.push(':');
if let Some(x) = o.get(k) {
canonical(x, out);
}
}
out.push('}');
}
Value::Array(a) => {
out.push('[');
for (i, x) in a.iter().enumerate() {
if i > 0 {
out.push(',');
}
canonical(x, out);
}
out.push(']');
}
other => out.push_str(&other.to_string()),
}
}
/// A 64-bit FNV-1a hash of the commands' schemas, taken in the order of their ids: each id and
/// its [`ParamSchema::to_json`] text, with the keys of each object sorted. Any change to a schema,
/// or a schema added or removed, changes it.
pub fn hash<'a>(schemas: impl IntoIterator<Item = (&'a str, &'a ParamSchema)>) -> u64 {
let mut all: Vec<(&str, &ParamSchema)> = schemas.into_iter().collect();
all.sort_by_key(|(id, _)| *id);
all.iter().fold(0xcbf2_9ce4_8422_2325, |h, (id, schema)| {
let mut text = String::new();
canonical(&schema.to_json(), &mut text);
let h = fnv1a(h, id);
let h = fnv1a(h, "\n");
let h = fnv1a(h, &text);
fnv1a(h, "\n")
})
}
/// A value as an error message shows it: its JSON, cut to 40 characters.
fn shown(v: &Value) -> String {
let s = v.to_string();
if s.chars().count() > 40 { format!("{}…", s.chars().take(40).collect::<String>()) } else { s }
}
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<String, Value>) -> Result<Option<Map<String, Value>>, 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() } });
}
let mut filled: Option<Map<String, Value>> = None;
for p in params {
let at = || join(path, 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);
}
continue;
}
None if p.required => return Err(ParamError { path: at(), problem: Problem::Missing }),
None => {}
}
// Omitted, or null read as omitted.
if let Omitted::Value(json) = p.omitted
&& let Ok(v) = serde_json::from_str::<Value>(json)
{
filled.get_or_insert_with(|| given.clone()).insert(p.name.to_string(), v);
}
}
Ok(filled)
}
/// What a value of type `ty` must be, as an error message says it.
fn expected(ty: &Type) -> String {
match ty {
Type::Number { min, max, unit } => {
format!("a number{}{}", range_text(min.as_ref(), max.as_ref()), unit.map(|u| format!(" ({u})")).unwrap_or_default())
}
Type::Integer { min, max } => {
format!("an integer{}", range_text(min.map(|m| Bound::Inclusive(m as f64)).as_ref(), max.map(|m| Bound::Inclusive(m as f64)).as_ref()))
}
Type::Boolean => "true or false".into(),
Type::String => "a string".into(),
Type::Enum(names) => format!("one of {}", names.iter().map(|n| format!("\"{n}\"")).collect::<Vec<_>>().join(", ")),
Type::Id => "an object id (a non-negative integer)".into(),
Type::Ids => "a list of object ids".into(),
Type::Color => "a color (a string, [r, g, b] or an object)".into(),
Type::Point => "[x, y] (two numbers)".into(),
Type::Object(_) => "an object".into(),
Type::Array(_) => "a list".into(),
}
}
/// ` > 0`, ` 0 to 100`, ` >= 1 and < 10`: the range text after a number's type.
fn range_text(min: Option<&Bound>, max: Option<&Bound>) -> String {
match (min, max) {
(Some(Bound::Inclusive(a)), Some(Bound::Inclusive(b))) => format!(" {a} to {b}"),
(Some(a), Some(b)) => format!("{} and{}", low_text(a), high_text(b)),
(Some(a), None) => low_text(a),
(None, Some(b)) => high_text(b),
(None, None) => String::new(),
}
}
fn low_text(b: &Bound) -> String {
match b {
Bound::Inclusive(a) => format!(" >= {a}"),
Bound::Exclusive(a) => format!(" > {a}"),
}
}
fn high_text(b: &Bound) -> String {
match b {
Bound::Inclusive(a) => format!(" <= {a}"),
Bound::Exclusive(a) => format!(" < {a}"),
}
}
fn in_range(x: f64, min: Option<&Bound>, max: Option<&Bound>) -> bool {
let low = match min {
Some(Bound::Inclusive(a)) => x >= *a,
Some(Bound::Exclusive(a)) => x > *a,
None => true,
};
let high = match max {
Some(Bound::Inclusive(b)) => x <= *b,
Some(Bound::Exclusive(b)) => x < *b,
None => true,
};
low && high
}
fn is_id(v: &Value) -> bool {
v.as_u64().is_some()
}
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<Option<Value>, 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<Vec<Value>> = 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));
}
};
if ok || lenient { Ok(None) } else { Err(wrong()) }
}
/// `{a: number, b?: bool}`: the params object of a params text.
fn object_text(params: &[Param]) -> String {
format!("{{{}}}", params.iter().map(param_text).collect::<Vec<_>>().join(", "))
}
fn param_text(p: &Param) -> String {
let mut s = format!("{}{}: {}", p.name, if p.required { "" } else { "?" }, type_text(&p.ty));
match p.omitted {
Omitted::Value(v) | Omitted::Note(v) => s.push_str(&format!(" (default: {v})")),
Omitted::Nothing => {}
}
if !p.doc.is_empty() {
s.push_str(&format!(" ({})", p.doc));
}
if p.deprecated.is_some() {
s.push_str(" (deprecated)");
}
s
}
fn type_text(ty: &Type) -> String {
match ty {
Type::Number { min, max, unit } => {
format!("number{}{}", range_text(min.as_ref(), max.as_ref()), unit.map(|u| format!(" {u}")).unwrap_or_default())
}
Type::Integer { min, max } => {
format!("integer{}", range_text(min.map(|m| Bound::Inclusive(m as f64)).as_ref(), max.map(|m| Bound::Inclusive(m as f64)).as_ref()))
}
Type::Boolean => "bool".into(),
Type::String => "string".into(),
Type::Enum(names) => names.iter().map(|n| format!("\"{n}\"")).collect::<Vec<_>>().join("|"),
Type::Id => "id".into(),
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", "properties": …, "required": […], "additionalProperties": false}`.
fn object_schema(params: &[Param]) -> Map<String, Value> {
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()));
let required: Vec<Value> = params.iter().filter(|p| p.required).map(|p| p.name.into()).collect();
if !required.is_empty() {
o.insert("required".into(), required.into());
}
o.insert("additionalProperties".into(), false.into());
o
}
fn param_schema(p: &Param) -> Value {
let inner = type_schema(&p.ty);
let mut o = if p.null_omitted {
let mut o = Map::new();
o.insert("anyOf".into(), json!([inner, {"type": "null"}]));
o
} else {
match inner {
Value::Object(o) => o,
_ => Map::new(),
}
};
if !p.doc.is_empty() {
o.insert("description".into(), p.doc.into());
}
match p.omitted {
Omitted::Value(json) => {
if let Ok(v) = serde_json::from_str::<Value>(json) {
o.insert("default".into(), v);
}
}
Omitted::Note(note) => {
o.insert("x-default".into(), note.into());
}
Omitted::Nothing => {}
}
o.insert("x-since".into(), p.since.into());
if let Some(level) = p.deprecated {
o.insert("deprecated".into(), true.into());
o.insert("x-deprecatedSince".into(), level.into());
}
if p.lenient {
o.insert("x-lenient".into(), true.into());
}
Value::Object(o)
}
fn bound_keywords(o: &mut Map<String, Value>, min: Option<&Bound>, max: Option<&Bound>) {
let low = match min {
Some(Bound::Inclusive(a)) => Some(("minimum", a)),
Some(Bound::Exclusive(a)) => Some(("exclusiveMinimum", a)),
None => None,
};
let high = match max {
Some(Bound::Inclusive(b)) => Some(("maximum", b)),
Some(Bound::Exclusive(b)) => Some(("exclusiveMaximum", b)),
None => None,
};
for (key, x) in low.into_iter().chain(high) {
o.insert(key.into(), json!(x));
}
}
fn type_schema(ty: &Type) -> Value {
match ty {
Type::Number { min, max, unit } => {
let mut o = Map::new();
o.insert("type".into(), "number".into());
bound_keywords(&mut o, min.as_ref(), max.as_ref());
if let Some(u) = unit {
o.insert("x-unit".into(), (*u).into());
}
Value::Object(o)
}
Type::Integer { min, max } => {
let mut o = Map::new();
o.insert("type".into(), "integer".into());
if let Some(m) = min {
o.insert("minimum".into(), (*m).into());
}
if let Some(m) = max {
o.insert("maximum".into(), (*m).into());
}
Value::Object(o)
}
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::Color => json!({
"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "number"}, "minItems": 3}, {"type": "object"}],
"x-kind": "color",
}),
Type::Point => json!({
"type": "array",
"prefixItems": [{"type": "number"}, {"type": "number"}],
"items": false,
"minItems": 2,
"x-kind": "point",
}),
Type::Object(params) => Value::Object(object_schema(params)),
Type::Array(item) => json!({"type": "array", "items": type_schema(item)}),
}
}
#[cfg(test)]
mod tests;
+186
View File
@@ -0,0 +1,186 @@
//! Tests of the validator, the params text and the JSON form, on schemas written here.
use serde_json::{Value, json};
use crate::*;
/// The schema with the one parameter `x` of type `ty`.
fn one(ty: Type) -> ParamSchema {
let params: &'static [Param] = Box::leak(Box::new([Param::new("x", ty)]));
ParamSchema::new(params, "")
}
/// Whether `{x: v}` passes the schema of `ty`; on a refusal, the error must name `x`.
fn passes(ty: Type, v: Value) -> bool {
match one(ty).validate(&json!({ "x": v })) {
Ok(_) => true,
Err(e) => {
assert!(e.path.starts_with('x'), "{e}");
assert!(e.to_string().contains("`x"), "{e}");
false
}
}
}
#[test]
fn numbers_are_checked_against_their_range() {
let any = Type::NUMBER;
assert!(passes(any, json!(-3.5)) && passes(any, json!(7)));
assert!(!passes(any, json!("7")) && !passes(any, json!(null)) && !passes(any, json!(true)));
let positive = Type::Number { min: Some(Bound::Exclusive(0.0)), max: None, unit: None };
assert!(passes(positive, json!(0.001)));
assert!(!passes(positive, json!(0)) && !passes(positive, json!(-1)));
let percent = Type::Number { min: Some(Bound::Inclusive(0.0)), max: Some(Bound::Inclusive(100.0)), unit: Some("%") };
assert!(passes(percent, json!(0)) && passes(percent, json!(100)));
assert!(!passes(percent, json!(100.5)));
let below = Type::Number { min: None, max: Some(Bound::Exclusive(1.0)), unit: None };
assert!(passes(below, json!(0.99)) && !passes(below, json!(1)));
assert_eq!(one(positive).validate(&json!({"x": 0})).unwrap_err().to_string(), "`x` must be a number > 0, not 0");
assert_eq!(one(percent).validate(&json!({"x": 101})).unwrap_err().to_string(), "`x` must be a number 0 to 100 (%), not 101");
}
#[test]
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")));
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)));
}
#[test]
fn booleans_and_strings_take_only_their_own_type() {
assert!(passes(Type::Boolean, json!(true)) && passes(Type::Boolean, json!(false)));
assert!(!passes(Type::Boolean, json!("true")) && !passes(Type::Boolean, json!(1)) && !passes(Type::Boolean, json!(null)));
assert!(passes(Type::String, json!("")) && passes(Type::String, json!("View 1")));
assert!(!passes(Type::String, json!(5)) && !passes(Type::String, json!(["a"])));
assert_eq!(one(Type::Boolean).validate(&json!({"x": "yes"})).unwrap_err().to_string(), "`x` must be true or false, not \"yes\"");
}
#[test]
fn an_enum_takes_one_of_its_names() {
let modes = Type::Enum(&["rgb", "cmyk"]);
assert!(passes(modes, json!("rgb")) && passes(modes, json!("cmyk")));
assert!(!passes(modes, json!("RGB")) && !passes(modes, json!("lab")) && !passes(modes, json!(0)));
assert_eq!(one(modes).validate(&json!({"x": "lab"})).unwrap_err().to_string(), "`x` must be one of \"rgb\", \"cmyk\", not \"lab\"");
}
#[test]
fn ids_are_non_negative_integers() {
assert!(passes(Type::Id, json!(0)) && passes(Type::Id, json!(42)));
assert!(!passes(Type::Id, json!(-1)) && !passes(Type::Id, json!(1.5)) && !passes(Type::Id, json!("42")));
assert!(passes(Type::Ids, json!([])) && passes(Type::Ids, json!([3, 4])));
assert!(!passes(Type::Ids, json!(3)) && !passes(Type::Ids, json!([3, "4"])) && !passes(Type::Ids, json!([-3])));
}
#[test]
fn colors_and_points_have_their_shapes() {
assert!(passes(Type::Color, json!("#ff0000")) && passes(Type::Color, json!([1, 0, 0])) && passes(Type::Color, json!({"gray": 50})));
assert!(!passes(Type::Color, json!(5)) && !passes(Type::Color, json!([1, 0])) && !passes(Type::Color, json!([1, 0, "x"])));
assert!(passes(Type::Point, json!([0, -2.5])));
assert!(!passes(Type::Point, json!([1])) && !passes(Type::Point, json!([1, 2, 3])) && !passes(Type::Point, json!([1, "2"])));
assert!(!passes(Type::Point, json!({"x": 1, "y": 2})));
}
const EDIT: &[Param] = &[Param::new("name", Type::String), Param::new("delete", Type::Boolean).defaults_to("false")];
#[test]
fn objects_and_arrays_name_the_path_of_the_value_inside() {
let edits = Type::Array(&Type::Object(EDIT));
assert!(passes(edits, json!([])) && passes(edits, json!([{"name": "A"}, {"name": "B", "delete": true}])));
let err = |v: Value| one(edits).validate(&json!({ "x": v })).unwrap_err();
assert_eq!(err(json!([{"name": "A"}, {"name": "B", "delete": "no"}])).path, "x[1].delete");
assert_eq!(err(json!([{"name": "A"}, {"delete": true}])).to_string(), "missing `x[1].name`");
let unknown = err(json!([{"name": "A", "nmae": "B"}]));
assert_eq!((unknown.path.as_str(), unknown.problem), ("x[0].nmae", Problem::Unknown { keys: vec!["name", "delete"] }));
assert_eq!(err(json!({"name": "A"})).to_string(), "`x` must be a list, not {\"name\":\"A\"}");
assert_eq!(err(json!(["A"])).to_string(), "`x[0]` must be an object, not \"A\"");
// Defaults inside are filled in too.
let filled = one(edits).validate(&json!({"x": [{"name": "A"}]})).unwrap();
assert_eq!(filled, Some(json!({"x": [{"name": "A", "delete": false}]})));
}
const VIEW: &[Param] = &[
Param::new("name", Type::String).default_note("\"View N\"").null_omitted(),
Param::new("center", Type::Point),
Param::new("rotation", Type::Number { min: None, max: None, unit: Some("deg") }).defaults_to("0"),
];
#[test]
fn keys_requirements_nulls_and_defaults() {
let s = ParamSchema::new(VIEW, "save a view").returns("{name}");
// Unknown keys are refused first, naming the key and the keys the command takes.
let e = s.validate(&json!({"centre": [0, 0]})).unwrap_err();
assert_eq!((e.path.as_str(), e.problem), ("centre", Problem::Unknown { keys: vec!["name", "center", "rotation"] }));
assert_eq!(s.validate(&json!({})).unwrap_err(), ParamError { path: "center".into(), problem: Problem::Missing });
// Null counts as omitted only where the schema says so.
assert_eq!(s.validate(&json!({"center": [1, 2], "name": null, "rotation": 15})).unwrap(), None);
assert_eq!(s.validate(&json!({"center": [1, 2], "rotation": null})).unwrap_err().path, "rotation");
// An omitted value with a default gets it; one with a note is left to the command.
assert_eq!(s.validate(&json!({"center": [1, 2]})).unwrap(), Some(json!({"center": [1, 2], "rotation": 0})));
// Params that aren't an object are refused; null params are `{}`.
assert_eq!(s.validate(&json!(5)).unwrap_err().to_string(), "the params must be an object, not 5");
assert_eq!(s.validate(&Value::Null).unwrap_err().path, "center");
let none = ParamSchema::new(&[], "");
assert_eq!(none.validate(&Value::Null).unwrap(), None);
assert_eq!(none.validate(&json!({"a": 1})).unwrap_err().to_string(), "unknown parameter `a`; the object takes no keys");
}
const LENIENT: &[Param] = &[Param::new("on", Type::Boolean).optional().lenient(), Param::new("size", Type::NUMBER).optional().lenient()];
#[test]
fn lenient_parameters_pass_values_of_another_type_to_the_command() {
let s = ParamSchema::new(LENIENT, "");
assert_eq!(s.validate(&json!({"on": "yes", "size": "12"})).unwrap(), None);
// Unknown keys are still refused.
assert!(s.validate(&json!({"of": true})).is_err());
}
#[test]
fn the_params_text_lists_each_parameter_with_its_type() {
let s = ParamSchema::new(VIEW, "save a view").returns("{name}");
assert_eq!(s.params_text(), "{name?: string (default: \"View N\"), center: [x, y], rotation?: number deg (default: 0)} save a view → {name}");
const ALL: &[Param] = &[
Param::new("a", Type::Integer { min: Some(1), max: Some(25) }),
Param::new("b", Type::Enum(&["x", "y"])).optional(),
Param::new("c", Type::Ids).optional().doc("instead of the selection"),
Param::new("d", Type::Color).optional().deprecated(2),
Param::new("e", Type::Array(&Type::Object(EDIT))).optional(),
Param::new("f", Type::Number { min: Some(Bound::Exclusive(0.0)), max: Some(Bound::Inclusive(10.0)), unit: Some("pt") }).optional(),
];
assert_eq!(
ParamSchema::new(ALL, "").params_text(),
"{a: integer 1 to 25, b?: \"x\"|\"y\", c?: [id…] (instead of the selection), d?: color (deprecated), \
e?: [{name: string, delete?: bool (default: false)}…], f?: number > 0 and <= 10 pt}"
);
}
#[test]
fn the_json_form_is_an_object_schema() {
let s = ParamSchema::new(VIEW, "save a view").returns("{name}").writes(Writes::Document).cost(2);
let j = s.to_json();
assert_eq!(j["$schema"], DIALECT);
assert_eq!(j["type"], "object");
assert_eq!(j["additionalProperties"], false);
assert_eq!(j["required"], json!(["center"]));
assert_eq!(j["properties"]["name"]["anyOf"], json!([{"type": "string"}, {"type": "null"}]));
assert_eq!(j["properties"]["name"]["x-default"], "\"View N\"");
assert_eq!(j["properties"]["rotation"], json!({"type": "number", "x-unit": "deg", "default": 0, "x-since": 1}));
assert_eq!(j["properties"]["center"]["prefixItems"], json!([{"type": "number"}, {"type": "number"}]));
assert_eq!((j["x-cost"].clone(), j["x-writes"].clone(), j["x-returns"].clone()), (json!(2), json!("document"), json!("{name}")));
let range = one(Type::Number { min: Some(Bound::Exclusive(0.0)), max: Some(Bound::Inclusive(1.0)), unit: None }).to_json();
assert_eq!(range["properties"]["x"], json!({"type": "number", "exclusiveMinimum": 0.0, "maximum": 1.0, "x-since": 1}));
}
#[test]
fn the_hash_follows_every_schema_but_not_their_order() {
let a = ParamSchema::new(VIEW, "save a view");
let b = ParamSchema::new(LENIENT, "");
let h = hash([("view.a", &a), ("view.b", &b)]);
assert_eq!(h, hash([("view.b", &b), ("view.a", &a)]));
assert_ne!(h, hash([("view.a", &a)]));
assert_ne!(h, hash([("view.a", &a), ("view.c", &b)]));
let changed = ParamSchema::new(VIEW, "save a view").since(2);
assert_ne!(h, hash([("view.a", &changed), ("view.b", &b)]));
}
+2
View File
@@ -35,6 +35,8 @@ vectorcraft-metafile = { workspace = true }
vectorcraft-plugins = { workspace = true }
vectorcraft-affinity = { workspace = true }
vectorcraft-scriptcheck = { workspace = true }
# The commands' parameter schemas (schemas.rs).
vectorcraft-apischema = { workspace = true }
image = { workspace = true }
hayro-jpeg2000 = { version = "=0.3.5", default-features = false, features = ["std", "simd"] }
flate2 = { workspace = true }
+16 -2
View File
@@ -126,6 +126,9 @@ pub struct CommandSpec {
pub run: Run,
/// Record in the journal (false for queries and selection-only helpers).
pub journal: bool,
/// The parameters' schema: [`Session::execute`] checks the params against it before the
/// command runs, and `params` is the text it writes ([`crate::schemas`]).
pub schema: Option<&'static vectorcraft_apischema::ParamSchema>,
}
/// Serializable command metadata.
@@ -139,6 +142,9 @@ pub struct CommandInfo {
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub disabled_reason: Option<String>,
/// The parameters' schema as a JSON Schema 2020-12 object schema, for a command that has one.
#[serde(skip_serializing_if = "Option::is_none")]
pub schema: Option<Value>,
}
impl CommandSpec {
@@ -152,6 +158,7 @@ impl CommandSpec {
params: self.params,
enabled: e.is_ok(),
disabled_reason: e.err(),
schema: self.schema.map(vectorcraft_apischema::ParamSchema::to_json),
}
}
}
@@ -234,10 +241,17 @@ pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
macro_rules! cmd {
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true }
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, schema: None }
};
(query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false }
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, schema: None }
};
// With a parameter schema: `schema: &SCHEMA` after the run function.
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr, schema: $schema:expr) => {
$crate::cmd::CommandSpec { schema: Some($schema), ..$crate::cmd::cmd!($id, $label, [$($m),*], $sc, $params, $en, $run) }
};
(query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr, schema: $schema:expr) => {
$crate::cmd::CommandSpec { schema: Some($schema), ..$crate::cmd::cmd!(query $id, $label, [$($m),*], $sc, $params, $en, $run) }
};
}
pub(crate) use cmd;
+1 -1
View File
@@ -47,7 +47,7 @@ impl Kind {
/// One command spec (the `cmd!` macro wants literals; these ids are built with `concat!`).
const fn spec(id: &'static str, label: &'static str, menu: &'static [&'static str], params: &'static str, run: Run, journal: bool) -> CommandSpec {
CommandSpec { id, label, menu, shortcut: None, params, enabled: has_doc, run, journal }
CommandSpec { id, label, menu, shortcut: None, params, enabled: has_doc, run, journal, schema: None }
}
macro_rules! style_cmds {
+116 -9
View File
@@ -2,33 +2,105 @@
//! toggles (Show Transparency Grid); the Character panel's Snap to Glyph options.
use serde_json::{Value, json};
use vectorcraft_apischema::{Bound, Param, ParamSchema, Type, Writes};
use vectorcraft_doc::SavedView;
use vectorcraft_geom::Point;
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, f64_req, finite_numbers, has_doc, str_param, switch_or};
// The parameter schemas. Each command's params text is the text its schema writes
// (`ParamSchema::params_text`), which a test in `crate::schemas` checks.
static CAPS_LOCK: ParamSchema = ParamSchema::new(
&[Param::new("on", Type::Boolean).optional()],
"set or read the Caps Lock state the desktop app reports; while it is on, the drawing tools show the precise crosshair, and Use Precise Cursors keeps its saved value",
)
.returns("{on}")
.writes(Writes::Session);
static NEW_VIEW: ParamSchema = ParamSchema::new(
&[
Param::new("name", Type::String).default_note("the first free \"View N\"").null_omitted(),
Param::new("center", Type::Point),
Param::new("zoom", Type::Number { min: Some(Bound::Exclusive(0.0)), max: None, unit: None }).doc("1 = 100%"),
Param::new("rotation", Type::Number { min: None, max: None, unit: Some("deg") }).defaults_to("0"),
],
"save a view in the document (the app fills in the current one; at most 25, names up to 255 characters, each name once)",
)
.returns("{name, index}");
/// One rename or delete of Edit Views….
const VIEW_EDIT: &[Param] = &[
Param::new("name", Type::String),
Param::new("newName", Type::String).optional().null_omitted(),
Param::new("delete", Type::Boolean).optional().null_omitted(),
];
static EDIT_VIEWS: ParamSchema = ParamSchema::new(
&[
Param::new("name", Type::String).optional().null_omitted(),
Param::new("newName", Type::String).optional().null_omitted().doc("rename"),
Param::new("delete", Type::Boolean).optional().null_omitted(),
Param::new("edits", Type::Array(&Type::Object(VIEW_EDIT))).optional().null_omitted().doc("instead of name"),
],
"rename or delete saved views, all in one undo step; names are those before the edit and must stay unique; no params → {views}",
);
static LIST_VIEWS: ParamSchema = ParamSchema::new(&[], "").returns("{views: [{name, center, zoom, rotation}]}").writes(Writes::Nothing);
static TRANSPARENCY_GRID: ParamSchema = ParamSchema::new(
&[Param::new("on", Type::Boolean).default_note("toggle").null_omitted()],
"show the transparency grid behind the active document's artboards (each document has its own setting)",
)
.returns("{on}");
static GLYPH_SNAP: ParamSchema = ParamSchema::new(
&[
Param::new("baseline", Type::Boolean).optional(),
Param::new("xHeight", Type::Boolean).optional(),
Param::new("bounds", Type::Boolean).optional(),
Param::new("proximity", Type::Boolean).optional(),
Param::new("angular", Type::Boolean).optional(),
Param::new("anchors", Type::Boolean).optional(),
],
"the Character panel's Snap to Glyph options, kept in the preferences (the glyphSnap group, all on by default): which guides View › Snap to Glyph gives: the baseline and x-height of a single line of type, Glyph Bounds (each glyph's edges), Proximity Guides (both edges of each character's width), Angular Guides (a glyph's slanted strokes, and every guide of rotated type) and the glyphs' anchor points; {} reads them",
)
.returns("the options")
.writes(Writes::Preferences);
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "view.capsLock", "Caps Lock Cursor State", [], None, "{on?: bool} set or read the Caps Lock state the desktop app reports; while it is on, the drawing tools show the precise crosshair, and Use Precise Cursors keeps its saved value → {on}", always, caps_lock),
cmd!(
query "view.capsLock",
"Caps Lock Cursor State",
[],
None,
"{on?: bool} set or read the Caps Lock state the desktop app reports; while it is on, the drawing tools show the precise crosshair, and Use Precise Cursors keeps its saved value → {on}",
always,
caps_lock,
schema: &CAPS_LOCK
),
cmd!(
"view.saved.new",
"New View…",
["View"],
None,
"{name?, center: [x, y], zoom: (1 = 100%), rotation?: deg} save a view in the document (the app fills in the current one; default \"View N\"; at most 25, names up to 255 characters, each name once) → {name, index}",
"{name?: string (default: the first free \"View N\"), center: [x, y], zoom: number > 0 (1 = 100%), rotation?: number deg (default: 0)} save a view in the document (the app fills in the current one; at most 25, names up to 255 characters, each name once) → {name, index}",
has_doc,
new_view
new_view,
schema: &NEW_VIEW
),
cmd!(
"view.saved.edit",
"Edit Views…",
["View"],
None,
"{name, newName?: rename, delete?: bool} | {edits: [{name, newName?, delete?}…]} rename or delete saved views, all in one undo step; names are those before the edit and must stay unique; no params → {views}",
"{name?: string, newName?: string (rename), delete?: bool, edits?: [{name: string, newName?: string, delete?: bool}…] (instead of name)} rename or delete saved views, all in one undo step; names are those before the edit and must stay unique; no params → {views}",
has_views,
edit_views
edit_views,
schema: &EDIT_VIEWS
),
cmd!(query "view.saved.list", "Saved Views", [], None, "{} → {views: [{name, center, zoom, rotation}]}", has_doc, list_views),
cmd!(query "view.saved.list", "Saved Views", [], None, "{} → {views: [{name, center, zoom, rotation}]}", has_doc, list_views, schema: &LIST_VIEWS),
cmd!(
query "view.transparencyGrid",
"Show Transparency Grid",
@@ -36,16 +108,18 @@ pub fn specs() -> Vec<CommandSpec> {
Some("Cmd+Shift+D"),
"{on?: bool (default: toggle)} show the transparency grid behind the active document's artboards (each document has its own setting) → {on}",
has_doc,
transparency_grid
transparency_grid,
schema: &TRANSPARENCY_GRID
),
cmd!(
query "view.snapToGlyph.options",
"Snap to Glyph Options",
[],
None,
"{baseline?: bool, xHeight?: bool, bounds?: bool, proximity?: bool, angular?: bool, anchors?: bool} the Character panel's Snap to Glyph options, kept in the preferences (the glyphSnap group, all on by default): which guides View › Snap to Glyph gives — the baseline and x-height of a single line of type, Glyph Bounds (each glyph's edges), Proximity Guides (both edges of each character's width), Angular Guides (a glyph's slanted strokes, and every guide of rotated type) and the glyphs' anchor points; {} reads them → the options",
"{baseline?: bool, xHeight?: bool, bounds?: bool, proximity?: bool, angular?: bool, anchors?: bool} the Character panel's Snap to Glyph options, kept in the preferences (the glyphSnap group, all on by default): which guides View › Snap to Glyph gives: the baseline and x-height of a single line of type, Glyph Bounds (each glyph's edges), Proximity Guides (both edges of each character's width), Angular Guides (a glyph's slanted strokes, and every guide of rotated type) and the glyphs' anchor points; {} reads them → the options",
always,
glyph_snap_options
glyph_snap_options,
schema: &GLYPH_SNAP
),
]
}
@@ -280,6 +354,39 @@ mod tests {
assert_eq!(s.execute("view.saved.new", &json!({"name": long, "center": [0, 0], "zoom": 1})).unwrap()["name"], "x".repeat(255));
}
/// Each command refuses a value its schema doesn't take with an error naming the parameter, and
/// leaves the document, its views, its undo history and the settings as they were.
#[test]
fn each_view_command_refuses_a_bad_value_naming_the_parameter() {
let mut s = session_with_views(&["A"]);
for (id, params, named) in [
("view.capsLock", json!({"on": "yes"}), "`on`"),
("view.capsLock", json!({"of": true}), "`of`"),
("view.saved.new", json!({"center": [0, 0], "zoom": 0}), "`zoom`"),
("view.saved.new", json!({"center": [0, "0"], "zoom": 1}), "`center`"),
("view.saved.new", json!({"center": [0, 0], "zoom": 1, "rotation": "15"}), "`rotation`"),
("view.saved.new", json!({"name": 5, "center": [0, 0], "zoom": 1}), "`name`"),
("view.saved.new", json!({"zoom": 1}), "`center`"),
("view.saved.edit", json!({"name": "A", "delete": "yes"}), "`delete`"),
("view.saved.edit", json!({"name": "A", "newName": 7}), "`newName`"),
("view.saved.edit", json!({"edits": [{"name": "A", "delete": 1}]}), "`edits[0].delete`"),
("view.saved.edit", json!({"edits": [{"newName": "B"}]}), "`edits[0].name`"),
("view.saved.edit", json!({"edits": "A"}), "`edits`"),
("view.saved.list", json!({"all": true}), "`all`"),
("view.transparencyGrid", json!({"on": 1}), "`on`"),
("view.snapToGlyph.options", json!({"bounds": "off"}), "`bounds`"),
] {
let st = s.doc().unwrap();
let doc = (st.doc.views.clone(), st.history.undo.len(), st.is_dirty());
let (grid, caps, snap) = (st.transparency_grid, s.caps_lock, s.prefs.glyph_snap);
let e = s.execute(id, &params).unwrap_err().to_string();
assert!(e.contains(id) && e.contains(named), "{id} {params}: {e}");
let st = s.doc().unwrap();
assert_eq!((&st.doc.views, st.history.undo.len(), st.is_dirty()), (&doc.0, doc.1, doc.2), "{id} {params}");
assert_eq!((st.transparency_grid, s.caps_lock, s.prefs.glyph_snap), (grid, caps, snap), "{id} {params}");
}
}
/// Edit Views… is off until the document has a view.
#[test]
fn edit_views_needs_a_saved_view() {
+1
View File
@@ -12,6 +12,7 @@ pub mod guard;
pub mod inspect;
pub mod journal;
pub mod link_watch;
pub mod schemas;
pub mod steps;
pub mod store;
mod tooling;
+196
View File
@@ -0,0 +1,196 @@
//! The commands' parameter schemas ([`vectorcraft_apischema::ParamSchema`]) and the API level they
//! 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
//! 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.
//!
//! [`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.
use serde_json::Value;
use vectorcraft_apischema::Problem;
use crate::Result;
use crate::cmd::params::unknown;
use crate::cmd::{CommandSpec, bad, command_specs};
/// The API level of the commands' parameters.
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`]).
pub fn schema_hash() -> u64 {
vectorcraft_apischema::hash(command_specs().iter().filter_map(|c| Some((c.id, c.schema?))))
}
/// `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<Option<Value>> {
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()),
})
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use serde_json::{Map, Value, json};
use vectorcraft_apischema::{Bound, Omitted, Param, ParamSchema, Type};
use super::*;
use crate::{EngineError, Session};
fn schemas() -> Vec<(&'static str, &'static ParamSchema)> {
command_specs().iter().filter_map(|c| Some((c.id, c.schema?))).collect()
}
/// The params text of a command with a schema is the text the schema writes.
#[test]
fn params_text_is_the_text_the_schema_writes() {
assert!(!schemas().is_empty());
for c in command_specs() {
if let Some(schema) = c.schema {
assert_eq!(c.params, schema.params_text(), "{}", c.id);
}
}
}
/// A schema changed without a new API level (and a new row of hashes) fails here: add the row
/// `(API_VERSION + 1, <the hash below>)` and raise [`API_VERSION`].
#[test]
fn schema_hash_matches_the_api_version() {
let last = SCHEMA_HASHES.last().copied();
assert_eq!(last.map(|(level, _)| level), Some(API_VERSION), "the last row of SCHEMA_HASHES is not API_VERSION's");
assert_eq!(last.map(|(_, hash)| hash), Some(schema_hash()), "the schemas changed: {:#x}", schema_hash());
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.
#[test]
fn defaults_parse_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);
}
}
}
for (id, schema) in schemas() {
assert!(schema.since >= 1 && schema.since <= API_VERSION, "{id}");
walk(id, schema.params);
}
}
/// 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() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let e = s.execute("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}");
let e = s.execute("view.saved.new", &json!({"center": [0, 0], "zoom": 1, "rotaton": 5})).unwrap_err().to_string();
assert!(e.contains("unknown parameter `rotaton`; did you mean `rotation`?"), "{e}");
assert!(s.doc().unwrap().doc.views.is_empty());
assert!(s.doc().unwrap().history.undo.is_empty());
}
/// The engine's own listing carries the schema of each command that has one.
#[test]
fn engine_commands_list_the_schemas() {
let s = Session::new();
let all = s.commands();
let grid = all.iter().find(|c| c.id == "view.transparencyGrid").and_then(|c| c.schema.clone()).unwrap();
assert_eq!(grid["$schema"], vectorcraft_apischema::DIALECT);
assert_eq!(grid["properties"]["on"]["x-default"], "toggle");
let listed = serde_json::to_value(all.iter().find(|c| c.id == "view.saved.new").unwrap()).unwrap();
assert_eq!(listed["schema"]["required"], json!(["center", "zoom"]));
let without = serde_json::to_value(all.iter().find(|c| c.id == "file.new").unwrap()).unwrap();
assert!(without.get("schema").is_none());
}
/// Values of type `ty` that pass it.
fn valid(ty: Type) -> BoxedStrategy<Value> {
fn number(min: Option<Bound>, max: Option<Bound>) -> BoxedStrategy<Value> {
let low = match min {
Some(Bound::Inclusive(a)) => a,
Some(Bound::Exclusive(a)) => a + 1e-6_f64.max(a.abs() * 1e-9),
None => -1e6,
};
let high = match max {
Some(Bound::Inclusive(b)) => b,
Some(Bound::Exclusive(b)) => b - 1e-6_f64.max(b.abs() * 1e-9),
None => low.max(0.0) + 1e6,
};
(low..=high).prop_map(|x| json!(x)).boxed()
}
match ty {
Type::Number { min, max, .. } => number(min, max),
Type::Integer { min, max } => (min.unwrap_or(-1_000_000)..=max.unwrap_or(1_000_000)).prop_map(|x| json!(x)).boxed(),
Type::Boolean => any::<bool>().prop_map(Value::from).boxed(),
Type::String => "[ -~]{0,12}".prop_map(Value::from).boxed(),
Type::Enum(names) => prop::sample::select(names.to_vec()).prop_map(Value::from).boxed(),
Type::Id => (0u64..10_000).prop_map(Value::from).boxed(),
Type::Ids => prop::collection::vec(0u64..10_000, 0..4).prop_map(Value::from).boxed(),
Type::Color => Just(json!("#336699")).boxed(),
Type::Point => (-1e6..1e6f64, -1e6..1e6f64).prop_map(|(x, y)| json!([x, y])).boxed(),
Type::Object(params) => valid_object(params),
Type::Array(item) => prop::collection::vec(valid(*item), 0..3).prop_map(Value::from).boxed(),
}
}
/// Objects that pass `params`: each required parameter, and each optional one or not.
fn valid_object(params: &'static [Param]) -> BoxedStrategy<Value> {
let parts: Vec<BoxedStrategy<Option<(String, Value)>>> = params
.iter()
.map(|p| {
let entry = valid(p.ty).prop_map(move |v| Some((p.name.to_string(), v)));
if p.required { entry.boxed() } else { prop_oneof![Just(None), entry].boxed() }
})
.collect();
parts.prop_map(|entries| Value::Object(entries.into_iter().flatten().collect::<Map<String, Value>>())).boxed()
}
/// Commands with a schema and params for them that pass it.
fn valid_calls() -> BoxedStrategy<(&'static str, Value)> {
let calls: Vec<BoxedStrategy<(&'static str, Value)>> =
schemas().into_iter().map(|(id, schema)| valid_object(schema.params).prop_map(move |p| (id, p)).boxed()).collect();
prop::strategy::Union::new(calls).boxed()
}
proptest! {
/// Params that pass a command's schema never get a parameter error about a value's type or
/// range from the command itself: the schema refuses whatever the command's own checks would.
#[test]
fn schema_valid_params_get_no_type_error_from_the_command((id, params) in valid_calls(), views in 0usize..3) {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
for i in 0..views {
s.execute("view.saved.new", &json!({"name": format!("V{i}"), "center": [0, 0], "zoom": 1})).unwrap();
}
match s.execute(id, &params) {
Err(EngineError::BadParams { msg, .. }) => {
prop_assert!(!msg.contains(" must be ") && !msg.contains("missing") && !msg.contains("unknown parameter"), "{id} {params}: {msg}");
}
Err(EngineError::Internal { msg, .. }) => prop_assert!(false, "{id} {params} panicked: {msg}"),
_ => {}
}
}
}
}
+5 -1
View File
@@ -35,6 +35,10 @@ impl Session {
/// Execute a command by id. This is THE entry point for every frontend.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| cmd::params::unknown_command(id))?;
// 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)?;
let run_params = checked.as_ref().unwrap_or(params);
// The crate's own tests are the audit of the params descriptions (#1442): a key a test
// passes that its command's description doesn't name fails the test.
#[cfg(test)]
@@ -91,7 +95,7 @@ impl Session {
if let Some(ids) = &select_first {
s.execute("select.set", &serde_json::json!({ "ids": ids }))?;
}
let r = (spec.run)(s, params);
let r = (spec.run)(s, run_params);
if r.is_err()
&& let Some((uid, selection, layer)) = selected_before
&& let Some(st) = s.docs.iter_mut().find(|d| d.uid == uid)
+28
View File
@@ -1253,6 +1253,34 @@ reference that names no value fails the step, so a command never runs with one l
vectorcraft-cli run --cmd file.new --params '{"width":600,"height":200}' --cmd text.create --params '{"x":10,"y":60,"text":"plain and bold words","size":30}' --cmd text.setRangeStyle --params '{"id":"$1.id","start":10,"end":14,"style":"Bold"}'
```
### Parameter schemas
Some commands have a machine-readable parameter schema: at present `view.capsLock`, `view.saved.new`,
`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
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.
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:
```json
{"id": "view.saved.new", "params": {"center": [0, 0], "zoom": 0}}
→ invalid parameters for `view.saved.new`: `zoom` must be a number > 0, not 0
{"id": "view.saved.edit", "params": {"edits": [{"name": "A", "delete": 1}]}}
→ invalid parameters for `view.saved.edit`: `edits[0].delete` must be true or false, not 1
```
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.
## Checking scripts written for the reference app
`script.check {path? | data? | dataBase64?, name?, includes?}` reads a script written for the reference app
+1
View File
@@ -617,6 +617,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 and JPEG 2000, `.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`.
- **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 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.
+1
View File
@@ -63,6 +63,7 @@ pub const TABLE: &[(&str, Class)] = &[
("plugins", Class::Layer(2)),
("affinity", Class::Standalone),
("scriptcheck", Class::Standalone),
("apischema", Class::Standalone),
];
/// Explicit orderings *within* a layer (earlier may be used by later).