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