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, TABLE_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); 7] = [
69    (
70        "sum",
71        "sum(index, lo, hi, expr) -- index is a name the series binds, not a value; hi may be \
72         inf, which makes it a series",
73    ),
74    (
75        "crop",
76        "crop(x, start, end) -- windows x to [start, end) seconds, zero outside",
77    ),
78    (
79        "join",
80        "join(a, b, ...) -- builds one wide value from 2..=8 mono args",
81    ),
82    (
83        "ch",
84        "ch(x, index) -- extracts one component of a wide value by a literal index",
85    ),
86    (
87        "x[i]",
88        "@x[i], self[i] -- the stored sample at integer index i, at the rate in use; @x(e) \
89         reads the instant e",
90    ),
91    (
92        "idx",
93        "idx(time), idx(time, floor), idx(time, ceil) -- the sample index nearest a time, \
94         ties to even, or the one below or above it; written only inside an index",
95    ),
96    (
97        "noise",
98        "noise(seed, period=, color=) -- one line per 1/period hertz, each of unit \
99         amplitude, so the series' own RMS is the square root of half its line count; scale \
100         it to the level the piece wants",
101    ),
102];
103
104/// Confirmed against the lexer's and parser's own tables, not asserted from memory: no token
105/// exists for any of these, so none can appear in an expression at all.
106pub const NOT_SUPPORTED: [&str; 4] = [
107    "comparison operators: < > <= >= == !=",
108    "boolean operators: && || !",
109    "conditionals: if/else, a ternary",
110    "exponentiation `^`: write pow(base, exponent)",
111];
112
113/// One written cast, and every representation it takes to which.
114pub struct Crossing {
115    pub name: &'static str,
116    pub rows: Vec<(&'static str, &'static str)>,
117}
118
119pub struct Builtins {
120    pub callables: Vec<Callable>,
121    pub casts: Vec<Crossing>,
122    pub table_version: u64,
123    pub families: &'static [(&'static str, &'static str)],
124    pub refusals: &'static [(&'static str, &'static str)],
125    pub unit_suffixes: &'static [(&'static str, &'static str)],
126    pub note_names: &'static str,
127    pub reserved: &'static [(&'static str, &'static str)],
128    pub special_forms: &'static [(&'static str, &'static str)],
129    pub not_supported: &'static [&'static str],
130}
131
132fn plain_callable(name: &'static str) -> Callable {
133    let sig = signature(name)
134        .unwrap_or_else(|| unreachable!("{name} is in BUILTINS but SIGNATURES does not cover it"));
135    Callable {
136        name,
137        required: sig.required(),
138        max_positional: if sig.variadic {
139            MAX_WIDTH
140        } else {
141            sig.params.len()
142        },
143        named: sig.named(),
144        required_named: &[],
145        takes_gain: None,
146        arguments: meanings(name, sig.named()),
147        positional: positional(name),
148    }
149}
150
151/// `q` is refused outright on `Shape::OnePole` rather than merely unused, so its own positional
152/// ceiling sits below every other shape's.
153fn filter_callable(shape: Shape) -> Callable {
154    let gain = shape.takes_gain();
155    let max_positional = if shape == Shape::OnePole {
156        2
157    } else if gain {
158        4
159    } else {
160        3
161    };
162    let named = recognized_named(shape_name(shape)).unwrap_or(&[]);
163    Callable {
164        name: shape_name(shape),
165        required: 2,
166        max_positional,
167        named,
168        required_named: &[],
169        takes_gain: Some(gain),
170        arguments: meanings(shape_name(shape), named),
171        positional: positional(shape_name(shape)),
172    }
173}
174
175/// Every state a value's form can be in, so a crossing table shows which duals a cast needs.
176const HELD: [(Held, bool); 6] = [
177    (Held::Form(Var::T), false),
178    (Held::Form(Var::T), true),
179    (Held::Form(Var::F), false),
180    (Held::Form(Var::F), true),
181    (Held::Sampled, false),
182    (Held::Frames, false),
183];
184
185/// Read off `Cast::resolve` rather than restated: a row exists where the cast answers.
186fn crossings() -> Vec<Crossing> {
187    Cast::NAMES
188        .into_iter()
189        .map(|name| {
190            let cast = match Cast::from_name(name) {
191                Some(Cast::Stft { .. }) => Cast::Stft {
192                    window: 1024,
193                    hop: 256,
194                },
195                Some(cast) => cast,
196                None => unreachable!("{name} is one of Cast::NAMES"),
197            };
198            Crossing {
199                name,
200                rows: HELD
201                    .into_iter()
202                    .filter_map(|(held, dual)| {
203                        let ty = Ty {
204                            dual,
205                            ..Ty::discrete(held, Codomain::Real)
206                        };
207                        cast.resolve(&[ty])
208                            .ok()
209                            .map(|out| (notation(ty), notation(out)))
210                    })
211                    .collect(),
212            }
213        })
214        .collect()
215}
216
217pub fn builtins() -> Builtins {
218    let mut callables: Vec<Callable> = BUILTINS.into_iter().map(plain_callable).collect();
219    callables.extend(ALL_SHAPES.into_iter().map(filter_callable));
220    Builtins {
221        callables,
222        casts: crossings(),
223        table_version: TABLE_VERSION,
224        families: &FAMILIES,
225        refusals: &REGISTRY,
226        unit_suffixes: &UNIT_SUFFIXES,
227        note_names: NOTE_GRAMMAR,
228        reserved: &sva_ast::RESERVED,
229        special_forms: &SPECIAL_FORMS,
230        not_supported: &NOT_SUPPORTED,
231    }
232}
233
234pub fn builtins_data(b: &Builtins) -> String {
235    let callables = list(&b.callables, |c| {
236        format!(
237            "\n    {{ \"name\": \"{}\", \"required\": {}, \"max_positional\": {}, \"named\": {}, \
238             \"required_named\": {}, \"takes_gain\": {}, \"arguments\": {}, \"positional\": {} }}",
239            escape(c.name),
240            c.required,
241            c.max_positional,
242            strings(c.named),
243            strings(c.required_named),
244            c.takes_gain
245                .map_or_else(|| NONE.to_string(), |g| g.to_string()),
246            list(&c.arguments, argument_json),
247            list(&c.positional, positional_json)
248        )
249    });
250    let casts = list(&b.casts, |c| {
251        format!(
252            "\n    {{ \"name\": \"{}\", \"crossings\": {} }}",
253            escape(c.name),
254            list(&c.rows, |(from, to)| format!(
255                "{{ \"from\": \"{from}\", \"to\": \"{to}\" }}"
256            ))
257        )
258    });
259    format!(
260        "{{\n  \"callables\": {callables},\n  \"casts\": {casts},\n  \
261         \"rule_table\": {{ \"version\": {}, \"families\": {} }},\n  \
262         \"refusals\": {},\n  \"unit_suffixes\": {},\n  \"note_names\": \"{}\",\n  \
263         \"reserved\": {},\n  \"special_forms\": {},\n  \
264         \"not_supported\": {}\n}}",
265        b.table_version,
266        pair_list(b.families, "name", "duals"),
267        pair_list(b.refusals, "code", "when"),
268        pair_list(b.unit_suffixes, "suffix", "meaning"),
269        escape(b.note_names),
270        pair_list(b.reserved, "name", "note"),
271        pair_list(b.special_forms, "name", "shape"),
272        strings(b.not_supported)
273    )
274}
275
276fn positional_json((name, m): &(&str, Option<Meaning>)) -> String {
277    let text =
278        |t: Option<&str>| t.map_or_else(|| NONE.to_string(), |t| format!("\"{}\"", escape(t)));
279    format!(
280        "{{ \"name\": \"{}\", \"meaning\": {}, \"unit\": {}, \"part\": {} }}",
281        escape(name),
282        text(m.map(|m| m.text)),
283        text(m.map(|m| m.unit)),
284        text(m.and_then(|m| m.part))
285    )
286}
287
288fn argument_json((name, m, moves): &(&str, Meaning, bool)) -> String {
289    format!(
290        "{{ \"name\": \"{}\", \"meaning\": \"{}\", \"unit\": \"{}\", \"part\": {}, \
291         \"moves\": {moves} }}",
292        escape(name),
293        escape(m.text),
294        escape(m.unit),
295        m.part
296            .map_or_else(|| NONE.to_string(), |p| format!("\"{}\"", escape(p)))
297    )
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn the_note_grammar_this_dump_states_is_what_note_actually_resolves() {
306        for note in ["A4", "Cs4", "Db4"] {
307            assert!(
308                sva_ast::note_midi(note).is_some(),
309                "{note} should be a note"
310            );
311        }
312        for not_a_note in ["H4", "Cs", "C99999999999"] {
313            assert!(
314                sva_ast::note_midi(not_a_note).is_none(),
315                "{not_a_note} is not the grammar this dump states"
316            );
317        }
318    }
319
320    #[test]
321    fn every_operator_this_dump_says_does_not_exist_actually_refuses_to_parse() {
322        for bad in [
323            "1 < 2",
324            "1 > 2",
325            "1 <= 2",
326            "1 >= 2",
327            "1 == 2",
328            "1 != 2",
329            "1 && 1",
330            "1 || 1",
331            "!1",
332            "1 ? 2 : 3",
333            "2 ^ 3",
334        ] {
335            assert!(
336                sva_ast::parse_expr(bad).is_err(),
337                "`{bad}` parsed, so this dump's not_supported claim is stale"
338            );
339        }
340    }
341
342    #[test]
343    fn every_builtin_name_gets_exactly_one_callable_entry() {
344        let b = builtins();
345        assert_eq!(b.callables.len(), BUILTINS.len() + ALL_SHAPES.len());
346        for name in BUILTINS {
347            assert_eq!(
348                b.callables.iter().filter(|c| c.name == name).count(),
349                1,
350                "{name} should appear exactly once"
351            );
352        }
353        for shape in ALL_SHAPES {
354            let found = b
355                .callables
356                .iter()
357                .find(|c| c.name == shape_name(shape))
358                .unwrap_or_else(|| panic!("{} is missing", shape_name(shape)));
359            assert_eq!(found.takes_gain, Some(shape.takes_gain()));
360        }
361    }
362
363    /// `builtins()` refuses to assemble a named argument with no meaning, so this reaching
364    /// every callable is the whole vocabulary explained.
365    #[test]
366    fn every_named_argument_says_what_it_means_and_in_what_unit() {
367        for c in builtins().callables {
368            let named: Vec<&str> = c.arguments.iter().map(|(k, ..)| *k).collect();
369            assert_eq!(named, c.named, "{}", c.name);
370            for (key, m, _) in &c.arguments {
371                assert!(!m.text.is_empty() && !m.unit.is_empty(), "{}.{key}", c.name);
372            }
373        }
374        let b = builtins();
375        let solver = b
376            .callables
377            .iter()
378            .find(|c| c.name == "chaigne_askenfelt")
379            .expect("the solver");
380        let (_, stiffness, moves) = solver.arguments[0];
381        assert!(!moves, "a solver's stiffness is one number");
382        let bore = b.callables.iter().find(|c| c.name == "darabundit_scavone");
383        let (name, held) = bore.expect("the bore").positional[0];
384        let held = held.expect("the bore states its positional");
385        assert_eq!(
386            (name, held.unit),
387            ("length", "m"),
388            "the bore reads a length"
389        );
390        let crop = b.callables.iter().find(|c| c.name == "crop").expect("crop");
391        let bounds: Vec<(&str, Option<&str>)> = crop.positional[1..]
392            .iter()
393            .map(|(name, m)| (*name, m.map(|m| m.unit)))
394            .collect();
395        assert_eq!(
396            bounds,
397            [("start", Some("s")), ("end", Some("s"))],
398            "a window's bounds"
399        );
400        let lowpass = b
401            .callables
402            .iter()
403            .find(|c| c.name == "lowpass")
404            .expect("a filter");
405        assert!(
406            lowpass.arguments.iter().all(|(_, _, moves)| *moves),
407            "a cutoff may sweep"
408        );
409        assert_eq!(
410            (stiffness.text, stiffness.unit, stiffness.part),
411            ("string stiffness (inharmonicity)", "none", Some("string"))
412        );
413    }
414
415    #[test]
416    fn one_pole_alone_has_no_room_for_q_or_gain() {
417        let b = builtins();
418        let lp = b.callables.iter().find(|c| c.name == "lp").unwrap();
419        assert_eq!(lp.max_positional, 2);
420        let lowpass = b.callables.iter().find(|c| c.name == "lowpass").unwrap();
421        assert_eq!(lowpass.max_positional, 3);
422        let peaking = b.callables.iter().find(|c| c.name == "peaking").unwrap();
423        assert_eq!(peaking.max_positional, 4);
424    }
425
426    #[test]
427    fn each_reserved_name_still_behaves_the_way_this_dump_says_it_does() {
428        for (name, _) in sva_ast::RESERVED {
429            assert!(
430                sva_ast::is_reserved(name),
431                "`{name}` is listed as reserved but a parameter may still take it"
432            );
433            if name == "self" {
434                continue;
435            }
436            assert!(
437                sva_ast::parse_expr(name).is_ok(),
438                "`{name}` should parse as the special name this dump claims"
439            );
440        }
441        assert!(
442            !sva_ast::is_reserved("cutoff"),
443            "an ordinary parameter name must not be reserved, or the claim says nothing"
444        );
445        assert!(
446            sva_ast::parse_expr("self").is_err(),
447            "`self` alone must still require a call form"
448        );
449        assert!(
450            sva_ast::parse_expr("self(t - 1sp)").is_ok(),
451            "`self(...)` must still parse as a bounded self-reference"
452        );
453    }
454}