Skip to main content

sva_cli/
builtins.rs

1// Concern: assembles the language's callable/syntactic vocabulary off the engine's own tables | Non-concern: what a builtin renders, the JSON shape (output.rs) | IO: none -> a Builtins value
2
3use sva_engine::overload::{notation, signature};
4use sva_engine::{BUILTINS, Cast, Codomain, Held, MAX_WIDTH, REGISTRY, Ty, Var, recognized_named};
5use sva_formula::filter::{ALL_SHAPES, Shape};
6use sva_formula::{FAMILIES, TABLE_VERSION};
7
8pub struct Callable {
9    pub name: &'static str,
10    pub required: usize,
11    pub max_positional: usize,
12    pub named: &'static [&'static str],
13    /// A subset of `named` with no default — a caller who omits one gets a refusal, not a
14    /// fallback. Every builtin leaves this empty today.
15    pub required_named: &'static [&'static str],
16    pub takes_gain: Option<bool>,
17}
18
19/// `(spelling, meaning)`, in the lexer's own longest-match-first order.
20pub const UNIT_SUFFIXES: [(&str, &str); 11] = [
21    ("khz", "kilohertz (x1000)"),
22    ("ms", "milliseconds (seconds x0.001)"),
23    ("sp", "samples"),
24    ("hz", "hertz"),
25    ("db", "decibels (log)"),
26    ("ct", "cents (log)"),
27    ("st", "semitones (log)"),
28    ("b", "bars, resolved against bpm/meter"),
29    ("s", "seconds"),
30    ("m", "minutes (seconds x60)"),
31    ("h", "hours (seconds x3600)"),
32];
33
34pub const NOTE_GRAMMAR: &str = "a bare identifier: a letter A-G, an optional accidental spelled \
35    s (sharp) or b (flat) -- never #, then a whole-number octave, e.g. A4, Cs4, Db4; A4 = 440 Hz, \
36    twelve-tone equal temperament, up to G9 where MIDI's 128 notes end";
37
38/// `(name, what it resolves to)`.
39pub const RESERVED: [(&str, &str); 5] = [
40    ("t", "time in seconds"),
41    ("f", "frequency in hertz"),
42    ("i", "the imaginary unit"),
43    ("pi", "the constant pi"),
44    ("self", "a bounded self-reference, call-only: self(t - 1sp)"),
45];
46
47/// `(name, its call shape)`.
48pub const SPECIAL_FORMS: [(&str, &str); 5] = [
49    (
50        "sum",
51        "sum(index, lo, hi, expr) -- index is a name the series binds, not a value; hi may be \
52         inf, which makes it a series",
53    ),
54    (
55        "crop",
56        "crop(x, start, end) -- windows x to [start, end) seconds, zero outside",
57    ),
58    (
59        "join",
60        "join(a, b, ...) -- builds one wide value from 2..=8 mono args",
61    ),
62    (
63        "ch",
64        "ch(x, index) -- extracts one component of a wide value by a literal index",
65    ),
66    (
67        "noise",
68        "noise(seed, period=, color=) -- one line per 1/period hertz, each of unit \
69         amplitude, so the series' own RMS is the square root of half its line count; scale \
70         it to the level the piece wants",
71    ),
72];
73
74/// Confirmed against the lexer's and parser's own tables, not asserted from memory: no token
75/// exists for any of these, so none can appear in an expression at all.
76pub const NOT_SUPPORTED: [&str; 4] = [
77    "comparison operators: < > <= >= == !=",
78    "boolean operators: && || !",
79    "conditionals: if/else, a ternary",
80    "exponentiation `^`: write pow(base, exponent)",
81];
82
83/// One written cast, and every representation it takes to which.
84pub struct Crossing {
85    pub name: &'static str,
86    pub rows: Vec<(&'static str, &'static str)>,
87}
88
89pub struct Builtins {
90    pub callables: Vec<Callable>,
91    pub casts: Vec<Crossing>,
92    pub table_version: u64,
93    pub families: &'static [(&'static str, &'static str)],
94    pub refusals: &'static [(&'static str, &'static str)],
95    pub unit_suffixes: &'static [(&'static str, &'static str)],
96    pub note_names: &'static str,
97    pub reserved: &'static [(&'static str, &'static str)],
98    pub special_forms: &'static [(&'static str, &'static str)],
99    pub not_supported: &'static [&'static str],
100}
101
102fn plain_callable(name: &'static str) -> Callable {
103    let sig = signature(name)
104        .unwrap_or_else(|| unreachable!("{name} is in BUILTINS but SIGNATURES does not cover it"));
105    Callable {
106        name,
107        required: sig.required(),
108        max_positional: if sig.variadic {
109            MAX_WIDTH
110        } else {
111            sig.params.len()
112        },
113        named: sig.named(),
114        required_named: &[],
115        takes_gain: None,
116    }
117}
118
119/// `q` is refused outright on `Shape::OnePole` rather than merely unused, so its own positional
120/// ceiling sits below every other shape's.
121fn filter_callable(shape: Shape) -> Callable {
122    let gain = shape.takes_gain();
123    let max_positional = if shape == Shape::OnePole {
124        2
125    } else if gain {
126        4
127    } else {
128        3
129    };
130    Callable {
131        name: shape.name(),
132        required: 2,
133        max_positional,
134        named: recognized_named(shape.name()).unwrap_or(&[]),
135        required_named: &[],
136        takes_gain: Some(gain),
137    }
138}
139
140/// Every state a value's form can be in, so a crossing table shows which duals a cast needs.
141const HELD: [(Held, bool); 6] = [
142    (Held::Form(Var::T), false),
143    (Held::Form(Var::T), true),
144    (Held::Form(Var::F), false),
145    (Held::Form(Var::F), true),
146    (Held::Sampled, false),
147    (Held::Frames, false),
148];
149
150/// Read off `Cast::resolve` rather than restated: a row exists where the cast answers.
151fn crossings() -> Vec<Crossing> {
152    Cast::NAMES
153        .into_iter()
154        .map(|name| {
155            let cast = Cast::from_name(name, &[("window", 1024.0), ("hop", 256.0)])
156                .unwrap_or_else(|| unreachable!("{name} is one of Cast::NAMES"));
157            Crossing {
158                name,
159                rows: HELD
160                    .into_iter()
161                    .filter_map(|(held, dual)| {
162                        let ty = Ty {
163                            dual,
164                            ..Ty::discrete(held, Codomain::Real)
165                        };
166                        cast.resolve(&[ty])
167                            .ok()
168                            .map(|out| (notation(ty), notation(out)))
169                    })
170                    .collect(),
171            }
172        })
173        .collect()
174}
175
176pub fn builtins() -> Builtins {
177    let mut callables: Vec<Callable> = BUILTINS.into_iter().map(plain_callable).collect();
178    callables.extend(ALL_SHAPES.into_iter().map(filter_callable));
179    Builtins {
180        callables,
181        casts: crossings(),
182        table_version: TABLE_VERSION,
183        families: &FAMILIES,
184        refusals: &REGISTRY,
185        unit_suffixes: &UNIT_SUFFIXES,
186        note_names: NOTE_GRAMMAR,
187        reserved: &RESERVED,
188        special_forms: &SPECIAL_FORMS,
189        not_supported: &NOT_SUPPORTED,
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn the_note_grammar_this_dump_states_is_what_note_actually_resolves() {
199        for note in ["A4", "Cs4", "Db4"] {
200            assert!(
201                sva_formula::note::frequency(note).is_some(),
202                "{note} should be a note"
203            );
204        }
205        for not_a_note in ["H4", "Cs", "C99999999999"] {
206            assert!(
207                sva_formula::note::frequency(not_a_note).is_none(),
208                "{not_a_note} is not the grammar this dump states"
209            );
210        }
211    }
212
213    #[test]
214    fn every_operator_this_dump_says_does_not_exist_actually_refuses_to_parse() {
215        for bad in [
216            "1 < 2",
217            "1 > 2",
218            "1 <= 2",
219            "1 >= 2",
220            "1 == 2",
221            "1 != 2",
222            "1 && 1",
223            "1 || 1",
224            "!1",
225            "1 ? 2 : 3",
226            "2 ^ 3",
227        ] {
228            assert!(
229                sva_ast::parse_expr(bad).is_err(),
230                "`{bad}` parsed, so this dump's not_supported claim is stale"
231            );
232        }
233    }
234
235    #[test]
236    fn every_builtin_name_gets_exactly_one_callable_entry() {
237        let b = builtins();
238        assert_eq!(b.callables.len(), BUILTINS.len() + ALL_SHAPES.len());
239        for name in BUILTINS {
240            assert_eq!(
241                b.callables.iter().filter(|c| c.name == name).count(),
242                1,
243                "{name} should appear exactly once"
244            );
245        }
246        for shape in ALL_SHAPES {
247            let found = b
248                .callables
249                .iter()
250                .find(|c| c.name == shape.name())
251                .unwrap_or_else(|| panic!("{} is missing", shape.name()));
252            assert_eq!(found.takes_gain, Some(shape.takes_gain()));
253        }
254    }
255
256    #[test]
257    fn one_pole_alone_has_no_room_for_q_or_gain() {
258        let b = builtins();
259        let lp = b.callables.iter().find(|c| c.name == "lp").unwrap();
260        assert_eq!(lp.max_positional, 2);
261        let lowpass = b.callables.iter().find(|c| c.name == "lowpass").unwrap();
262        assert_eq!(lowpass.max_positional, 3);
263        let peaking = b.callables.iter().find(|c| c.name == "peaking").unwrap();
264        assert_eq!(peaking.max_positional, 4);
265    }
266
267    #[test]
268    fn each_reserved_name_still_behaves_the_way_this_dump_says_it_does() {
269        for (name, _) in RESERVED {
270            assert!(
271                sva_engine::instantiate::is_reserved(name),
272                "`{name}` is listed as reserved but a parameter may still take it"
273            );
274            if name == "self" {
275                continue;
276            }
277            assert!(
278                sva_ast::parse_expr(name).is_ok(),
279                "`{name}` should parse as the special name this dump claims"
280            );
281        }
282        assert!(
283            !sva_engine::instantiate::is_reserved("cutoff"),
284            "an ordinary parameter name must not be reserved, or the claim says nothing"
285        );
286        assert!(
287            sva_ast::parse_expr("self").is_err(),
288            "`self` alone must still require a call form"
289        );
290        assert!(
291            sva_ast::parse_expr("self(t - 1sp)").is_ok(),
292            "`self(...)` must still parse as a bounded self-reference"
293        );
294    }
295}