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