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