Skip to main content

sva_core/
builtins.rs

1// Concern: assembles the language's callable/syntactic vocabulary off the engine's own tables, as the `data` object | Non-concern: what a builtin renders | IO: none -> Builtins, JSON
2
3use sva_ast::BUILTINS;
4use sva_engine::overload::{notation, signature};
5use sva_engine::{
6    Cast, Codomain, Held, Meaning, REGISTRY, Ty, Var, meaning, named_may_move, recognized_named,
7    shape_name,
8};
9use sva_formula::filter::{ALL_SHAPES, Shape};
10use sva_formula::{FAMILIES, FOURIER_DUAL_RULES_VERSION};
11
12use crate::json::{NONE, escape, list, pair_list, strings};
13
14pub struct Callable {
15    pub name: &'static str,
16    pub required: usize,
17    /// `None` where no count is too many.
18    pub max_positional: Option<usize>,
19    pub named: &'static [&'static str],
20    /// A subset of `named` with no default — a caller who omits one gets a refusal, not a
21    /// fallback. Every builtin leaves this empty today.
22    pub required_named: &'static [&'static str],
23    pub takes_gain: Option<bool>,
24    /// Each of `named`, in its order, beside what it means and whether it may move with `t`.
25    pub arguments: Vec<(&'static str, Meaning, bool)>,
26    /// The signature's positional names, each beside its meaning where the model states one.
27    pub positional: Vec<(&'static str, Option<Meaning>)>,
28}
29
30fn positional(builtin: &str) -> Vec<(&'static str, Option<Meaning>)> {
31    signature(builtin)
32        .map_or(&[][..], |s| s.params)
33        .iter()
34        .map(|p| (p.name, meaning(builtin, p.name)))
35        .collect()
36}
37
38fn meanings(builtin: &str, named: &'static [&'static str]) -> Vec<(&'static str, Meaning, bool)> {
39    named
40        .iter()
41        .map(|key| {
42            let held = meaning(builtin, key)
43                .unwrap_or_else(|| unreachable!("`{builtin}` takes `{key}` with no meaning"));
44            (*key, held, named_may_move(builtin, key))
45        })
46        .collect()
47}
48
49/// `(spelling, meaning)`, in the lexer's own longest-match-first order.
50pub const UNIT_SUFFIXES: [(&str, &str); 11] = [
51    ("khz", "kilohertz (x1000)"),
52    ("ms", "milliseconds (seconds x0.001)"),
53    ("sp", "samples"),
54    ("hz", "hertz"),
55    ("db", "decibels (log)"),
56    ("ct", "cents (log)"),
57    ("st", "semitones (log)"),
58    ("b", "bars, resolved against bpm/meter"),
59    ("s", "seconds"),
60    ("m", "minutes (seconds x60)"),
61    ("h", "hours (seconds x3600)"),
62];
63
64pub const NOTE_GRAMMAR: &str = "a bare identifier: a letter A-G, an optional accidental spelled \
65    s (sharp) or b (flat) -- never #, then a whole-number octave, e.g. A4, Cs4, Db4; A4 = 440 Hz, \
66    twelve-tone equal temperament, up to G9 where MIDI's 128 notes end";
67
68/// `(name, its call shape)`.
69pub const SPECIAL_FORMS: [(&str, &str); 10] = [
70    (
71        "deg",
72        "deg(n) -- the tonic in variables/key times the ratio of scale degree n, a whole \
73         number folded from literals, counted from 1 through the scale variables/scale \
74         names, a built-in as `minor` or a scale file by ref; deg(7)*1st is a raised seventh, deg(5)/2 the fifth an \
75         octave down, and each follows every edit of the key or its scale",
76    ),
77    (
78        "sum",
79        "sum(index, lo, hi, expr) -- index is a name the series binds, not a value; a finite \
80         sum is its terms, whatever each reads, and one whose index a node's binding reads is \
81         written out one node per index between whole-number bounds; hi may be inf, which \
82         makes it a series of closed forms",
83    ),
84    (
85        "crop",
86        "crop(x, start, end) -- windows x to [start, end) seconds, zero outside",
87    ),
88    (
89        "join",
90        "join(a, b, ...) -- builds one wide value from 2..=8 mono args",
91    ),
92    (
93        "pwl",
94        "pwl(x, t0, v0, t1, v1, ...) -- the straight lines through the knots (tk, vk), read \
95         at the time x: zero before t0 and from the last knot on, two knots at one time a jump \
96         read from its right. Each time is a constant with a unit, never falling, and each \
97         value a constant, so it is linear in its values; any number of knots, found by \
98         bisection or by stepping forward sample to sample, so its length costs nothing per \
99         sample, and a knot table counts against no body's size bound. Exact at every \
100         instant; its support is [t0, tn) and its bound from an instant on the largest |vk| \
101         from there. It holds no atoms: `lines`, `atoms`, `spectrum`, `envelope`, `pitch` \
102         and `derivative` of a form holding one refuse as cast.left_algebra, and read off \
103         sample(...) they are measured",
104    ),
105    (
106        "pwl_integral",
107        "pwl_integral(x, t0, v0, ...) -- the integral of the same pwl from t0 to x, exact: \
108         zero before t0, a parabola on each segment, the whole area from the last knot on. \
109         Its knots in hertz make a phase in cycles: cos(2*pi*pwl_integral(t, 0s, 440hz, 1s, \
110         880hz)) glides an octave in one second; its bound from an instant on is the largest \
111         it reaches from there, and it holds no atoms either",
112    ),
113    (
114        "ch",
115        "ch(x, index) -- extracts one component of a wide value by a literal index",
116    ),
117    (
118        "x[i]",
119        "@x[i], self[i] -- the stored sample at integer index i, at the rate in use; @x(e) \
120         reads the instant e",
121    ),
122    (
123        "idx",
124        "idx(time), idx(time, floor), idx(time, ceil) -- the sample index nearest a time, \
125         ties to even, or the one below or above it; written only inside an index",
126    ),
127    (
128        "noise",
129        "noise(seed, period=, color=) -- a line every 1/period hertz from 1/period up, each \
130         at a phase its seed draws; its level is a density in hertz that the period never \
131         moves: the line at f Hz has amplitude f^log2(color)/sqrt(period), so the lines hold \
132         power f^(2*log2(color))/2 a hertz, a flat noise B/2 over any B Hz, an RMS of \
133         sqrt(B/2), 100 under a 20 kHz ceiling; scale it to the level the piece wants",
134    ),
135];
136
137/// Confirmed against the lexer's and parser's own tables, not asserted from memory: no token
138/// exists for any of these, so none can appear in an expression at all.
139pub const NOT_SUPPORTED: [&str; 4] = [
140    "comparison operators: < > <= >= == !=",
141    "boolean operators: && || !",
142    "conditionals: if/else, a ternary",
143    "exponentiation `^`: write pow(base, exponent)",
144];
145
146/// One written cast, and every representation it takes to which.
147pub struct Crossing {
148    pub name: &'static str,
149    pub rows: Vec<(&'static str, &'static str)>,
150}
151
152pub struct Builtins {
153    pub callables: Vec<Callable>,
154    pub casts: Vec<Crossing>,
155    pub fourier_dual_rules_version: u64,
156    pub families: &'static [(&'static str, &'static str)],
157    pub refusals: &'static [(&'static str, &'static str)],
158    pub unit_suffixes: &'static [(&'static str, &'static str)],
159    pub note_names: &'static str,
160    pub scales: &'static [(&'static str, &'static [f64], &'static str)],
161    pub reserved: &'static [(&'static str, &'static str)],
162    pub special_forms: &'static [(&'static str, &'static str)],
163    pub not_supported: &'static [&'static str],
164}
165
166fn plain_callable(name: &'static str) -> Callable {
167    let sig = signature(name)
168        .unwrap_or_else(|| unreachable!("{name} is in BUILTINS but SIGNATURES does not cover it"));
169    Callable {
170        name,
171        required: sig.required(),
172        max_positional: sig.most(),
173        named: sig.named(),
174        required_named: &[],
175        takes_gain: None,
176        arguments: meanings(name, sig.named()),
177        positional: positional(name),
178    }
179}
180
181/// `q` is refused outright on `Shape::OnePole` rather than merely unused, so its own positional
182/// ceiling sits below every other shape's.
183fn filter_callable(shape: Shape) -> Callable {
184    let gain = shape.takes_gain();
185    let max_positional = if shape == Shape::OnePole {
186        2
187    } else if gain {
188        4
189    } else {
190        3
191    };
192    let named = recognized_named(shape_name(shape)).unwrap_or(&[]);
193    Callable {
194        name: shape_name(shape),
195        required: 2,
196        max_positional: Some(max_positional),
197        named,
198        required_named: &[],
199        takes_gain: Some(gain),
200        arguments: meanings(shape_name(shape), named),
201        positional: positional(shape_name(shape)),
202    }
203}
204
205/// Every state a value's form can be in, so a crossing table shows which duals a cast needs.
206const HELD: [(Held, bool); 6] = [
207    (Held::Form(Var::T), false),
208    (Held::Form(Var::T), true),
209    (Held::Form(Var::F), false),
210    (Held::Form(Var::F), true),
211    (Held::Sampled, false),
212    (Held::Frames, false),
213];
214
215/// Read off `Cast::resolve` rather than restated: a row exists where the cast answers.
216fn crossings() -> Vec<Crossing> {
217    Cast::NAMES
218        .into_iter()
219        .map(|name| {
220            let cast = match Cast::from_name(name) {
221                Some(Cast::Stft { .. }) => Cast::Stft {
222                    window: 1024,
223                    hop: 256,
224                },
225                Some(cast) => cast,
226                None => unreachable!("{name} is one of Cast::NAMES"),
227            };
228            Crossing {
229                name,
230                rows: HELD
231                    .into_iter()
232                    .filter_map(|(held, dual)| {
233                        let ty = Ty {
234                            dual,
235                            ..Ty::discrete(held, Codomain::Real)
236                        };
237                        cast.resolve(&[ty])
238                            .ok()
239                            .map(|out| (notation(ty), notation(out)))
240                    })
241                    .collect(),
242            }
243        })
244        .collect()
245}
246
247pub fn builtins() -> Builtins {
248    let mut callables: Vec<Callable> = BUILTINS.into_iter().map(plain_callable).collect();
249    callables.extend(ALL_SHAPES.into_iter().map(filter_callable));
250    Builtins {
251        callables,
252        casts: crossings(),
253        fourier_dual_rules_version: FOURIER_DUAL_RULES_VERSION,
254        families: &FAMILIES,
255        refusals: &REGISTRY,
256        unit_suffixes: &UNIT_SUFFIXES,
257        note_names: NOTE_GRAMMAR,
258        scales: &sva_ast::BUILTIN_SCALES,
259        reserved: &sva_ast::RESERVED,
260        special_forms: &SPECIAL_FORMS,
261        not_supported: &NOT_SUPPORTED,
262    }
263}
264
265pub fn builtins_data(b: &Builtins) -> String {
266    let callables = list(&b.callables, |c| {
267        format!(
268            "\n    {{ \"name\": \"{}\", \"required\": {}, \"max_positional\": {}, \"named\": {}, \
269             \"required_named\": {}, \"takes_gain\": {}, \"arguments\": {}, \"positional\": {} }}",
270            escape(c.name),
271            c.required,
272            c.max_positional
273                .map_or_else(|| NONE.to_string(), |n| n.to_string()),
274            strings(c.named),
275            strings(c.required_named),
276            c.takes_gain
277                .map_or_else(|| NONE.to_string(), |g| g.to_string()),
278            list(&c.arguments, argument_json),
279            list(&c.positional, positional_json)
280        )
281    });
282    let casts = list(&b.casts, |c| {
283        format!(
284            "\n    {{ \"name\": \"{}\", \"crossings\": {} }}",
285            escape(c.name),
286            list(&c.rows, |(from, to)| format!(
287                "{{ \"from\": \"{from}\", \"to\": \"{to}\" }}"
288            ))
289        )
290    });
291    format!(
292        "{{\n  \"callables\": {callables},\n  \"casts\": {casts},\n  \
293         \"rule_table\": {{ \"version\": {}, \"families\": {} }},\n  \
294         \"refusals\": {},\n  \"unit_suffixes\": {},\n  \"note_names\": \"{}\",\n  \
295         \"scales\": {},\n  \"reserved\": {},\n  \"special_forms\": {},\n  \
296         \"not_supported\": {}\n}}",
297        b.fourier_dual_rules_version,
298        pair_list(b.families, "name", "duals"),
299        pair_list(b.refusals, "code", "when"),
300        pair_list(b.unit_suffixes, "suffix", "meaning"),
301        escape(b.note_names),
302        list(b.scales, scale_json),
303        pair_list(b.reserved, "name", "note"),
304        pair_list(b.special_forms, "name", "shape"),
305        strings(b.not_supported)
306    )
307}
308
309fn scale_json((name, cents, tuning): &(&str, &[f64], &str)) -> String {
310    let cents: Vec<String> = cents.iter().map(f64::to_string).collect();
311    format!(
312        "\n    {{ \"name\": \"{}\", \"cents\": [{}], \"tuning\": \"{}\" }}",
313        escape(name),
314        cents.join(", "),
315        escape(tuning)
316    )
317}
318
319fn positional_json((name, m): &(&str, Option<Meaning>)) -> String {
320    let text =
321        |t: Option<&str>| t.map_or_else(|| NONE.to_string(), |t| format!("\"{}\"", escape(t)));
322    format!(
323        "{{ \"name\": \"{}\", \"meaning\": {}, \"unit\": {}, \"part\": {} }}",
324        escape(name),
325        text(m.map(|m| m.text)),
326        text(m.map(|m| m.unit)),
327        text(m.and_then(|m| m.part))
328    )
329}
330
331fn argument_json((name, m, moves): &(&str, Meaning, bool)) -> String {
332    format!(
333        "{{ \"name\": \"{}\", \"meaning\": \"{}\", \"unit\": \"{}\", \"part\": {}, \
334         \"moves\": {moves} }}",
335        escape(name),
336        escape(m.text),
337        escape(m.unit),
338        m.part
339            .map_or_else(|| NONE.to_string(), |p| format!("\"{}\"", escape(p)))
340    )
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn the_note_grammar_this_dump_states_is_what_note_actually_resolves() {
349        for note in ["A4", "Cs4", "Db4"] {
350            assert!(
351                sva_ast::note_midi(note).is_some(),
352                "{note} should be a note"
353            );
354        }
355        for not_a_note in ["H4", "Cs", "C99999999999"] {
356            assert!(
357                sva_ast::note_midi(not_a_note).is_none(),
358                "{not_a_note} is not the grammar this dump states"
359            );
360        }
361    }
362
363    #[test]
364    fn every_operator_this_dump_says_does_not_exist_actually_refuses_to_parse() {
365        for bad in [
366            "1 < 2",
367            "1 > 2",
368            "1 <= 2",
369            "1 >= 2",
370            "1 == 2",
371            "1 != 2",
372            "1 && 1",
373            "1 || 1",
374            "!1",
375            "1 ? 2 : 3",
376            "2 ^ 3",
377        ] {
378            assert!(
379                sva_ast::parse_expr(bad).is_err(),
380                "`{bad}` parsed, so this dump's not_supported claim is stale"
381            );
382        }
383    }
384
385    #[test]
386    fn every_builtin_name_gets_exactly_one_callable_entry() {
387        let b = builtins();
388        assert_eq!(b.callables.len(), BUILTINS.len() + ALL_SHAPES.len());
389        for name in BUILTINS {
390            assert_eq!(
391                b.callables.iter().filter(|c| c.name == name).count(),
392                1,
393                "{name} should appear exactly once"
394            );
395        }
396        for shape in ALL_SHAPES {
397            let found = b
398                .callables
399                .iter()
400                .find(|c| c.name == shape_name(shape))
401                .unwrap_or_else(|| panic!("{} is missing", shape_name(shape)));
402            assert_eq!(found.takes_gain, Some(shape.takes_gain()));
403        }
404    }
405
406    /// `builtins()` refuses to assemble a named argument with no meaning, so this reaching
407    /// every callable is the whole vocabulary explained.
408    #[test]
409    fn every_named_argument_says_what_it_means_and_in_what_unit() {
410        for c in builtins().callables {
411            let named: Vec<&str> = c.arguments.iter().map(|(k, ..)| *k).collect();
412            assert_eq!(named, c.named, "{}", c.name);
413            for (key, m, _) in &c.arguments {
414                assert!(!m.text.is_empty() && !m.unit.is_empty(), "{}.{key}", c.name);
415            }
416        }
417        let b = builtins();
418        let solver = b
419            .callables
420            .iter()
421            .find(|c| c.name == "chaigne_askenfelt")
422            .expect("the solver");
423        let (_, stiffness, moves) = solver.arguments[0];
424        assert!(!moves, "a solver's stiffness is one number");
425        let bore = b.callables.iter().find(|c| c.name == "darabundit_scavone");
426        let (name, held) = bore.expect("the bore").positional[0];
427        let held = held.expect("the bore states its positional");
428        assert_eq!(
429            (name, held.unit),
430            ("length", "m"),
431            "the bore reads a length"
432        );
433        let crop = b.callables.iter().find(|c| c.name == "crop").expect("crop");
434        let bounds: Vec<(&str, Option<&str>)> = crop.positional[1..]
435            .iter()
436            .map(|(name, m)| (*name, m.map(|m| m.unit)))
437            .collect();
438        assert_eq!(
439            bounds,
440            [("start", Some("s")), ("end", Some("s"))],
441            "a window's bounds"
442        );
443        let lowpass = b
444            .callables
445            .iter()
446            .find(|c| c.name == "lowpass")
447            .expect("a filter");
448        assert!(
449            lowpass.arguments.iter().all(|(_, _, moves)| *moves),
450            "a cutoff may sweep"
451        );
452        assert_eq!(
453            (stiffness.text, stiffness.unit, stiffness.part),
454            ("string stiffness (inharmonicity)", "none", Some("string"))
455        );
456    }
457
458    #[test]
459    fn every_built_in_scale_is_listed_with_its_cents() {
460        let listed = builtins_data(&builtins());
461        assert!(
462            listed.contains(
463                "{ \"name\": \"dorian\", \"cents\": [200, 300, 500, 700, 900, 1000, 1200]"
464            ),
465            "{listed}"
466        );
467        for (name, ..) in sva_ast::BUILTIN_SCALES {
468            assert!(listed.contains(&format!("\"name\": \"{name}\"")), "{name}");
469        }
470    }
471
472    #[test]
473    fn one_pole_alone_has_no_room_for_q_or_gain() {
474        let b = builtins();
475        let lp = b.callables.iter().find(|c| c.name == "lp").unwrap();
476        assert_eq!(lp.max_positional, Some(2));
477        let lowpass = b.callables.iter().find(|c| c.name == "lowpass").unwrap();
478        assert_eq!(lowpass.max_positional, Some(3));
479        let peaking = b.callables.iter().find(|c| c.name == "peaking").unwrap();
480        assert_eq!(peaking.max_positional, Some(4));
481    }
482
483    #[test]
484    fn each_reserved_name_still_behaves_the_way_this_dump_says_it_does() {
485        for (name, _) in sva_ast::RESERVED {
486            assert!(
487                sva_ast::is_reserved(name),
488                "`{name}` is listed as reserved but a parameter may still take it"
489            );
490            if name == "self" {
491                continue;
492            }
493            assert!(
494                sva_ast::parse_expr(name).is_ok(),
495                "`{name}` should parse as the special name this dump claims"
496            );
497        }
498        assert!(
499            !sva_ast::is_reserved("cutoff"),
500            "an ordinary parameter name must not be reserved, or the claim says nothing"
501        );
502        assert!(
503            sva_ast::parse_expr("self").is_err(),
504            "`self` alone must still require a call form"
505        );
506        assert!(
507            sva_ast::parse_expr("self(t - 1sp)").is_ok(),
508            "`self(...)` must still parse as a bounded self-reference"
509        );
510    }
511}