Skip to main content

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, &params, &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, &params, 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, &params, 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}