rlx_core/preset/schema/export.rs
1//! What a preset may contain, as a machine-readable document (Plan 0158
2//! Phase 4).
3//!
4//! A studio building a parameter panel, an expression editor or a table form has
5//! to know two things the engine already knows: every **parameter** a system and
6//! the engine stages accept, and every **key** a structural table accepts. Both
7//! are declared in the engine and read by the loader; this renders them.
8//!
9//! ## The parameter half is generated, not restated
10//!
11//! It walks the same [`ParamSpec`] declarations the generated block in
12//! `presets/README.md` is rendered from (ADR-0170) — the same rosters, in the
13//! same order — so the published table and this document cannot disagree about a
14//! name, a default or a range. `core/tests/suite/preset.rs` renders both from one walk
15//! and asserts they agree.
16//!
17//! ## The structural half is declared beside its serde struct
18//!
19//! Every `[table]` a preset may write carries a [`TableDesc`] next to the `Raw*`
20//! struct the loader deserializes it into. That is a second statement of the
21//! table's shape and it is one on purpose: serde carries a field's *name* and
22//! *type*, and none of what an editor needs — the closed roster a string is
23//! drawn from, the default the loader substitutes, the sentence that says what
24//! the key does. What stops the two drifting is a test, not a construction:
25//! `core/src/preset/schema/tests.rs` reads the field roster serde derived
26//! (through a `Deserializer` that answers nothing and records what it was asked
27//! for) and asserts it is exactly what the descriptor names.
28//!
29//! **No enumeration is written here.** A `KeyKind::Roster` names the type that
30//! owns the closed set, and [`Roster::values`] asks it — so `[feedback] warp`'s
31//! roster is `Warp::ALL` and there is nothing here to fall out of step with it.
32//!
33//! ## The hash
34//!
35//! [`hash`] is FNV-1a over the document **body** — everything the export
36//! declares, without the hash field itself, which would otherwise be hashing its
37//! own output. A studio compares it against the one a player reports in `hello`
38//! to know whether the schema it built its panels from is the schema the player
39//! is running.
40//!
41//! No JSON crate: the writer below is thirty lines and NFR section 4's
42//! dependency gate asks for a justification longer than that.
43
44// A continuation of one module split across several files, so it needs the
45// names `preset/schema/mod.rs` has in scope.
46use super::*;
47
48use crate::render::scenes::{FamilyParam, ParamSpec, family_params};
49
50/// The document format's own version, carried as `v` so a consumer can refuse a
51/// shape it does not know. Independent of the workspace version and of the
52/// event stream's `v`: this one moves when the *document* changes shape, which
53/// is rarer than either.
54pub const SCHEMA_VERSION: u32 = 1;
55
56// ---------------------------------------------------------------------------
57// The descriptor vocabulary
58// ---------------------------------------------------------------------------
59
60/// A closed roster of accepted spellings, named by the type that owns it.
61///
62/// The whole point of the indirection: a `KeyKind::Roster(Roster::Warp)` renders
63/// `Warp::ALL`, so the export cannot list a warp the loader rejects or omit one
64/// it accepts. Writing the strings here instead would be a second copy of every
65/// roster in the engine.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum Roster {
68 /// `system` — the built-in scenes.
69 System,
70 /// `[curve] family`.
71 CurveFamily,
72 /// `[particles] family` — the four maps plus every IFS figure.
73 AttractorFamily,
74 /// `[particles] morph_to` — the IFS figures alone.
75 IfsFigure,
76 /// `[spectrum] layout`.
77 SpectrumLayout,
78 /// `[feedback] warp`.
79 Warp,
80 /// `[feedback] blend`.
81 Deposit,
82 /// `[palette] name`.
83 Palette,
84 /// `[layer] join`.
85 LayerJoin,
86 /// `[layer] blend`.
87 LayerBlend,
88 /// `[generator] rings` motif.
89 Motif,
90 /// `[generator] tiling`.
91 Tiling,
92 /// `[field] family`.
93 FieldFamily,
94 /// `[field] map`.
95 EscapeMap,
96 /// `[field] trap`.
97 TrapShape,
98 /// `[cellular] family`.
99 CellularFamily,
100 /// `[plexus] layout`.
101 PlexusLayout,
102}
103
104impl Roster {
105 /// The accepted spellings, asked of the type that owns the roster.
106 pub fn values(self) -> Vec<&'static str> {
107 use crate::render::feedback::{Deposit, Warp};
108 use crate::render::palette::NamedPalette;
109 use crate::render::scenes::lines::star::Motif;
110 use crate::render::scenes::lines::{CurveFamily, SpectrumLayout};
111 use crate::render::scenes::particles::AttractorFamily;
112 use crate::render::scenes::particles::ifs::IfsFigure;
113 match self {
114 Roster::System => SystemKind::ALL.iter().map(|k| k.as_str()).collect(),
115 Roster::CurveFamily => CurveFamily::ALL.iter().map(|f| f.as_str()).collect(),
116 Roster::AttractorFamily => AttractorFamily::MAPS
117 .iter()
118 .map(|f| f.as_str())
119 .chain(IfsFigure::ALL.iter().map(|f| f.name()))
120 .collect(),
121 Roster::IfsFigure => IfsFigure::ALL.iter().map(|f| f.name()).collect(),
122 Roster::SpectrumLayout => SpectrumLayout::NAMES.to_vec(),
123 Roster::Warp => Warp::ALL.iter().map(|w| w.as_str()).collect(),
124 Roster::Deposit => Deposit::ALL.iter().map(|d| d.as_str()).collect(),
125 Roster::Palette => NamedPalette::ALL.iter().map(|p| p.as_str()).collect(),
126 Roster::LayerJoin => LayerJoin::ALL.iter().map(|j| j.as_str()).collect(),
127 Roster::LayerBlend => LayerBlend::ALL.iter().map(|b| b.as_str()).collect(),
128 Roster::Motif => Motif::ALL.iter().map(|m| m.name()).collect(),
129 Roster::Tiling => crate::render::scenes::lines::hankin::TILINGS.to_vec(),
130 Roster::FieldFamily => FieldFamily::ALL.iter().map(|f| f.as_str()).collect(),
131 Roster::EscapeMap => EscapeMap::ALL.iter().map(|m| m.as_str()).collect(),
132 Roster::TrapShape => TrapShape::ALL.iter().map(|t| t.as_str()).collect(),
133 Roster::CellularFamily => CellularFamily::ALL.iter().map(|f| f.as_str()).collect(),
134 Roster::PlexusLayout => PlexusLayout::ALL.iter().map(|l| l.as_str()).collect(),
135 }
136 }
137
138 /// Whether `name` is in this roster — the membership test the round-trip
139 /// test uses against each type's own `from_name`.
140 pub fn accepts(self, name: &str) -> bool {
141 self.values().contains(&name)
142 }
143}
144
145/// What one structural key accepts.
146///
147/// Deliberately coarser than serde's type: an editor needs to know that
148/// `[path] d` is *free text* and `[curve] family` is *one of a closed set*, and
149/// serde reports both as `String`.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum KeyKind {
152 /// `true` / `false`.
153 Bool,
154 /// A whole number.
155 Int,
156 /// A real number.
157 Float,
158 /// Free text with no roster behind it — a path's `d`, an axiom, a shader
159 /// body.
160 Text,
161 /// An expression in the preset grammar.
162 Expr,
163 /// One of a closed set.
164 Roster(Roster),
165 /// An easing constant: seconds as a number, or `{ attack, release }`.
166 Easing,
167 /// A salt: a number, or the string `"random"` (ADR-0051).
168 Seed,
169 /// A hold edge: `"beat"`, `"bar"`, or a positive number of seconds
170 /// (ADR-0180 rule 2). Not a `Roster`, because the number is not one of a
171 /// closed set and an editor offering only the two words would reject a
172 /// legal entry.
173 Hold,
174 /// A colour: `"#rrggbb"` or `[r, g, b]` in `0..=1`.
175 Colour,
176 /// A table of author-chosen names to values of this kind.
177 Map(&'static KeyKind),
178 /// A list of values of this kind.
179 List(&'static KeyKind),
180 /// A nested table, named in [`TABLES`].
181 Table(&'static str),
182}
183
184impl KeyKind {
185 /// The document's spelling for this kind.
186 fn tag(&self) -> &'static str {
187 match self {
188 KeyKind::Bool => "bool",
189 KeyKind::Int => "int",
190 KeyKind::Float => "float",
191 KeyKind::Text => "text",
192 KeyKind::Expr => "expr",
193 KeyKind::Roster(_) => "enum",
194 KeyKind::Easing => "easing",
195 KeyKind::Seed => "seed",
196 KeyKind::Hold => "hold",
197 KeyKind::Colour => "colour",
198 KeyKind::Map(_) => "map",
199 KeyKind::List(_) => "list",
200 KeyKind::Table(_) => "table",
201 }
202 }
203}
204
205/// One key of one structural table.
206#[derive(Debug, Clone, Copy)]
207pub struct KeyDesc {
208 /// The key as it is written in the file.
209 pub name: &'static str,
210 /// What it accepts.
211 pub kind: KeyKind,
212 /// What the loader uses when the key is absent, written as it would appear
213 /// in TOML. Empty where absence means something other than a value — a
214 /// required key, or one whose absence turns a feature off rather than
215 /// selecting a value.
216 pub default: &'static str,
217 /// One line: what it does.
218 pub doc: &'static str,
219}
220
221/// One structural table a preset may write.
222#[derive(Debug, Clone, Copy)]
223pub struct TableDesc {
224 /// The table's name — its TOML header, or the name a [`KeyKind::Table`]
225 /// refers to it by. `preset` is the document root.
226 pub name: &'static str,
227 /// One line: what the table is for.
228 pub doc: &'static str,
229 /// Its keys, in declaration order.
230 pub keys: &'static [KeyDesc],
231}
232
233/// Every table, root first. A [`KeyKind::Table`] names one of these.
234///
235/// Flat with references rather than nested inline, because `[layer]` carries
236/// most of the root's tables and inlining would print each of them twice — and
237/// a consumer building a form wants one definition per table, not one per site.
238pub const TABLES: &[&TableDesc] = &[
239 &super::raw::PRESET,
240 &super::raw::LAYER,
241 &super::raw::LATCH,
242 &super::raw::CURVE,
243 &super::raw::GENERATOR,
244 &super::raw::RING,
245 &super::raw::PARTICLES,
246 &super::raw::PATH,
247 &super::raw::SPECTRUM,
248 &super::raw::MESH,
249 &super::raw::FIELD,
250 &super::raw::CELLULAR,
251 &super::raw::PLEXUS,
252 &super::raw::MILK,
253 &super::raw::MILK_ELEMENT,
254 &super::raw::FEEDBACK,
255 &super::raw::OCCUPANCY,
256 &super::raw::PALETTE,
257 &super::raw::STOP,
258];
259
260/// The table named `name`, or `None`.
261pub fn table(name: &str) -> Option<&'static TableDesc> {
262 TABLES.iter().copied().find(|t| t.name == name)
263}
264
265// ---------------------------------------------------------------------------
266// The parameter half
267// ---------------------------------------------------------------------------
268
269/// The engine-wide stages, labelled as a reader meets them rather than as the
270/// modules are named.
271///
272/// Zipped against [`GLOBAL_PARAMS`] rather than naming each module, because that
273/// array is already the one statement of which stages a preset may bind whatever
274/// its system. The labels are in its order, and
275/// [`param_rosters`]'s length check is what holds them there.
276pub const STAGE_LABELS: [&str; 7] = [
277 "background",
278 "trails",
279 "kaleidoscope",
280 "bloom",
281 "composite",
282 "tonemap",
283 "ink",
284];
285
286/// Every parameter roster a preset may bind, labelled: one per system, then one
287/// per engine stage.
288///
289/// **The one walk.** The generated reference in `presets/README.md` and the
290/// document below are both rendered from this, so the two cannot state different
291/// defaults for a name (ADR-0170).
292pub fn param_rosters() -> Vec<(&'static str, &'static [ParamSpec])> {
293 let mut out: Vec<(&'static str, &'static [ParamSpec])> = SystemKind::ALL
294 .iter()
295 .map(|kind| (kind.as_str(), kind.param_specs()))
296 .collect();
297 // A stage that joined `GLOBAL_PARAMS` without a label here would be printed
298 // under its neighbour's heading, so it is dropped rather than mislabelled —
299 // and `the_stage_labels_cover_every_global_roster` fails on it.
300 out.extend(STAGE_LABELS.iter().copied().zip(GLOBAL_PARAMS));
301 out
302}
303
304/// How many of the rosters are engine stages rather than systems — the tail of
305/// [`param_rosters`].
306pub fn stage_count() -> usize {
307 STAGE_LABELS.len().min(GLOBAL_PARAMS.len())
308}
309
310// ---------------------------------------------------------------------------
311// The document
312// ---------------------------------------------------------------------------
313
314/// The whole schema as JSON, hash included.
315///
316/// One line of output; a consumer parses it, and a human reading it reaches for
317/// a formatter. Deterministic: every roster it walks is a `const` array in
318/// declaration order, so two runs on one build produce identical bytes.
319pub fn document() -> String {
320 let body = body();
321 format!(
322 "{{\"v\":{SCHEMA_VERSION},\"hash\":\"{:016x}\",{body}",
323 hash_str(&body)
324 )
325}
326
327/// The document's stable hash.
328///
329/// FNV-1a over the **body** — the document without its own `v` and `hash` — so
330/// the hash is a function of what the engine declares rather than of itself. It
331/// changes when and only when that declaration changes.
332pub fn hash() -> u64 {
333 hash_str(&body())
334}
335
336/// The hash as the sixteen hex characters `document` prints and `hello` reports.
337pub fn hash_hex() -> String {
338 format!("{:016x}", hash())
339}
340
341/// Everything the export declares, as the tail of a JSON object — from
342/// `"systems"` through the closing brace.
343fn body() -> String {
344 let rosters = param_rosters();
345 let stages = stage_count();
346 let split = rosters.len().saturating_sub(stages);
347 let mut out = String::with_capacity(64 * 1024);
348
349 out.push_str("\"systems\":[");
350 for (i, (label, specs)) in rosters.iter().take(split).enumerate() {
351 if i > 0 {
352 out.push(',');
353 }
354 push_roster(&mut out, label, specs);
355 }
356 out.push_str("],\"stages\":[");
357 for (i, (label, specs)) in rosters.iter().skip(split).enumerate() {
358 if i > 0 {
359 out.push(',');
360 }
361 push_roster(&mut out, label, specs);
362 }
363 out.push_str("],\"tables\":[");
364 for (i, table) in TABLES.iter().enumerate() {
365 if i > 0 {
366 out.push(',');
367 }
368 push_table(&mut out, table);
369 }
370 out.push_str("],\"grammar\":");
371 push_grammar(&mut out);
372 out.push('}');
373 out
374}
375
376/// The expression grammar's three identifier rosters.
377///
378/// **Generated by walking the engine's own declarations**, the same discipline
379/// the parameter half is held to (ADR-0170): `variables` is what the parser's
380/// identifier lookup accepts out of `VAR_NAMES`, `functions` is the roster
381/// `Func::from_name` resolves through, and `constants` is the table `constant`
382/// resolves through. Nothing here is a second copy, so an editor colouring these
383/// colours exactly what the engine knows.
384///
385/// The reserved `[latch]` placeholders are **absent** from `variables`, because
386/// the parser refuses them by name: an author reaches a latch through the name
387/// they declared for it, and offering `_latch0` would be offering a spelling
388/// that does not compile. A preset's own latch names are in the preset, not in
389/// the schema.
390fn push_grammar(out: &mut String) {
391 out.push_str("{\"variables\":");
392 push_names(out, expr::variable_names());
393 out.push_str(",\"functions\":");
394 push_names(out, expr::function_names());
395 out.push_str(",\"constants\":");
396 push_names(out, expr::constant_names());
397 out.push('}');
398}
399
400/// A JSON array of strings.
401fn push_names(out: &mut String, names: impl Iterator<Item = &'static str>) {
402 out.push('[');
403 for (i, name) in names.enumerate() {
404 if i > 0 {
405 out.push(',');
406 }
407 push_string(out, name);
408 }
409 out.push(']');
410}
411
412/// One labelled parameter roster.
413fn push_roster(out: &mut String, label: &str, specs: &[ParamSpec]) {
414 // Keyed by the roster's own label, which is what
415 // [`family_params`](crate::render::scenes::family_params) answers to, so a
416 // parameter of the same name on another system gets no cells of this one's.
417 let families = family_params(label);
418 out.push_str("{\"name\":");
419 push_string(out, label);
420 out.push_str(",\"params\":[");
421 for (i, spec) in specs.iter().enumerate() {
422 if i > 0 {
423 out.push(',');
424 }
425 out.push_str("{\"name\":");
426 push_string(out, spec.name);
427 out.push_str(",\"default\":");
428 push_number(out, spec.default);
429 out.push_str(",\"range\":");
430 push_range(out, spec.range);
431 out.push_str(",\"doc\":");
432 push_string(out, spec.doc);
433 // The editor's grouping (ADR-0256): which group the parameter is filed
434 // under and whether it leads that group. Additive for the reason `kind`
435 // is, below. Written ahead of `kind` so the parameter object still
436 // closes on `kind` or opens `families` after it, which is the shape a
437 // consumer scanning for `"kind":"structural"}` reads.
438 out.push_str(",\"group\":");
439 push_string(out, spec.group.as_str());
440 out.push_str(",\"main\":");
441 out.push_str(if spec.main { "true" } else { "false" });
442 // ADR-0180 rule 4's distinction, so a studio can group its panel the
443 // way the reference groups its tables. Additive: a consumer that does
444 // not know the field ignores it, which is why `SCHEMA_VERSION` does
445 // not move — the body hash does, and that is the staleness signal a
446 // studio already compares.
447 out.push_str(",\"kind\":");
448 push_string(out, spec.kind.as_str());
449 // `families` is additive under exactly that reasoning (ADR-0194 point
450 // 1): one entry per family of this system, in the system's roster
451 // order, `range: null` where the family does not read the parameter at
452 // all. It is written **only** for a parameter `family_params` has a row
453 // for, so a consumer that finds no key falls back to `range` above and
454 // renders what it rendered before this field existed. Rendered from the
455 // same walk the generated reference prints its per-family cell from, so
456 // the two cannot disagree.
457 if let Some(row) = families.iter().find(|row| row.name == spec.name) {
458 out.push_str(",\"families\":[");
459 for (j, cell) in row.ranges.iter().enumerate() {
460 if j > 0 {
461 out.push(',');
462 }
463 out.push_str("{\"family\":");
464 push_string(out, cell.family);
465 out.push_str(",\"range\":");
466 push_range(out, cell.range);
467 out.push('}');
468 }
469 out.push(']');
470 }
471 out.push('}');
472 }
473 out.push_str("]}");
474}
475
476/// A declared range as the document writes it: `[lo, hi]`, or `null` where none
477/// is declared.
478fn push_range(out: &mut String, range: Option<[f32; 2]>) {
479 match range {
480 Some([lo, hi]) => {
481 out.push('[');
482 push_number(out, lo);
483 out.push(',');
484 push_number(out, hi);
485 out.push(']');
486 }
487 None => out.push_str("null"),
488 }
489}
490
491/// One structural table.
492fn push_table(out: &mut String, table: &TableDesc) {
493 out.push_str("{\"name\":");
494 push_string(out, table.name);
495 out.push_str(",\"doc\":");
496 push_string(out, table.doc);
497 out.push_str(",\"keys\":[");
498 for (i, key) in table.keys.iter().enumerate() {
499 if i > 0 {
500 out.push(',');
501 }
502 out.push_str("{\"name\":");
503 push_string(out, key.name);
504 out.push_str(",\"default\":");
505 push_string(out, key.default);
506 out.push_str(",\"doc\":");
507 push_string(out, key.doc);
508 out.push(',');
509 push_kind(out, &key.kind);
510 out.push('}');
511 }
512 out.push_str("]}");
513}
514
515/// One key's kind, as the fields it contributes to that key's object.
516fn push_kind(out: &mut String, kind: &KeyKind) {
517 out.push_str("\"kind\":");
518 push_string(out, kind.tag());
519 match kind {
520 KeyKind::Roster(roster) => {
521 out.push_str(",\"values\":[");
522 for (i, value) in roster.values().iter().enumerate() {
523 if i > 0 {
524 out.push(',');
525 }
526 push_string(out, value);
527 }
528 out.push(']');
529 }
530 KeyKind::Map(of) | KeyKind::List(of) => {
531 out.push_str(",\"of\":{");
532 push_kind(out, of);
533 out.push('}');
534 }
535 KeyKind::Table(name) => {
536 out.push_str(",\"table\":");
537 push_string(out, name);
538 }
539 _ => {}
540 }
541}
542
543/// A JSON string, quotes included.
544///
545/// Escapes what RFC 8259 requires and nothing else: the quote, the backslash,
546/// and every control character below `0x20`. Doc lines are hand-written ASCII
547/// prose, so the `\u` arm is a guard against a future one rather than a live
548/// case.
549fn push_string(out: &mut String, text: &str) {
550 out.push('"');
551 for ch in text.chars() {
552 match ch {
553 '"' => out.push_str("\\\""),
554 '\\' => out.push_str("\\\\"),
555 '\n' => out.push_str("\\n"),
556 '\r' => out.push_str("\\r"),
557 '\t' => out.push_str("\\t"),
558 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
559 c => out.push(c),
560 }
561 }
562 out.push('"');
563}
564
565/// A JSON number.
566///
567/// `{:?}` on an `f32` prints the shortest decimal that round-trips, which keeps
568/// `0.5` as `0.5` rather than `0.5000` and keeps the hash stable across the
569/// values a spec can hold. A non-finite default cannot be written as JSON, so it
570/// is emitted as `null` — the loader would refuse such a spec long before this,
571/// and printing `NaN` would produce a document nothing can parse.
572fn push_number(out: &mut String, value: f32) {
573 if value.is_finite() {
574 out.push_str(&format!("{value:?}"));
575 } else {
576 out.push_str("null");
577 }
578}
579
580/// FNV-1a, 64-bit, over `text`'s bytes — the function [`hash`] uses, exposed so
581/// a test can hash a deliberately perturbed copy of the document and assert the
582/// hash moved with it.
583///
584/// Not cryptographic and not asked to be: the question it answers is "is the
585/// studio's copy of this document the one this player is running", where the
586/// adversary is a stale file rather than a person. Written out rather than taken
587/// from `DefaultHasher`, whose output std explicitly does not promise to be
588/// stable across releases — a hash a studio caches has to survive a toolchain
589/// bump.
590pub fn hash_str(text: &str) -> u64 {
591 const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
592 const PRIME: u64 = 0x0000_0100_0000_01b3;
593 let mut hash = OFFSET;
594 for byte in text.as_bytes() {
595 hash ^= u64::from(*byte);
596 hash = hash.wrapping_mul(PRIME);
597 }
598 hash
599}
600
601// ---------------------------------------------------------------------------
602// The editor schema (ADR-0190)
603// ---------------------------------------------------------------------------
604
605/// The JSON Schema an editor completes preset TOML from, as committed to
606/// `presets/preset.schema.json` and associated with preset files by `.taplo.toml`.
607///
608/// **A second rendering of the declarations [`document`] prints**, not a second
609/// copy of them: the structural half walks [`TABLES`], the parameter half walks
610/// [`SystemKind::param_specs`] and [`GLOBAL_PARAMS`], and neither writes a name
611/// or a roster of its own. `core/tests/suite/preset_schema.rs` holds the committed file
612/// to this function and validates the whole preset corpus against the *same*
613/// declarations — so the file, the editor and the loader cannot disagree about
614/// what a preset may contain.
615///
616/// Draft-07, because that is what Even Better TOML's Taplo backend implements —
617/// in particular the `if`/`then` the per-system parameter sets need.
618///
619/// **This file validates and does not complete.** Taplo reads hover text and
620/// completions only off unconditional properties, so a `[params]` key inside one
621/// of the `if`/`then` cases below gets neither. It is the fallback `.taplo.toml`
622/// applies to a file no family rule claims; [`system_json_schema`] is what a
623/// library file named for its family gets.
624///
625/// ## What it does and does not enforce
626///
627/// `additionalProperties: false` appears **only** where the engine's declaration
628/// is authoritative for the whole key set: the document root, each structural
629/// table, and each system's `[params]`. It is deliberately absent from the
630/// author-keyed maps — `[smoothing]`, `[hold]`, `[occupancy]`, `[latch]` — whose
631/// keys the preset chooses.
632///
633/// **No range is enforced anywhere.** [`ParamSpec::range`] is documented as
634/// neither a clamp nor a validation bound, and presets set a value outside it on
635/// purpose; a range that underlined a correct preset would be worse than no range
636/// at all. It is carried as prose in `markdownDescription`, where an author reads
637/// it and nothing acts on it.
638///
639/// Every `[params]` value is `type: string`: a binding is an expression, and
640/// `glow = 1.0` is a load error rather than a shorthand.
641pub fn json_schema() -> String {
642 let mut out = String::with_capacity(256 * 1024);
643 out.push_str("{\n");
644 out.push_str(" \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n");
645 out.push_str(" \"title\": \"Ritmolux preset\",\n");
646 out.push_str(" \"description\": ");
647 out.push_str(&json_string(
648 "Generated from the engine's own parameter and table declarations. Do not \
649 edit by hand: re-run the core test suite with RLX_UPDATE_PRESET_SCHEMA=1.",
650 ));
651 out.push_str(",\n \"type\": \"object\",\n");
652 out.push_str(" \"additionalProperties\": false,\n");
653
654 out.push_str(" \"properties\": {\n");
655 push_table_properties(&mut out, 2, &super::raw::PRESET);
656 out.push_str("\n },\n");
657
658 // Each distinct parameter declaration is written **once**, under
659 // `definitions`, and every system accepting it `$ref`s that one entry.
660 // Without the indirection the 28 cases below carry some 1,250 copies of a
661 // hover text, and a one-word edit to a parameter's doc line rewrites the file
662 // in 28 places instead of one.
663 let definitions = param_definitions();
664
665 // The per-system parameter sets. One `if`/`then` per system per surface: the
666 // root's `[params]` and a `[layer]`'s own accept different sets, because a
667 // layer binds its scene's parameters and never the compositing stages.
668 out.push_str(" \"allOf\": [\n");
669 let mut first = true;
670 for surface in [ParamSurface::Root, ParamSurface::Layer] {
671 for kind in SystemKind::ALL {
672 push_separator(&mut out, &mut first);
673 push_system_case(&mut out, 2, kind, surface, &definitions);
674 }
675 }
676 out.push_str("\n ],\n");
677
678 out.push_str(" \"definitions\": {\n");
679 let mut first = true;
680 for table in TABLES {
681 // The root is the document itself rather than a definition, and nothing
682 // `$ref`s it.
683 if table.name == super::raw::PRESET.name {
684 continue;
685 }
686 push_separator(&mut out, &mut first);
687 push_definition(&mut out, 2, table);
688 }
689 let rows = family_rows();
690 for (key, spec) in &definitions {
691 push_separator(&mut out, &mut first);
692 push_param_definition(&mut out, 2, key, spec, &rows);
693 }
694 out.push_str("\n }\n}\n");
695 out
696}
697
698/// Where the per-system schemas are committed, relative to the repository root.
699pub const SYSTEM_SCHEMA_DIR: &str = "presets/schema";
700
701/// Where the generic schema is committed, relative to the repository root.
702pub const GENERIC_SCHEMA_PATH: &str = "presets/preset.schema.json";
703
704/// Where the editor association is committed, relative to the repository root.
705pub const TAPLO_CONFIG_PATH: &str = ".taplo.toml";
706
707/// The committed path of `kind`'s own schema, relative to the repository root.
708pub fn system_schema_path(kind: SystemKind) -> String {
709 format!("{SYSTEM_SCHEMA_DIR}/{}.schema.json", kind.as_str())
710}
711
712/// Every generated editor file, as `(path relative to the repository root,
713/// content)`: the generic schema, one schema per system, and `.taplo.toml`.
714///
715/// **The one list** the drift test compares and the regenerate command writes,
716/// so no file can be rendered and not checked, or checked and not rendered.
717pub fn editor_files() -> Vec<(String, String)> {
718 let mut out = vec![(GENERIC_SCHEMA_PATH.to_owned(), json_schema())];
719 out.extend(
720 SystemKind::ALL
721 .iter()
722 .map(|kind| (system_schema_path(*kind), system_json_schema(*kind))),
723 );
724 out.push((TAPLO_CONFIG_PATH.to_owned(), taplo_config()));
725 out
726}
727
728/// The JSON Schema for a preset driving `kind`, self-contained, as committed
729/// under `presets/schema/`.
730///
731/// **No `if`/`then` on the path from the root to a parameter.** Taplo validates
732/// through a conditional but reads neither hover text nor completions through
733/// one, so [`json_schema`]'s per-system cases complete nothing. Here `system` is
734/// a `const` and `params` is this system's property set, unconditionally — the
735/// set [`params_of`] gives the generic file's matching case, `$ref`ing the same
736/// definition keys.
737///
738/// **`[layer]` is not narrowed.** A layer names its own system, and narrowing it
739/// here would take one conditional per system and every system's parameter
740/// definitions in every file. Its `params` stays what the table declares: any
741/// name, bound to a string. The generic file still validates a layer's names.
742///
743/// `definitions` is pruned to what this file references — the structural tables
744/// reachable from the root, and this system's parameter declarations. A `$ref`
745/// into another file is not something Taplo resolves, so nothing is shared.
746pub fn system_json_schema(kind: SystemKind) -> String {
747 let definitions = param_definitions();
748 let params = params_of(kind, ParamSurface::Root);
749 let name = json_string(kind.as_str());
750
751 let mut out = String::with_capacity(96 * 1024);
752 out.push_str("{\n");
753 out.push_str(" \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n");
754 out.push_str(&format!(
755 " \"title\": {},\n",
756 json_string(&format!("Ritmolux preset: {}", kind.as_str()))
757 ));
758 out.push_str(" \"description\": ");
759 out.push_str(&json_string(&format!(
760 "The schema for a preset driving `{}`, selected by a library filename beginning \
761 `{}_`. Generated from the engine's own parameter and table declarations. Do not \
762 edit by hand: re-run the core test suite with RLX_UPDATE_PRESET_SCHEMA=1.",
763 kind.as_str(),
764 kind.family()
765 )));
766 out.push_str(",\n \"type\": \"object\",\n");
767 out.push_str(" \"additionalProperties\": false,\n");
768
769 out.push_str(" \"properties\": {\n");
770 push_table_properties_with(
771 &mut out,
772 2,
773 &super::raw::PRESET,
774 |out, depth, key| match key.name {
775 "system" => {
776 let pad = " ".repeat(depth);
777 out.push_str(&format!(
778 "{pad}\"type\": \"string\",\n{pad}\"const\": {name}"
779 ));
780 true
781 }
782 "params" => {
783 push_params_body(out, depth, ¶ms, &definitions);
784 true
785 }
786 _ => false,
787 },
788 );
789 out.push_str("\n },\n");
790
791 let tables = reachable_tables(&super::raw::PRESET);
792 out.push_str(" \"definitions\": {\n");
793 let mut first = true;
794 for table in TABLES {
795 if tables.contains(&table.name) {
796 push_separator(&mut out, &mut first);
797 push_definition(&mut out, 2, table);
798 }
799 }
800 let rows = family_rows();
801 for (key, spec) in &definitions {
802 if params
803 .iter()
804 .any(|accepted| same_declaration(accepted, spec))
805 {
806 push_separator(&mut out, &mut first);
807 push_param_definition(&mut out, 2, key, spec, &rows);
808 }
809 }
810 out.push_str("\n }\n}\n");
811 out
812}
813
814/// The name of every structural table a `$ref` reaches from `root`'s keys, at
815/// any depth, `root` itself excluded unless something refers back to it.
816fn reachable_tables(root: &TableDesc) -> Vec<&'static str> {
817 fn visit(kind: &KeyKind, seen: &mut Vec<&'static str>) {
818 match kind {
819 KeyKind::Table(name) => {
820 if !seen.contains(name) {
821 seen.push(*name);
822 for key in table(name).map_or(&[][..], |t| t.keys) {
823 visit(&key.kind, seen);
824 }
825 }
826 }
827 KeyKind::Map(of) | KeyKind::List(of) => visit(of, seen),
828 _ => {}
829 }
830 }
831 let mut seen = Vec::new();
832 for key in root.keys {
833 visit(&key.kind, &mut seen);
834 }
835 seen
836}
837
838/// The three library directories, as the `.taplo.toml` globs spell them.
839const LIBRARY_DIRS: [&str; 3] = ["presets", "presets/proposed", "presets/pending"];
840
841/// The glob matching a library file of `family` in `dir`, one of
842/// [`LIBRARY_DIRS`].
843fn family_glob(dir: &str, family: &str) -> String {
844 format!("/**/{dir}/{family}_*.toml")
845}
846
847/// `.taplo.toml`: which schema Even Better TOML applies to which preset file.
848///
849/// One rule per system, matching its family's filenames in the three library
850/// directories, then one fallback rule on the generic schema that **excludes**
851/// every family glob. The exclusion is what routes a family file to its own
852/// schema: Taplo breaks a tie between two matching rules by taking the later one,
853/// which is the fallback, so rule order alone would select the wrong file.
854///
855/// Every glob begins with `/`. Taplo joins a relative glob onto the workspace
856/// root's filesystem path but matches it against the document URL with only the
857/// scheme stripped, and on Windows those two differ by a leading `/`, so a
858/// relative glob matches nothing there. The generated header says so too, for
859/// the reader who opens the file rather than this function.
860pub fn taplo_config() -> String {
861 let mut out = String::with_capacity(8 * 1024);
862 out.push_str(TAPLO_HEADER);
863 for kind in SystemKind::ALL {
864 out.push_str("\n[[rule]]\n");
865 out.push_str(&format!("name = \"ritmolux-{}\"\n", kind.as_str()));
866 push_toml_globs(
867 &mut out,
868 "include",
869 LIBRARY_DIRS
870 .iter()
871 .map(|dir| family_glob(dir, kind.family())),
872 );
873 out.push_str(&format!("schema.path = \"{}\"\n", system_schema_path(kind)));
874 }
875 out.push_str("\n[[rule]]\n");
876 out.push_str("name = \"ritmolux-preset\"\n");
877 push_toml_globs(
878 &mut out,
879 "include",
880 LIBRARY_DIRS
881 .iter()
882 .map(|dir| format!("/**/{dir}/*.toml"))
883 .chain(std::iter::once("/**/docs/examples/**/*.toml".to_owned())),
884 );
885 push_toml_globs(
886 &mut out,
887 "exclude",
888 SystemKind::ALL.iter().flat_map(|kind| {
889 LIBRARY_DIRS
890 .iter()
891 .map(|dir| family_glob(dir, kind.family()))
892 }),
893 );
894 out.push_str(&format!("schema.path = \"{GENERIC_SCHEMA_PATH}\"\n"));
895 out
896}
897
898/// `key = [ ... ]`, one quoted glob per line.
899fn push_toml_globs(out: &mut String, key: &str, globs: impl Iterator<Item = String>) {
900 out.push_str(&format!("{key} = [\n"));
901 for glob in globs {
902 out.push_str(&format!(" \"{glob}\",\n"));
903 }
904 out.push_str("]\n");
905}
906
907/// The comment block `.taplo.toml` opens with. Written out rather than
908/// rendered, because nothing in it is a declaration the engine owns.
909const TAPLO_HEADER: &str = "\
910# GENERATED - do not edit. core/tests/suite/preset_schema.rs fails if this file, the
911# generic schema or any per-system schema is stale. Regenerate all of them with
912#
913# RLX_UPDATE_PRESET_SCHEMA=1 cargo nextest run -p rlx-core the_generated_editor_files_are_current
914#
915# Associates the generated JSON Schemas with the preset files they describe, so
916# an editor with Even Better TOML installed completes parameter names, shows each
917# one's documentation on hover, and flags a key the preset's system does not
918# accept. No per-user setup: the extension finds this file at the workspace root.
919#
920# THE `[formatting]` TABLE IS DELIBERATELY ABSENT, AND MUST STAY ABSENT.
921# This project does not format TOML (ADR-0190). The presets carry deliberate,
922# local alignment no formatter models - column-padded inline tables, `=` columns
923# aligned per block, a two-space gap before a trailing comment - and `taplo fmt`
924# measured against this corpus rewrote 99 to 121 of 124 files under every
925# configuration tried and panicked on eight shipped presets under its defaults.
926# A `[formatting]` table here would switch on the one Taplo feature that was
927# rejected on measurement. The extension still ships a formatter this file cannot
928# disable; docs/developing.md says how to keep it off for TOML.
929#
930# ONE SCHEMA PER SYSTEM, SELECTED BY THE FILENAME. Taplo validates through a
931# schema's `if`/`then` but reads neither hover text nor completions through one,
932# so a single schema keyed on `system` completes nothing inside `[params]` (Plan
933# 0169 Phase 4). Each system therefore has a self-contained schema under
934# presets/schema/, and a library file reaches it through its filename's family
935# prefix: `collage_*.toml` gets shape_collage's. `ritmolux --check` warns on a
936# library file named off its family, which is exactly the file that would get no
937# completion.
938#
939# The last rule is the fallback: presets/preset.schema.json, which validates
940# every system's parameters and completes none. It covers the teaching files in
941# docs/examples/ and any library file no family rule claims, and it EXCLUDES every
942# family glob. That exclusion is what hands a family file its own schema, not
943# rule order: Taplo breaks a tie between matching rules by taking the later one,
944# which is the fallback.
945#
946# EVERY GLOB STARTS WITH `/`. Taplo joins a relative glob onto the workspace root
947# path (`c:/Users/...` on Windows, no leading slash) but matches it against the
948# document URL with only the scheme stripped (`/c:/Users/...`), so on Windows a
949# relative glob never matches and the editor reports no schema. A leading `/`
950# makes the glob count as absolute, which skips the join; `/**/` then matches the
951# URL path on Windows and macOS alike.
952#
953# This repository's own manifests (`Cargo.toml`, `deny.toml`, `config.toml`) sit
954# outside every glob, so no rule is needed to keep a preset schema off them.
955# `schema.path` is relative to this file, so it resolves the same in a clone at
956# any path.
957";
958
959/// Every **distinct** parameter declaration in the engine, each with the
960/// `definitions` key the schema refs it by.
961///
962/// Deduplicated by declaration rather than by name, which is the whole point:
963/// 29 of the 208 parameter names are declared differently by different systems —
964/// `d` is a curve's sampling step and an attractor's fourth coefficient, with
965/// different sentences and different defaults — so one entry per *name* would
966/// show the wrong default and the wrong prose on hover for whichever system lost.
967///
968/// The key is `param.<name>` for a name with a single declaration, and
969/// `param.<name>.<owner>` for each variant of one declared more than once, where
970/// `owner` is the first roster to declare that variant. Readable rather than
971/// numbered, and stable because both rosters are `const` arrays walked in
972/// declaration order. A key that would collide anyway takes a numeric suffix, so
973/// two variants can never silently land on one definition.
974fn param_definitions() -> Vec<(String, &'static ParamSpec)> {
975 let labelled = SystemKind::ALL
976 .iter()
977 .map(|kind| (kind.as_str(), kind.param_specs()))
978 .chain(
979 STAGE_LABELS
980 .iter()
981 .copied()
982 .zip(GLOBAL_PARAMS.iter().copied()),
983 );
984
985 // (owner label, spec), one per distinct declaration, in walk order.
986 let mut distinct: Vec<(&'static str, &'static ParamSpec)> = Vec::new();
987 for (label, specs) in labelled {
988 for spec in specs {
989 if !distinct
990 .iter()
991 .any(|(_, seen)| same_declaration(seen, spec))
992 {
993 distinct.push((label, spec));
994 }
995 }
996 }
997
998 let mut out: Vec<(String, &'static ParamSpec)> = Vec::with_capacity(distinct.len());
999 for (label, spec) in &distinct {
1000 let ambiguous = distinct
1001 .iter()
1002 .filter(|(_, other)| other.name == spec.name)
1003 .count()
1004 > 1;
1005 let mut key = if ambiguous {
1006 format!("param.{}.{label}", spec.name)
1007 } else {
1008 format!("param.{}", spec.name)
1009 };
1010 // Belt and braces: a key already taken would otherwise overwrite its
1011 // twin in the JSON object, which is a silently wrong hover rather than a
1012 // failure.
1013 let mut next = 2;
1014 while out.iter().any(|(taken, _)| *taken == key) {
1015 key = format!("param.{}.{label}.{next}", spec.name);
1016 next += 1;
1017 }
1018 out.push((key, spec));
1019 }
1020 out
1021}
1022
1023/// Whether two specs are the same declaration — the same name saying the same
1024/// thing about the same default.
1025///
1026/// Bit equality on the floats rather than `==`, so the comparison is total: a
1027/// `NaN` default would otherwise never equal itself and would be written once per
1028/// roster that declares it.
1029fn same_declaration(a: &ParamSpec, b: &ParamSpec) -> bool {
1030 let bits = |range: Option<[f32; 2]>| range.map(|[lo, hi]| (lo.to_bits(), hi.to_bits()));
1031 a.name == b.name
1032 && a.doc == b.doc
1033 && a.default.to_bits() == b.default.to_bits()
1034 && bits(a.range) == bits(b.range)
1035 && a.kind.as_str() == b.kind.as_str()
1036}
1037
1038/// The definition key for `spec`, found by declaration.
1039fn definition_key<'k>(
1040 definitions: &'k [(String, &'static ParamSpec)],
1041 spec: &ParamSpec,
1042) -> Option<&'k str> {
1043 definitions
1044 .iter()
1045 .find(|(_, candidate)| same_declaration(candidate, spec))
1046 .map(|(key, _)| key.as_str())
1047}
1048
1049/// One parameter declaration as a `definitions` entry: a string, what it does,
1050/// and its default, range and kind as hover prose.
1051fn push_param_definition(
1052 out: &mut String,
1053 depth: usize,
1054 key: &str,
1055 spec: &ParamSpec,
1056 rows: &[(&'static ParamSpec, &'static FamilyParam)],
1057) {
1058 let pad = " ".repeat(depth);
1059 out.push_str(&format!("{pad}{}: {{\n", json_string(key)));
1060 out.push_str(&format!("{pad} \"type\": \"string\",\n"));
1061 out.push_str(&format!(
1062 "{pad} \"description\": {},\n",
1063 json_string(spec.doc)
1064 ));
1065 out.push_str(&format!(
1066 "{pad} \"markdownDescription\": {}\n",
1067 json_string(&markdown_for(spec, family_row_for(rows, spec)))
1068 ));
1069 out.push_str(&format!("{pad}}}"));
1070}
1071
1072/// Which `[params]` surface a per-system case is about.
1073///
1074/// The two accept different sets, which is why the distinction exists in the
1075/// schema at all: [`is_known_param`] admits the compositing stages at the root,
1076/// and the layer surface admits only the layer system's own roster — the rule the
1077/// loader already enforces by warning.
1078#[derive(Clone, Copy, PartialEq, Eq)]
1079pub enum ParamSurface {
1080 /// The document's own `[params]`.
1081 Root,
1082 /// A `[layer]`'s `[layer.params]`.
1083 Layer,
1084}
1085
1086/// Every parameter one surface of `kind` accepts, in the order an author meets
1087/// them: the system's own roster first, then the engine stages'.
1088///
1089/// **The membership test this renders is the loader's**, read off the same two
1090/// rosters `is_known_param` and `compile_bindings` consult — so a key the editor
1091/// underlines is exactly a key the loader would warn about, and a key it accepts
1092/// is one the loader reads. `core/tests/suite/preset_schema.rs` validates against this
1093/// same function, which is what makes the committed JSON and the corpus check one
1094/// model rather than two.
1095///
1096/// A name declared by the system and again by a stage appears **once**, at its
1097/// first occurrence — the order `kind_of_param` resolves in.
1098pub fn params_of(kind: SystemKind, surface: ParamSurface) -> Vec<&'static ParamSpec> {
1099 let mut out: Vec<&'static ParamSpec> = Vec::new();
1100 let rosters = kind.param_specs().iter().chain(
1101 GLOBAL_PARAMS
1102 .iter()
1103 .filter(|_| surface == ParamSurface::Root)
1104 .flat_map(|stage| stage.iter()),
1105 );
1106 for spec in rosters {
1107 if !out.iter().any(|seen| seen.name == spec.name) {
1108 out.push(spec);
1109 }
1110 }
1111 out
1112}
1113
1114/// One `if`/`then` pair: when `system` is `kind`, that surface's `[params]` keys
1115/// are this system's.
1116///
1117/// The `if` **requires** `system` as well as matching it, because a subschema for
1118/// an absent key is vacuously satisfied — without the `required` every case would
1119/// fire on a document that declares no system at all, and the parameter set of
1120/// whichever fired last would be the one enforced.
1121fn push_system_case(
1122 out: &mut String,
1123 depth: usize,
1124 kind: SystemKind,
1125 surface: ParamSurface,
1126 definitions: &[(String, &'static ParamSpec)],
1127) {
1128 let pad = " ".repeat(depth);
1129 let params = params_of(kind, surface);
1130 let name = json_string(kind.as_str());
1131 out.push_str(&format!("{pad}{{\n"));
1132 match surface {
1133 ParamSurface::Root => {
1134 out.push_str(&format!(
1135 "{pad} \"if\": {{ \"required\": [\"system\"], \"properties\": {{ \"system\": {{ \"const\": {name} }} }} }},\n"
1136 ));
1137 out.push_str(&format!(
1138 "{pad} \"then\": {{ \"properties\": {{ \"params\": "
1139 ));
1140 push_params_object(out, depth + 2, ¶ms, definitions);
1141 out.push_str(&format!(" }} }}\n{pad}}}"));
1142 }
1143 ParamSurface::Layer => {
1144 out.push_str(&format!(
1145 "{pad} \"if\": {{ \"required\": [\"layer\"], \"properties\": {{ \"layer\": {{ \"required\": [\"system\"], \"properties\": {{ \"system\": {{ \"const\": {name} }} }} }} }} }},\n"
1146 ));
1147 out.push_str(&format!(
1148 "{pad} \"then\": {{ \"properties\": {{ \"layer\": {{ \"properties\": {{ \"params\": "
1149 ));
1150 push_params_object(out, depth + 2, ¶ms, definitions);
1151 out.push_str(&format!(" }} }} }} }}\n{pad}}}"));
1152 }
1153 }
1154}
1155
1156/// The `[params]` object for one system: every accepted name as a string
1157/// property, and nothing else admitted.
1158fn push_params_object(
1159 out: &mut String,
1160 depth: usize,
1161 params: &[&'static ParamSpec],
1162 definitions: &[(String, &'static ParamSpec)],
1163) {
1164 out.push_str("{\n");
1165 push_params_body(out, depth + 1, params, definitions);
1166 out.push_str(" }");
1167}
1168
1169/// [`push_params_object`] without its braces: the lines a per-system file writes
1170/// inside its `params` property, beside that property's description.
1171fn push_params_body(
1172 out: &mut String,
1173 depth: usize,
1174 params: &[&'static ParamSpec],
1175 definitions: &[(String, &'static ParamSpec)],
1176) {
1177 let pad = " ".repeat(depth);
1178 out.push_str(&format!("{pad}\"type\": \"object\",\n"));
1179 out.push_str(&format!("{pad}\"additionalProperties\": false,\n"));
1180 out.push_str(&format!("{pad}\"properties\": {{\n"));
1181 let mut first = true;
1182 for spec in params {
1183 // Every spec reached here came out of a roster `param_definitions` also
1184 // walked, so the lookup cannot miss. A miss would mean the two walks had
1185 // stopped reading the same declarations, and writing the parameter inline
1186 // instead would hide that behind a hover that merely looks right.
1187 let key = definition_key(definitions, spec)
1188 .expect("every parameter roster is walked by param_definitions");
1189 push_separator(out, &mut first);
1190 out.push_str(&format!(
1191 "{pad} {}: {{ \"$ref\": \"#/definitions/{key}\" }}",
1192 json_string(spec.name)
1193 ));
1194 }
1195 out.push_str(&format!("\n{pad}}}"));
1196}
1197
1198/// The hover text for one parameter: what it does, then its default, its range
1199/// and its kind.
1200///
1201/// The range is **prose here and a bound nowhere**, for the reason
1202/// [`json_schema`] gives: a preset may legitimately set a value outside it. That
1203/// is what lets `families` be printed here with no `if`/`then` beside it
1204/// (ADR-0194 point 5): a per-family constraint would have nothing to check,
1205/// since every value is an expression string.
1206///
1207/// `families` is the row [`family_rows`] paired with this declaration, or `None`
1208/// for a parameter that reads the same on every family its system draws — which
1209/// is every parameter of a system with no family table at all.
1210fn markdown_for(spec: &ParamSpec, families: Option<&FamilyParam>) -> String {
1211 let mut text = format!("{}\n\n", spec.doc);
1212 text.push_str(&format!("- default `{:?}`\n", spec.default));
1213 match families {
1214 Some(row) => push_family_prose(&mut text, row),
1215 None => match spec.range {
1216 Some([lo, hi]) => text.push_str(&format!(
1217 "- typical range `{lo:?}` to `{hi:?}` — a guide, not a bound: the engine \
1218 neither clamps to it nor rejects a value outside it\n"
1219 )),
1220 None => text.push_str("- no typical range declared\n"),
1221 },
1222 }
1223 text.push_str(&format!("- {} parameter\n", spec.kind.as_str()));
1224 // No system is named: one declaration is shared by every system that makes
1225 // it, so a sentence naming one of them would be wrong on the others. A
1226 // family-dependent declaration is the exception and is held to belong to one
1227 // roster by `every_family_row_declaration_belongs_to_one_roster`, which is
1228 // what makes the families above safe to name.
1229 text.push_str("\nThe value is an expression, always quoted.");
1230 text
1231}
1232
1233/// The per-family half of a family-dependent parameter's hover: the range on
1234/// each family that reads it, then the families it is inert on — the same
1235/// content the generated reference's Range cell carries, in the same order.
1236fn push_family_prose(text: &mut String, row: &FamilyParam) {
1237 let mut reads = Vec::new();
1238 let mut inert = Vec::new();
1239 for cell in row.ranges {
1240 match cell.range {
1241 Some([lo, hi]) => reads.push(format!("`{}` `{lo:?}` to `{hi:?}`", cell.family)),
1242 None => inert.push(format!("`{}`", cell.family)),
1243 }
1244 }
1245 if !reads.is_empty() {
1246 text.push_str(&format!(
1247 "- typical range depends on the family: {} — a guide, not a bound: the engine \
1248 neither clamps to it nor rejects a value outside it\n",
1249 reads.join(", ")
1250 ));
1251 }
1252 if !inert.is_empty() {
1253 text.push_str(&format!(
1254 "- inert on {} — the parameter is not read there at all\n",
1255 inert.join(", ")
1256 ));
1257 }
1258}
1259
1260/// Every family-dependent parameter declaration in the engine, paired with the
1261/// [`FamilyParam`] row that describes it — the lookup `markdown_for` needs,
1262/// which the document does not, because `push_roster` already has the roster's
1263/// label in hand and this walk does not.
1264///
1265/// **Keyed by declaration, because the editor schema's `definitions` are.** A
1266/// family-dependent declaration shared by two systems would key one hover on the
1267/// other's families; `every_family_row_declaration_belongs_to_one_roster` in
1268/// `core/tests/suite/preset.rs` is what forbids that, and it fails rather than
1269/// letting a wrong hover be printed.
1270pub fn family_rows() -> Vec<(&'static ParamSpec, &'static FamilyParam)> {
1271 let mut out = Vec::new();
1272 for (label, specs) in param_rosters() {
1273 let rows = family_params(label);
1274 for spec in specs {
1275 if let Some(row) = rows.iter().find(|row| row.name == spec.name) {
1276 out.push((spec, row));
1277 }
1278 }
1279 }
1280 out
1281}
1282
1283/// The family row describing `spec`, found by declaration.
1284fn family_row_for(
1285 rows: &[(&'static ParamSpec, &'static FamilyParam)],
1286 spec: &ParamSpec,
1287) -> Option<&'static FamilyParam> {
1288 rows.iter()
1289 .find(|(candidate, _)| same_declaration(candidate, spec))
1290 .map(|(_, row)| *row)
1291}
1292
1293/// One structural table as a `definitions` entry.
1294fn push_definition(out: &mut String, depth: usize, table: &TableDesc) {
1295 let pad = " ".repeat(depth);
1296 out.push_str(&format!("{pad}{}: {{\n", json_string(table.name)));
1297 out.push_str(&format!(
1298 "{pad} \"description\": {},\n",
1299 json_string(table.doc)
1300 ));
1301 out.push_str(&format!("{pad} \"type\": \"object\",\n"));
1302 // Authoritative: `core/src/preset/schema/tests.rs` reads the field roster
1303 // serde derived for this table and asserts it is exactly these keys, so a key
1304 // the editor refuses is a key the loader has no field for.
1305 out.push_str(&format!("{pad} \"additionalProperties\": false,\n"));
1306 out.push_str(&format!("{pad} \"properties\": {{\n"));
1307 push_table_properties(out, depth + 2, table);
1308 out.push_str(&format!("\n{pad} }}\n{pad}}}"));
1309}
1310
1311/// Every key of `table` as a JSON Schema property.
1312fn push_table_properties(out: &mut String, depth: usize, table: &TableDesc) {
1313 push_table_properties_with(out, depth, table, |_, _, _| false);
1314}
1315
1316/// [`push_table_properties`], with `body` offered each key's type half first.
1317///
1318/// `body` returns `true` when it wrote that half itself, and the key's
1319/// [`KeyKind`] is then not rendered. Everything else about the property — its
1320/// name, its description, its default — is written the same way either way, so a
1321/// per-system file's `system` and `params` read on hover exactly as the generic
1322/// file's do.
1323fn push_table_properties_with(
1324 out: &mut String,
1325 depth: usize,
1326 table: &TableDesc,
1327 mut body: impl FnMut(&mut String, usize, &KeyDesc) -> bool,
1328) {
1329 let pad = " ".repeat(depth);
1330 let mut first = true;
1331 for key in table.keys {
1332 push_separator(out, &mut first);
1333 out.push_str(&format!("{pad}{}: {{\n", json_string(key.name)));
1334 if !body(out, depth + 1, key) {
1335 push_kind_body(out, depth + 1, &key.kind);
1336 }
1337 out.push_str(&format!(
1338 ",\n{pad} \"description\": {}",
1339 json_string(key.doc)
1340 ));
1341 // An empty `default` means absence denotes something other than a value —
1342 // a required key, or one whose absence turns a feature off — so there is
1343 // no default to quote.
1344 if !key.default.is_empty() {
1345 out.push_str(&format!(
1346 ",\n{pad} \"markdownDescription\": {}",
1347 json_string(&format!("{}\n\nDefault `{}`.", key.doc, key.default))
1348 ));
1349 }
1350 out.push_str(&format!("\n{pad}}}"));
1351 }
1352}
1353
1354/// The type half of one key's schema — everything but its description.
1355///
1356/// The mapping is [`KeyKind`]'s and nothing here invents a roster: a
1357/// [`KeyKind::Roster`] asks the type that owns the closed set, so an `enum` in the
1358/// schema is the same list the loader accepts.
1359fn push_kind_body(out: &mut String, depth: usize, kind: &KeyKind) {
1360 let pad = " ".repeat(depth);
1361 match kind {
1362 KeyKind::Bool => out.push_str(&format!("{pad}\"type\": \"boolean\"")),
1363 KeyKind::Int => out.push_str(&format!("{pad}\"type\": \"integer\"")),
1364 KeyKind::Float => out.push_str(&format!("{pad}\"type\": \"number\"")),
1365 // Free text and an expression are both strings to an editor; the
1366 // difference is what the loader does with them, not what TOML may hold.
1367 KeyKind::Text | KeyKind::Expr => out.push_str(&format!("{pad}\"type\": \"string\"")),
1368 KeyKind::Roster(roster) => {
1369 out.push_str(&format!("{pad}\"type\": \"string\",\n{pad}\"enum\": ["));
1370 let mut first = true;
1371 for value in roster.values() {
1372 if !first {
1373 out.push_str(", ");
1374 }
1375 first = false;
1376 out.push_str(&json_string(value));
1377 }
1378 out.push(']');
1379 }
1380 // Seconds, or the `{ attack, release }` pair ADR-0035 widened it to.
1381 KeyKind::Easing => out.push_str(&format!(
1382 "{pad}\"anyOf\": [\n{pad} {{ \"type\": \"number\" }},\n{pad} {{ \"type\": \"object\", \"additionalProperties\": false, \"properties\": {{ \"attack\": {{ \"type\": \"number\" }}, \"release\": {{ \"type\": \"number\" }} }} }}\n{pad}]"
1383 )),
1384 // A number, or the word `random` (ADR-0051).
1385 KeyKind::Seed => out.push_str(&format!(
1386 "{pad}\"anyOf\": [\n{pad} {{ \"type\": \"number\" }},\n{pad} {{ \"type\": \"string\", \"enum\": [\"random\"] }}\n{pad}]"
1387 )),
1388 // A named edge, or a period in seconds — which the loader accepts bare or
1389 // quoted. Written as an unconstrained string beside the number rather than
1390 // as an `enum` of the two words, because an `enum` would underline the
1391 // legal `"2.0"`; the two words are carried as `examples`, which an editor
1392 // offers and does not enforce.
1393 KeyKind::Hold => out.push_str(&format!(
1394 "{pad}\"anyOf\": [\n{pad} {{ \"type\": \"number\" }},\n{pad} {{ \"type\": \"string\", \"examples\": [\"beat\", \"bar\"] }}\n{pad}]"
1395 )),
1396 // `#rrggbb` (or a bare `rrggbb`), or an `[r, g, b]` array in 0..=1. The
1397 // string carries no `pattern`: the loader's message about a malformed hex
1398 // names the offending value, and a pattern would underline a half-typed
1399 // colour on every keystroke.
1400 KeyKind::Colour => out.push_str(&format!(
1401 "{pad}\"anyOf\": [\n{pad} {{ \"type\": \"string\" }},\n{pad} {{ \"type\": \"array\", \"items\": {{ \"type\": \"number\" }} }}\n{pad}]"
1402 )),
1403 // Author-chosen keys, so no `additionalProperties: false`: the engine is
1404 // authoritative for the value's shape and the preset for the key set.
1405 KeyKind::Map(of) => {
1406 out.push_str(&format!(
1407 "{pad}\"type\": \"object\",\n{pad}\"additionalProperties\": {{\n"
1408 ));
1409 push_kind_body(out, depth + 1, of);
1410 out.push_str(&format!("\n{pad}}}"));
1411 }
1412 KeyKind::List(of) => {
1413 out.push_str(&format!("{pad}\"type\": \"array\",\n{pad}\"items\": {{\n"));
1414 push_kind_body(out, depth + 1, of);
1415 out.push_str(&format!("\n{pad}}}"));
1416 }
1417 KeyKind::Table(name) => {
1418 out.push_str(&format!("{pad}\"$ref\": \"#/definitions/{name}\""));
1419 }
1420 }
1421}
1422
1423/// Write `",\n"` before every element but the first.
1424fn push_separator(out: &mut String, first: &mut bool) {
1425 if !*first {
1426 out.push_str(",\n");
1427 }
1428 *first = false;
1429}
1430
1431/// `text` as a JSON string literal, quotes included.
1432fn json_string(text: &str) -> String {
1433 let mut out = String::with_capacity(text.len() + 2);
1434 push_string(&mut out, text);
1435 out
1436}