330 lines
14 KiB
Rust
330 lines
14 KiB
Rust
//! The plug-in manifest (`vc_manifest`) and parameter schema.
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::{Map, Value, json};
|
||
|
||
use crate::{Error, Result};
|
||
|
||
/// Longest manifest the host reads.
|
||
pub const MAX_MANIFEST_BYTES: usize = 64 * 1024;
|
||
/// Most parameters a plug-in may declare.
|
||
pub const MAX_PARAMS: usize = 64;
|
||
|
||
/// What a plug-in is.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub enum Kind {
|
||
/// An object filter: reads the selected paths and returns the objects to keep, change or add
|
||
/// (Object › Plug-ins, `plugin.run`).
|
||
Filter,
|
||
/// A live effect: rewrites one object's geometry each time it is drawn, from its appearance
|
||
/// stack (Effect › Plug-ins, `effect.apply`).
|
||
Effect,
|
||
}
|
||
|
||
impl Kind {
|
||
pub fn name(self) -> &'static str {
|
||
match self {
|
||
Kind::Filter => "filter",
|
||
Kind::Effect => "effect",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One declared parameter.
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(tag = "type", rename_all = "camelCase")]
|
||
pub enum ParamSpec {
|
||
/// A real number in `min..=max`.
|
||
Number { min: f64, max: f64, default: f64 },
|
||
/// A whole number in `min..=max`.
|
||
Int {
|
||
#[serde(default = "int_min")]
|
||
min: i64,
|
||
#[serde(default = "int_max")]
|
||
max: i64,
|
||
#[serde(default)]
|
||
default: i64,
|
||
},
|
||
/// A checkbox.
|
||
Bool {
|
||
#[serde(default)]
|
||
default: bool,
|
||
},
|
||
/// One of `options` (a dropdown).
|
||
Choice {
|
||
options: Vec<String>,
|
||
#[serde(default)]
|
||
default: Option<String>,
|
||
},
|
||
}
|
||
|
||
fn int_min() -> i64 {
|
||
-1_000_000
|
||
}
|
||
fn int_max() -> i64 {
|
||
1_000_000
|
||
}
|
||
|
||
impl ParamSpec {
|
||
/// The value a parameter takes when it isn't given.
|
||
pub fn default_value(&self) -> Value {
|
||
match self {
|
||
ParamSpec::Number { default, .. } => json!(default),
|
||
ParamSpec::Int { default, .. } => json!(default),
|
||
ParamSpec::Bool { default } => json!(default),
|
||
ParamSpec::Choice { options, default } => json!(default.clone().or_else(|| options.first().cloned()).unwrap_or_default()),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The manifest a plug-in returns from `vc_manifest` (UTF-8 JSON).
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct Manifest {
|
||
/// Stable id, `[A-Za-z0-9._-]{1,64}` (reverse-DNS style is recommended: `org.example.tidy`).
|
||
pub id: String,
|
||
/// Menu label.
|
||
pub name: String,
|
||
#[serde(default)]
|
||
pub version: String,
|
||
pub kind: Kind,
|
||
#[serde(default)]
|
||
pub description: String,
|
||
#[serde(default)]
|
||
pub author: String,
|
||
/// Parameters in declaration (dialog) order; a JSON object `{"name": spec, …}` in the manifest.
|
||
#[serde(default, deserialize_with = "ordered_params", serialize_with = "params_as_object")]
|
||
pub params: Vec<(String, ParamSpec)>,
|
||
}
|
||
|
||
impl Manifest {
|
||
/// Parses and validates manifest JSON.
|
||
pub fn parse(bytes: &[u8]) -> Result<Manifest> {
|
||
if bytes.len() > MAX_MANIFEST_BYTES {
|
||
return Err(Error::Manifest(format!("manifest is {} bytes (limit {MAX_MANIFEST_BYTES})", bytes.len())));
|
||
}
|
||
let text = std::str::from_utf8(bytes).map_err(|_| Error::Manifest("manifest is not UTF-8".into()))?;
|
||
let man: Manifest = serde_json::from_str(text).map_err(|e| Error::Manifest(format!("manifest: {e}")))?;
|
||
man.validate()?;
|
||
Ok(man)
|
||
}
|
||
|
||
/// The manifest of a module still being loaded.
|
||
pub(crate) fn placeholder() -> Manifest {
|
||
Manifest {
|
||
id: String::new(),
|
||
name: String::new(),
|
||
version: String::new(),
|
||
kind: Kind::Filter,
|
||
description: String::new(),
|
||
author: String::new(),
|
||
params: Vec::new(),
|
||
}
|
||
}
|
||
|
||
fn validate(&self) -> Result<()> {
|
||
if !valid_id(&self.id) {
|
||
return Err(Error::Manifest(format!("invalid id {:?}: use 1-64 characters from A-Z a-z 0-9 . _ -", self.id)));
|
||
}
|
||
let name = self.name.trim();
|
||
if name.is_empty() || name.chars().count() > 64 || name.chars().any(char::is_control) {
|
||
return Err(Error::Manifest("`name` must be 1-64 printable characters".into()));
|
||
}
|
||
if self.version.len() > 32 || self.description.len() > 1024 || self.author.len() > 128 {
|
||
return Err(Error::Manifest("`version`, `description` or `author` is too long".into()));
|
||
}
|
||
for (k, spec) in &self.params {
|
||
if !valid_key(k) {
|
||
return Err(Error::Manifest(format!("invalid parameter name {k:?}")));
|
||
}
|
||
match spec {
|
||
ParamSpec::Number { min, max, default } if !(min.is_finite() && max.is_finite() && default.is_finite()) || min > max => {
|
||
return Err(Error::Manifest(format!("parameter `{k}`: bad range")));
|
||
}
|
||
ParamSpec::Int { min, max, .. } if min > max => return Err(Error::Manifest(format!("parameter `{k}`: bad range"))),
|
||
ParamSpec::Choice { options, default } => {
|
||
if options.is_empty()
|
||
|| options.len() > 64
|
||
|| options.iter().any(|o| o.is_empty() || o.len() > 64 || o.chars().any(char::is_control))
|
||
{
|
||
return Err(Error::Manifest(format!("parameter `{k}`: choices must be 1-64 non-empty, printable strings")));
|
||
}
|
||
if default.as_ref().is_some_and(|d| !options.contains(d)) {
|
||
return Err(Error::Manifest(format!("parameter `{k}`: default is not one of the options")));
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// The parameters documented the way command and effect parameters are
|
||
/// (`{amount: 0..100 (50), mode: "soft"|"hard" (soft), mono: bool (false)}`).
|
||
pub fn params_doc(&self) -> String {
|
||
let parts: Vec<String> = self
|
||
.params
|
||
.iter()
|
||
.map(|(k, spec)| match spec {
|
||
ParamSpec::Number { min, max, default } => format!("{k}: {min}..{max} ({default})"),
|
||
ParamSpec::Int { min, max, default } => format!("{k}: int {min}..{max} ({default})"),
|
||
ParamSpec::Bool { default } => format!("{k}: bool ({default})"),
|
||
ParamSpec::Choice { options, .. } => {
|
||
let opts: Vec<String> = options.iter().map(|o| format!("{o:?}")).collect();
|
||
format!("{k}: {} ({})", opts.join("|"), spec.default_value().as_str().unwrap_or_default())
|
||
}
|
||
})
|
||
.collect();
|
||
format!("{{{}}}", parts.join(", "))
|
||
}
|
||
|
||
/// Every parameter at its default value.
|
||
pub fn defaults(&self) -> Map<String, Value> {
|
||
self.params.iter().map(|(k, spec)| (k.clone(), spec.default_value())).collect()
|
||
}
|
||
|
||
/// Validates user `params` against the schema: missing keys take their defaults, numbers are
|
||
/// clamped into range (whole numbers rounded), unknown keys are dropped; a value of the wrong
|
||
/// type is an error.
|
||
pub fn resolve_params(&self, params: &Value) -> Result<Map<String, Value>> {
|
||
let given = match params {
|
||
Value::Null => &Map::new(),
|
||
Value::Object(m) => m,
|
||
_ => return Err(Error::Params("plug-in parameters must be a JSON object".into())),
|
||
};
|
||
let mut out = Map::new();
|
||
for (k, spec) in &self.params {
|
||
let Some(v) = given.get(k).filter(|v| !v.is_null()) else {
|
||
out.insert(k.clone(), spec.default_value());
|
||
continue;
|
||
};
|
||
let wrong = |what: &str| Error::Params(format!("`{k}` must be {what}"));
|
||
let resolved = match spec {
|
||
ParamSpec::Number { min, max, .. } => json!(v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| wrong("a number"))?.clamp(*min, *max)),
|
||
ParamSpec::Int { min, max, .. } => {
|
||
let f = v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| wrong("a number"))?;
|
||
json!((f.round().clamp(*min as f64, *max as f64)) as i64)
|
||
}
|
||
ParamSpec::Bool { .. } => json!(v.as_bool().ok_or_else(|| wrong("true or false"))?),
|
||
ParamSpec::Choice { options, .. } => {
|
||
let s = v.as_str().ok_or_else(|| wrong("a string"))?;
|
||
if !options.iter().any(|o| o == s) {
|
||
return Err(Error::Params(format!("`{k}` must be one of {}", options.join(", "))));
|
||
}
|
||
json!(s)
|
||
}
|
||
};
|
||
out.insert(k.clone(), resolved);
|
||
}
|
||
Ok(out)
|
||
}
|
||
}
|
||
|
||
/// Reads the `params` object keeping its key order (serde_json's `Map` would sort it).
|
||
fn ordered_params<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Vec<(String, ParamSpec)>, D::Error> {
|
||
struct V;
|
||
impl<'de> serde::de::Visitor<'de> for V {
|
||
type Value = Vec<(String, ParamSpec)>;
|
||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||
f.write_str("an object of parameter specs")
|
||
}
|
||
fn visit_unit<E: serde::de::Error>(self) -> std::result::Result<Self::Value, E> {
|
||
Ok(Vec::new())
|
||
}
|
||
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut map: A) -> std::result::Result<Self::Value, A::Error> {
|
||
let mut out: Vec<(String, ParamSpec)> = Vec::new();
|
||
while let Some((k, v)) = map.next_entry::<String, ParamSpec>()? {
|
||
if out.len() >= MAX_PARAMS {
|
||
return Err(serde::de::Error::custom(format!("too many parameters (limit {MAX_PARAMS})")));
|
||
}
|
||
if out.iter().any(|(o, _)| *o == k) {
|
||
return Err(serde::de::Error::custom(format!("parameter `{k}` is declared twice")));
|
||
}
|
||
out.push((k, v));
|
||
}
|
||
Ok(out)
|
||
}
|
||
}
|
||
d.deserialize_any(V)
|
||
}
|
||
|
||
fn params_as_object<S: serde::Serializer>(p: &[(String, ParamSpec)], s: S) -> std::result::Result<S::Ok, S::Error> {
|
||
use serde::ser::SerializeMap;
|
||
let mut m = s.serialize_map(Some(p.len()))?;
|
||
for (k, v) in p {
|
||
m.serialize_entry(k, v)?;
|
||
}
|
||
m.end()
|
||
}
|
||
|
||
/// `[A-Za-z0-9._-]{1,64}`.
|
||
pub fn valid_id(id: &str) -> bool {
|
||
(1..=64).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
||
}
|
||
|
||
/// `[A-Za-z0-9_]{1,64}`, not starting with `_` and not a key the commands and dialogs use
|
||
/// themselves.
|
||
fn valid_key(k: &str) -> bool {
|
||
(1..=64).contains(&k.len())
|
||
&& k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
|
||
&& !k.starts_with('_')
|
||
&& !matches!(k, "id" | "ids" | "params" | "preview")
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
const FULL: &str = r#"{"id":"org.example.tone","name":"Tone","version":"1.2","kind":"effect",
|
||
"params":{"amount":{"type":"number","min":0,"max":100,"default":50},
|
||
"mode":{"type":"choice","options":["soft","hard"],"default":"hard"},
|
||
"mono":{"type":"bool"},"seed":{"type":"int","min":0,"max":9}}}"#;
|
||
|
||
#[test]
|
||
fn parses_and_keeps_param_order() {
|
||
let m = Manifest::parse(FULL.as_bytes()).unwrap();
|
||
assert_eq!(m.id, "org.example.tone");
|
||
assert_eq!(m.kind, Kind::Effect);
|
||
assert_eq!(m.params.iter().map(|p| p.0.as_str()).collect::<Vec<_>>(), ["amount", "mode", "mono", "seed"]);
|
||
assert_eq!(m.params_doc(), r#"{amount: 0..100 (50), mode: "soft"|"hard" (hard), mono: bool (false), seed: int 0..9 (0)}"#);
|
||
assert_eq!(Value::Object(m.defaults()), json!({"amount": 50.0, "mode": "hard", "mono": false, "seed": 0}));
|
||
}
|
||
|
||
#[test]
|
||
fn resolves_defaults_clamps_and_rejects_wrong_types() {
|
||
let m = Manifest::parse(FULL.as_bytes()).unwrap();
|
||
let p = m.resolve_params(&json!({"amount": 500, "seed": 3.6, "junk": 1})).unwrap();
|
||
assert_eq!(Value::Object(p), json!({"amount": 100.0, "mode": "hard", "mono": false, "seed": 4}));
|
||
assert!(m.resolve_params(&json!({"amount": "lots"})).is_err());
|
||
assert!(m.resolve_params(&json!({"mode": "medium"})).is_err());
|
||
assert!(m.resolve_params(&json!({"mono": 1})).is_err());
|
||
assert!(m.resolve_params(&json!([1, 2])).is_err());
|
||
assert!(m.resolve_params(&Value::Null).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_bad_manifests() {
|
||
for bad in [
|
||
"",
|
||
"[]",
|
||
"not json",
|
||
r#"{"id":"","name":"x","kind":"filter"}"#,
|
||
r#"{"id":"a b","name":"x","kind":"filter"}"#,
|
||
r#"{"id":"a","name":"","kind":"filter"}"#,
|
||
r#"{"id":"a","name":"x","kind":"panel"}"#,
|
||
r#"{"id":"a","name":"x"}"#,
|
||
r#"{"id":"a","name":"x","kind":"filter","params":{"r":{"type":"number","min":5,"max":1,"default":1}}}"#,
|
||
r#"{"id":"a","name":"x","kind":"filter","params":{"c":{"type":"choice","options":[]}}}"#,
|
||
r#"{"id":"a","name":"x","kind":"filter","params":{"__x":{"type":"bool"}}}"#,
|
||
r#"{"id":"a","name":"x","kind":"filter","params":{"preview":{"type":"bool"}}}"#,
|
||
r#"{"id":"a","name":"x","kind":"filter","params":{"a":{"type":"bool"},"a":{"type":"bool"}}}"#,
|
||
r#"{"id":"a","name":"x","kind":"filter","params":[1]}"#,
|
||
] {
|
||
assert!(Manifest::parse(bad.as_bytes()).is_err(), "{bad}");
|
||
}
|
||
assert!(Manifest::parse(&[0xff, 0xfe]).is_err());
|
||
assert!(Manifest::parse(&vec![b' '; MAX_MANIFEST_BYTES + 1]).is_err());
|
||
}
|
||
}
|