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