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