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