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=) -- one line per 1/period hertz, each of unit \
130         amplitude, so the series' own RMS is the square root of half its line count; scale \
131         it to the level the piece wants",
132    ),
133];
134
135/// Confirmed against the lexer's and parser's own tables, not asserted from memory: no token
136/// exists for any of these, so none can appear in an expression at all.
137pub const NOT_SUPPORTED: [&str; 4] = [
138    "comparison operators: < > <= >= == !=",
139    "boolean operators: && || !",
140    "conditionals: if/else, a ternary",
141    "exponentiation `^`: write pow(base, exponent)",
142];
143
144/// One written cast, and every representation it takes to which.
145pub struct Crossing {
146    pub name: &'static str,
147    pub rows: Vec<(&'static str, &'static str)>,
148}
149
150pub struct Builtins {
151    pub callables: Vec<Callable>,
152    pub casts: Vec<Crossing>,
153    pub fourier_dual_rules_version: u64,
154    pub families: &'static [(&'static str, &'static str)],
155    pub refusals: &'static [(&'static str, &'static str)],
156    pub unit_suffixes: &'static [(&'static str, &'static str)],
157    pub note_names: &'static str,
158    pub scales: &'static [(&'static str, &'static [f64], &'static str)],
159    pub reserved: &'static [(&'static str, &'static str)],
160    pub special_forms: &'static [(&'static str, &'static str)],
161    pub not_supported: &'static [&'static str],
162}
163
164fn plain_callable(name: &'static str) -> Callable {
165    let sig = signature(name)
166        .unwrap_or_else(|| unreachable!("{name} is in BUILTINS but SIGNATURES does not cover it"));
167    Callable {
168        name,
169        required: sig.required(),
170        max_positional: sig.most(),
171        named: sig.named(),
172        required_named: &[],
173        takes_gain: None,
174        arguments: meanings(name, sig.named()),
175        positional: positional(name),
176    }
177}
178
179/// `q` is refused outright on `Shape::OnePole` rather than merely unused, so its own positional
180/// ceiling sits below every other shape's.
181fn filter_callable(shape: Shape) -> Callable {
182    let gain = shape.takes_gain();
183    let max_positional = if shape == Shape::OnePole {
184        2
185    } else if gain {
186        4
187    } else {
188        3
189    };
190    let named = recognized_named(shape_name(shape)).unwrap_or(&[]);
191    Callable {
192        name: shape_name(shape),
193        required: 2,
194        max_positional: Some(max_positional),
195        named,
196        required_named: &[],
197        takes_gain: Some(gain),
198        arguments: meanings(shape_name(shape), named),
199        positional: positional(shape_name(shape)),
200    }
201}
202
203/// Every state a value's form can be in, so a crossing table shows which duals a cast needs.
204const HELD: [(Held, bool); 6] = [
205    (Held::Form(Var::T), false),
206    (Held::Form(Var::T), true),
207    (Held::Form(Var::F), false),
208    (Held::Form(Var::F), true),
209    (Held::Sampled, false),
210    (Held::Frames, false),
211];
212
213/// Read off `Cast::resolve` rather than restated: a row exists where the cast answers.
214fn crossings() -> Vec<Crossing> {
215    Cast::NAMES
216        .into_iter()
217        .map(|name| {
218            let cast = match Cast::from_name(name) {
219                Some(Cast::Stft { .. }) => Cast::Stft {
220                    window: 1024,
221                    hop: 256,
222                },
223                Some(cast) => cast,
224                None => unreachable!("{name} is one of Cast::NAMES"),
225            };
226            Crossing {
227                name,
228                rows: HELD
229                    .into_iter()
230                    .filter_map(|(held, dual)| {
231                        let ty = Ty {
232                            dual,
233                            ..Ty::discrete(held, Codomain::Real)
234                        };
235                        cast.resolve(&[ty])
236                            .ok()
237                            .map(|out| (notation(ty), notation(out)))
238                    })
239                    .collect(),
240            }
241        })
242        .collect()
243}
244
245pub fn builtins() -> Builtins {
246    let mut callables: Vec<Callable> = BUILTINS.into_iter().map(plain_callable).collect();
247    callables.extend(ALL_SHAPES.into_iter().map(filter_callable));
248    Builtins {
249        callables,
250        casts: crossings(),
251        fourier_dual_rules_version: FOURIER_DUAL_RULES_VERSION,
252        families: &FAMILIES,
253        refusals: &REGISTRY,
254        unit_suffixes: &UNIT_SUFFIXES,
255        note_names: NOTE_GRAMMAR,
256        scales: &sva_ast::BUILTIN_SCALES,
257        reserved: &sva_ast::RESERVED,
258        special_forms: &SPECIAL_FORMS,
259        not_supported: &NOT_SUPPORTED,
260    }
261}
262
263pub fn builtins_data(b: &Builtins) -> String {
264    let callables = list(&b.callables, |c| {
265        format!(
266            "\n    {{ \"name\": \"{}\", \"required\": {}, \"max_positional\": {}, \"named\": {}, \
267             \"required_named\": {}, \"takes_gain\": {}, \"arguments\": {}, \"positional\": {} }}",
268            escape(c.name),
269            c.required,
270            c.max_positional
271                .map_or_else(|| NONE.to_string(), |n| n.to_string()),
272            strings(c.named),
273            strings(c.required_named),
274            c.takes_gain
275                .map_or_else(|| NONE.to_string(), |g| g.to_string()),
276            list(&c.arguments, argument_json),
277            list(&c.positional, positional_json)
278        )
279    });
280    let casts = list(&b.casts, |c| {
281        format!(
282            "\n    {{ \"name\": \"{}\", \"crossings\": {} }}",
283            escape(c.name),
284            list(&c.rows, |(from, to)| format!(
285                "{{ \"from\": \"{from}\", \"to\": \"{to}\" }}"
286            ))
287        )
288    });
289    format!(
290        "{{\n  \"callables\": {callables},\n  \"casts\": {casts},\n  \
291         \"rule_table\": {{ \"version\": {}, \"families\": {} }},\n  \
292         \"refusals\": {},\n  \"unit_suffixes\": {},\n  \"note_names\": \"{}\",\n  \
293         \"scales\": {},\n  \"reserved\": {},\n  \"special_forms\": {},\n  \
294         \"not_supported\": {}\n}}",
295        b.fourier_dual_rules_version,
296        pair_list(b.families, "name", "duals"),
297        pair_list(b.refusals, "code", "when"),
298        pair_list(b.unit_suffixes, "suffix", "meaning"),
299        escape(b.note_names),
300        list(b.scales, scale_json),
301        pair_list(b.reserved, "name", "note"),
302        pair_list(b.special_forms, "name", "shape"),
303        strings(b.not_supported)
304    )
305}
306
307fn scale_json((name, cents, tuning): &(&str, &[f64], &str)) -> String {
308    let cents: Vec<String> = cents.iter().map(f64::to_string).collect();
309    format!(
310        "\n    {{ \"name\": \"{}\", \"cents\": [{}], \"tuning\": \"{}\" }}",
311        escape(name),
312        cents.join(", "),
313        escape(tuning)
314    )
315}
316
317fn positional_json((name, m): &(&str, Option<Meaning>)) -> String {
318    let text =
319        |t: Option<&str>| t.map_or_else(|| NONE.to_string(), |t| format!("\"{}\"", escape(t)));
320    format!(
321        "{{ \"name\": \"{}\", \"meaning\": {}, \"unit\": {}, \"part\": {} }}",
322        escape(name),
323        text(m.map(|m| m.text)),
324        text(m.map(|m| m.unit)),
325        text(m.and_then(|m| m.part))
326    )
327}
328
329fn argument_json((name, m, moves): &(&str, Meaning, bool)) -> String {
330    format!(
331        "{{ \"name\": \"{}\", \"meaning\": \"{}\", \"unit\": \"{}\", \"part\": {}, \
332         \"moves\": {moves} }}",
333        escape(name),
334        escape(m.text),
335        escape(m.unit),
336        m.part
337            .map_or_else(|| NONE.to_string(), |p| format!("\"{}\"", escape(p)))
338    )
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn the_note_grammar_this_dump_states_is_what_note_actually_resolves() {
347        for note in ["A4", "Cs4", "Db4"] {
348            assert!(
349                sva_ast::note_midi(note).is_some(),
350                "{note} should be a note"
351            );
352        }
353        for not_a_note in ["H4", "Cs", "C99999999999"] {
354            assert!(
355                sva_ast::note_midi(not_a_note).is_none(),
356                "{not_a_note} is not the grammar this dump states"
357            );
358        }
359    }
360
361    #[test]
362    fn every_operator_this_dump_says_does_not_exist_actually_refuses_to_parse() {
363        for bad in [
364            "1 < 2",
365            "1 > 2",
366            "1 <= 2",
367            "1 >= 2",
368            "1 == 2",
369            "1 != 2",
370            "1 && 1",
371            "1 || 1",
372            "!1",
373            "1 ? 2 : 3",
374            "2 ^ 3",
375        ] {
376            assert!(
377                sva_ast::parse_expr(bad).is_err(),
378                "`{bad}` parsed, so this dump's not_supported claim is stale"
379            );
380        }
381    }
382
383    #[test]
384    fn every_builtin_name_gets_exactly_one_callable_entry() {
385        let b = builtins();
386        assert_eq!(b.callables.len(), BUILTINS.len() + ALL_SHAPES.len());
387        for name in BUILTINS {
388            assert_eq!(
389                b.callables.iter().filter(|c| c.name == name).count(),
390                1,
391                "{name} should appear exactly once"
392            );
393        }
394        for shape in ALL_SHAPES {
395            let found = b
396                .callables
397                .iter()
398                .find(|c| c.name == shape_name(shape))
399                .unwrap_or_else(|| panic!("{} is missing", shape_name(shape)));
400            assert_eq!(found.takes_gain, Some(shape.takes_gain()));
401        }
402    }
403
404    /// `builtins()` refuses to assemble a named argument with no meaning, so this reaching
405    /// every callable is the whole vocabulary explained.
406    #[test]
407    fn every_named_argument_says_what_it_means_and_in_what_unit() {
408        for c in builtins().callables {
409            let named: Vec<&str> = c.arguments.iter().map(|(k, ..)| *k).collect();
410            assert_eq!(named, c.named, "{}", c.name);
411            for (key, m, _) in &c.arguments {
412                assert!(!m.text.is_empty() && !m.unit.is_empty(), "{}.{key}", c.name);
413            }
414        }
415        let b = builtins();
416        let solver = b
417            .callables
418            .iter()
419            .find(|c| c.name == "chaigne_askenfelt")
420            .expect("the solver");
421        let (_, stiffness, moves) = solver.arguments[0];
422        assert!(!moves, "a solver's stiffness is one number");
423        let bore = b.callables.iter().find(|c| c.name == "darabundit_scavone");
424        let (name, held) = bore.expect("the bore").positional[0];
425        let held = held.expect("the bore states its positional");
426        assert_eq!(
427            (name, held.unit),
428            ("length", "m"),
429            "the bore reads a length"
430        );
431        let crop = b.callables.iter().find(|c| c.name == "crop").expect("crop");
432        let bounds: Vec<(&str, Option<&str>)> = crop.positional[1..]
433            .iter()
434            .map(|(name, m)| (*name, m.map(|m| m.unit)))
435            .collect();
436        assert_eq!(
437            bounds,
438            [("start", Some("s")), ("end", Some("s"))],
439            "a window's bounds"
440        );
441        let lowpass = b
442            .callables
443            .iter()
444            .find(|c| c.name == "lowpass")
445            .expect("a filter");
446        assert!(
447            lowpass.arguments.iter().all(|(_, _, moves)| *moves),
448            "a cutoff may sweep"
449        );
450        assert_eq!(
451            (stiffness.text, stiffness.unit, stiffness.part),
452            ("string stiffness (inharmonicity)", "none", Some("string"))
453        );
454    }
455
456    #[test]
457    fn every_built_in_scale_is_listed_with_its_cents() {
458        let listed = builtins_data(&builtins());
459        assert!(
460            listed.contains(
461                "{ \"name\": \"dorian\", \"cents\": [200, 300, 500, 700, 900, 1000, 1200]"
462            ),
463            "{listed}"
464        );
465        for (name, ..) in sva_ast::BUILTIN_SCALES {
466            assert!(listed.contains(&format!("\"name\": \"{name}\"")), "{name}");
467        }
468    }
469
470    #[test]
471    fn one_pole_alone_has_no_room_for_q_or_gain() {
472        let b = builtins();
473        let lp = b.callables.iter().find(|c| c.name == "lp").unwrap();
474        assert_eq!(lp.max_positional, Some(2));
475        let lowpass = b.callables.iter().find(|c| c.name == "lowpass").unwrap();
476        assert_eq!(lowpass.max_positional, Some(3));
477        let peaking = b.callables.iter().find(|c| c.name == "peaking").unwrap();
478        assert_eq!(peaking.max_positional, Some(4));
479    }
480
481    #[test]
482    fn each_reserved_name_still_behaves_the_way_this_dump_says_it_does() {
483        for (name, _) in sva_ast::RESERVED {
484            assert!(
485                sva_ast::is_reserved(name),
486                "`{name}` is listed as reserved but a parameter may still take it"
487            );
488            if name == "self" {
489                continue;
490            }
491            assert!(
492                sva_ast::parse_expr(name).is_ok(),
493                "`{name}` should parse as the special name this dump claims"
494            );
495        }
496        assert!(
497            !sva_ast::is_reserved("cutoff"),
498            "an ordinary parameter name must not be reserved, or the claim says nothing"
499        );
500        assert!(
501            sva_ast::parse_expr("self").is_err(),
502            "`self` alone must still require a call form"
503        );
504        assert!(
505            sva_ast::parse_expr("self(t - 1sp)").is_ok(),
506            "`self(...)` must still parse as a bounded self-reference"
507        );
508    }
509}