Skip to main content

sva_ast/
reserved.rs

1// Concern: finds the reserved variables' values and lowers each read of them, again where one changes | Non-concern: a song's moves of a variable (song/) | IO: (&mut Graph[, beside]) -> Refusals
2
3use crate::diag::{ByteSpan, DiagCode};
4use crate::expr::{Address, Arg, BinOp, Binds, DEGREE, Expr, Literal, children, map_children_ok};
5use crate::filename::{FileSpan, SpanUnit, parse_filename};
6use crate::graph::{Defined, Graph, VARIABLES, resolve_ref_path};
7use crate::ingest::{Data, Parsed, base_name, body_at};
8use crate::refusal::Refusal;
9use crate::scale::{BUILTIN_SCALES, Scale, builtin_scale};
10use crate::score::{Item, Ratio};
11
12/// Each one the node `variables/<name>`: a `b` literal or span reads `bpm` and `meter`, and a
13/// `deg` call reads `key`, its tonic, and `scale`, a built-in scale's name or a ref to the
14/// scale file it counts in.
15pub const RESERVED_VARIABLES: [&str; 4] = ["bpm", "meter", "key", "scale"];
16
17const BARS: [&str; 2] = ["bpm", "meter"];
18const DEGREES: [&str; 2] = ["key", "scale"];
19
20pub fn variable(name: &str) -> String {
21    format!("{VARIABLES}/{name}")
22}
23
24/// The reserved variables a node's written body, defaults and file span read; a `repeat` or
25/// `concat` of a file spanned in bars reads that span.
26pub(crate) fn reads(
27    body: &Expr,
28    defaults: &[(String, Expr)],
29    span: Option<FileSpan>,
30) -> Vec<&'static str> {
31    let mut bars = in_bars(span);
32    let mut degrees = false;
33    for e in std::iter::once(body).chain(defaults.iter().map(|(_, value)| value)) {
34        scan(e, &mut bars, &mut degrees);
35    }
36    let mut out = Vec::new();
37    if bars {
38        out.extend(BARS);
39    }
40    if degrees {
41        out.extend(DEGREES);
42    }
43    out
44}
45
46/// The same of a parsed file: a score reads bars where it counts in them or binds one, and
47/// degrees where a line counts one.
48pub(crate) fn parsed_reads(parsed: &Parsed, span: Option<FileSpan>) -> Vec<&'static str> {
49    let Some(Data::Score(score)) = &parsed.data else {
50        return reads(&parsed.expr, &parsed.defaults, span);
51    };
52    let mut bars = in_bars(span);
53    let mut degrees = score.reads_degrees();
54    for (_, item) in score.items() {
55        let lasts = match item {
56            Item::Event(event) => {
57                for bind in &event.binds {
58                    scan(&bind.value, &mut bars, &mut degrees);
59                }
60                event.len.is_some()
61            }
62            Item::Move(m) => m.ramp.is_some(),
63            _ => false,
64        };
65        bars |= score.unit == SpanUnit::Bars && (lasts || item.at().amount != Ratio::ZERO);
66    }
67    let mut out = Vec::new();
68    if bars {
69        out.extend(BARS);
70    }
71    if degrees {
72        out.extend(DEGREES);
73    }
74    out
75}
76
77fn scan(e: &Expr, bars: &mut bool, degrees: &mut bool) {
78    match e {
79        Expr::Lit(Literal::Bars(_)) => *bars = true,
80        Expr::Call { name, .. } if name == DEGREE => *degrees = true,
81        Expr::Call { name, args, .. } if name == "repeat" || name == "concat" => {
82            *bars |= args.iter().any(|arg| match arg {
83                Arg::Pos(Expr::Ref { path, .. }) => in_bars(spanned(path)),
84                _ => false,
85            });
86        }
87        _ => {}
88    }
89    for c in children(e, Binds::Substitute) {
90        scan(c, bars, degrees);
91    }
92}
93
94fn spanned(path: &str) -> Option<FileSpan> {
95    parse_filename(base_name(path)).1
96}
97
98fn in_bars(span: Option<FileSpan>) -> bool {
99    span.is_some_and(|span| span.unit == SpanUnit::Bars)
100}
101
102/// `variables/bpm` and `variables/meter` together: seconds per bar is `beats * 60 / bpm`.
103#[derive(Clone, Debug, PartialEq)]
104pub struct Tempo {
105    pub bpm: f64,
106    pub meter: String,
107    beats: f64,
108}
109
110impl Tempo {
111    pub fn seconds_per_bar(&self) -> f64 {
112        self.beats * 60.0 / self.bpm
113    }
114
115    pub fn beats(&self) -> f64 {
116        self.beats
117    }
118
119    fn seconds(&self, bars: f64) -> Expr {
120        let num = |v: f64| Box::new(Expr::Lit(Literal::Num(v)));
121        Expr::Bin(
122            BinOp::Mul,
123            num(bars),
124            Box::new(Expr::Bin(BinOp::Div, num(self.beats * 60.0), num(self.bpm))),
125        )
126    }
127}
128
129/// What lowering reads off each reserved variable; the tonic stays a ref, so only whether it
130/// is held.
131#[derive(Clone, Debug, Default, PartialEq)]
132pub struct Values {
133    pub tempo: Option<Tempo>,
134    pub scale: Option<Scale>,
135    tonic: bool,
136}
137
138impl Values {
139    /// Each reserved variable a node lowered under `other` reads otherwise under these.
140    fn changed(&self, other: &Values) -> Vec<&'static str> {
141        let mut out = Vec::new();
142        if self.tempo != other.tempo {
143            out.extend(BARS);
144        }
145        if self.scale != other.scale {
146            out.push("scale");
147        }
148        out
149    }
150}
151
152fn tempo_refusal(name: &str, reason: String) -> Refusal {
153    Refusal::new(variable(name), ByteSpan::at(0), DiagCode::BadTempo, reason)
154}
155
156/// An unparsable numerator is refused, not guessed.
157fn beats_per_bar(meter: &str) -> Result<f64, Refusal> {
158    let numerator = &meter[..meter.find('/').unwrap_or(meter.len())];
159    numerator.trim().parse::<f64>().map_err(|e| {
160        tempo_refusal(
161            "meter",
162            format!(
163                "meter `{meter}`'s beats-per-bar component `{numerator}` is not a number: {e}; \
164                 a meter is `<beats>/<note>`, as `4/4`"
165            ),
166        )
167    })
168}
169
170fn tempo(bpm: Option<&Expr>, meter: Option<&Expr>) -> Result<Option<Tempo>, Refusal> {
171    match (bpm, meter) {
172        (None, None) => Ok(None),
173        (Some(Expr::Lit(Literal::Num(bpm))), Some(Expr::Lit(Literal::Str(meter)))) => {
174            if *bpm <= 0.0 {
175                return Err(tempo_refusal(
176                    "bpm",
177                    format!("bpm must be positive, got {bpm}"),
178                ));
179            }
180            Ok(Some(Tempo {
181                bpm: *bpm,
182                meter: meter.clone(),
183                beats: beats_per_bar(meter)?,
184            }))
185        }
186        (Some(_), Some(_)) => Err(tempo_refusal(
187            "bpm",
188            "`variables/bpm` must be a number and `variables/meter` a meter, as `4/4`".to_string(),
189        )),
190        (Some(_), None) => Err(tempo_refusal(
191            "meter",
192            "`variables/bpm` is present but `variables/meter` is missing; both or neither"
193                .to_string(),
194        )),
195        (None, Some(_)) => Err(tempo_refusal(
196            "bpm",
197            "`variables/meter` is present but `variables/bpm` is missing; both or neither"
198                .to_string(),
199        )),
200    }
201}
202
203/// The one whole degree a `deg` call names, folded from literals.
204pub(crate) fn degree(args: &[Arg]) -> Result<i64, String> {
205    let whole = match args {
206        [Arg::Pos(x)] => crate::series::folded(x, &|_| None)
207            .filter(|d| d.fract() == 0.0 && d.abs() < 2f64.powi(53)),
208        _ => None,
209    };
210    whole.map(|d| d as i64).ok_or_else(|| {
211        "`deg` takes one whole scale degree folded from literals, as deg(5) or deg(-6); a \
212         chromatic note is a degree times a ratio, as deg(7)*1st"
213            .to_string()
214    })
215}
216
217/// `deg(n)`'s value: the tonic `variables/key` holds, read at `t`, times degree n's ratio.
218pub(crate) fn tonic_times(key: &str, ratio: f64, span: ByteSpan) -> Expr {
219    let tonic = Expr::Ref {
220        path: key.to_string(),
221        arg: Box::new(Expr::Var("t".to_string())),
222        binds: Vec::new(),
223        address: Address::Time,
224        span,
225    };
226    Expr::Bin(
227        BinOp::Mul,
228        Box::new(tonic),
229        Box::new(Expr::Lit(Literal::Num(ratio))),
230    )
231}
232
233/// `variables/key` as a node at `path` spells a ref to it.
234pub(crate) fn key_ref(path: &str) -> String {
235    format!(
236        "{}{}",
237        "../".repeat(path.matches('/').count()),
238        variable("key")
239    )
240}
241
242/// A built-in scale's name as written: one word, or words joined by `-`, which an expression
243/// reads as subtraction, so `harmonic-minor` is the name it spells.
244fn scale_name(written: &Expr) -> Option<String> {
245    match written {
246        Expr::Var(word) => Some(word.clone()),
247        Expr::Bin(BinOp::Sub, left, right) => {
248            Some(format!("{}-{}", scale_name(left)?, scale_name(right)?))
249        }
250        _ => None,
251    }
252}
253
254fn builtin_names() -> String {
255    BUILTIN_SCALES.map(|(name, ..)| name).join(", ")
256}
257
258impl Graph {
259    /// Each reserved variable's value, read off the node this graph holds, else `beside`'s.
260    pub fn values(&self, beside: Option<&Graph>) -> Result<Values, Vec<Refusal>> {
261        let held = |path: &str| self.defines(path) || beside.is_some_and(|b| b.defines(path));
262        let node = |name: &str| {
263            let path = variable(name);
264            self.source(&path)
265                .or_else(|| beside?.source(&path))
266                .map(|d| &d.body)
267        };
268        let tempo = tempo(node("bpm"), node("meter"));
269        let scale = match node("scale") {
270            None => Ok(None),
271            Some(written) => self
272                .scale_at(written, &variable("scale"), beside)
273                .map(Some)
274                .map_err(|reason| {
275                    Refusal::new(
276                        variable("scale"),
277                        ByteSpan::at(0),
278                        DiagCode::BadScale,
279                        reason,
280                    )
281                }),
282        };
283        match (tempo, scale) {
284            (Ok(tempo), Ok(scale)) => Ok(Values {
285                tempo,
286                scale,
287                tonic: held(&variable("key")),
288            }),
289            (tempo, scale) => Err(tempo.err().into_iter().chain(scale.err()).collect()),
290        }
291    }
292
293    /// The scale `written` at `referencing` names: a built-in by its name, any other by a bare
294    /// ref to its file.
295    pub(crate) fn scale_at(
296        &self,
297        written: &Expr,
298        referencing: &str,
299        beside: Option<&Graph>,
300    ) -> Result<Scale, String> {
301        let target = match written {
302            Expr::Ref {
303                path: to,
304                arg,
305                binds,
306                address: Address::Time,
307                ..
308            } if binds.is_empty() && **arg == Expr::Var("t".to_string()) => {
309                resolve_ref_path(referencing, to)
310            }
311            _ => {
312                return match scale_name(written) {
313                    Some(name) => builtin_scale(&name).ok_or_else(|| {
314                        format!(
315                            "`{referencing}` names `{name}`, which is no built-in scale; the \
316                             built-ins are {}, and any other scale is a file of cents named by \
317                             a ref, as `@../scales/custom`",
318                            builtin_names()
319                        )
320                    }),
321                    None => Err(format!(
322                        "`{referencing}` holds `{}`; a scale is a built-in named bare, as \
323                         `minor`, or a scale file named by a ref, as `@../scales/custom`",
324                        crate::print::render(written)
325                    )),
326                };
327            }
328        };
329        let found = target
330            .as_deref()
331            .and_then(|target| self.scale(target).or_else(|| beside?.scale(target)));
332        match (target, found) {
333            (Some(_), Some(scale)) => Ok(scale.clone()),
334            (target, _) => Err(format!(
335                "`{referencing}` names {}, and a scale is a file of rows of cents above the \
336                 tonic, the last the period, as `scales/custom` would hold them, or a built-in \
337                 named bare: {}",
338                match target {
339                    Some(t) if self.defines(&t) || beside.is_some_and(|b| b.defines(&t)) => {
340                        format!("`{t}`, no scale")
341                    }
342                    Some(t) => format!("`{t}`, which no file holds"),
343                    None => "a path above the composition root".to_string(),
344                },
345                builtin_names()
346            )),
347        }
348    }
349
350    /// Every read of a reserved variable lowered to its value: a `b` literal or span to
351    /// seconds, a `deg` call to the tonic's ref times its ratio. Beside a graph lowered before,
352    /// each of its nodes that read a variable under another value than it holds now is read
353    /// again from its text and lowered here.
354    pub fn lower_reserved(&mut self, beside: Option<&Graph>) -> Result<(), Vec<Refusal>> {
355        let values = self.values(beside)?;
356        let mut refusals = Vec::new();
357        if let Some(beside) = beside {
358            let stale = |path: &str| match beside.lowered_under(path) {
359                None => false,
360                Some(was) => {
361                    let changed = values.changed(was);
362                    beside
363                        .reserved_reads(path)
364                        .iter()
365                        .any(|v| changed.contains(v))
366                }
367            };
368            let stale: Vec<String> = beside
369                .paths()
370                .filter(|path| !self.defines(path) && stale(path))
371                .map(str::to_string)
372                .collect();
373            for path in stale {
374                if let Err(refused) = self.reread(&path, beside) {
375                    refusals.push(refused);
376                }
377            }
378        }
379        let reading: Vec<String> = self
380            .paths()
381            .filter(|path| !self.reserved_reads(path).is_empty())
382            .map(str::to_string)
383            .collect();
384        for path in reading {
385            let defined = self.defined(&path).expect("a node this graph holds");
386            match self.lowered(&path, defined, &values) {
387                Ok(lowered) => self.lower(&path, lowered, &values),
388                Err(refused) => refusals.extend(refused),
389            }
390        }
391        match refusals.is_empty() {
392            true => Ok(()),
393            false => Err(refusals),
394        }
395    }
396
397    /// `defined` at `path` with its reads of a reserved variable lowered under `values`; a
398    /// refusal in a score names its line and column.
399    pub(crate) fn lowered(
400        &self,
401        path: &str,
402        defined: &Defined,
403        values: &Values,
404    ) -> Result<Defined, Vec<Refusal>> {
405        self.lowered_with(path, defined, values, None)
406    }
407
408    /// The same, each `deg(n)`'s ratio read off `ratio` where given: a song whose scale moves
409    /// passes degree n's ratio lane there.
410    pub(crate) fn lowered_with(
411        &self,
412        path: &str,
413        defined: &Defined,
414        values: &Values,
415        ratio: Option<&dyn Fn(i64) -> Expr>,
416    ) -> Result<Defined, Vec<Refusal>> {
417        let reads = match self.defines(path) {
418            true => self.reserved_reads(path).to_vec(),
419            false => reads(&defined.body, &defined.defaults, None),
420        };
421        let missing = |name: &str| match name {
422            "bpm" | "meter" => values.tempo.is_none().then(|| {
423                Refusal::new(
424                    path,
425                    ByteSpan::at(0),
426                    DiagCode::BadTempo,
427                    format!(
428                        "`{path}` counts in bars, `b`, and this composition declares no tempo; \
429                         add `variables/bpm` and `variables/meter`"
430                    ),
431                )
432            }),
433            "key" => (!values.tonic).then(|| {
434                Refusal::new(
435                    path,
436                    ByteSpan::at(0),
437                    DiagCode::BadScale,
438                    format!(
439                        "`{path}` calls `deg`, which counts from the tonic `variables/key` \
440                         holds, and this composition has none"
441                    ),
442                )
443            }),
444            _ => None,
445        };
446        let mut refusals: Vec<Refusal> = reads.iter().filter_map(|name| missing(name)).collect();
447        refusals.dedup_by(|a, b| a.reason == b.reason);
448        if !refusals.is_empty() {
449            return Err(refusals);
450        }
451        let text = self.text(path);
452        let score = self.score(path).is_some();
453        let refuse = |span: ByteSpan, message: String| match (score, text) {
454            (true, Some(text)) => crate::score::refused(
455                format!("{path}{}", crate::score::EXTENSION),
456                text,
457                span.start,
458                DiagCode::BadScore,
459                &message,
460            ),
461            _ => Refusal::new(path, span, DiagCode::BadScale, message),
462        };
463        let from = match (score, text) {
464            (false, Some(text)) => body_at(text),
465            _ => 0,
466        };
467        let mut lowering = Lowering {
468            values,
469            key: key_ref(path),
470            refuse: &refuse,
471            refusals: Vec::new(),
472            ratio,
473        };
474        let body = lowering.lower(&defined.body, from);
475        let defaults = defined
476            .defaults
477            .iter()
478            .map(|(name, value)| (name.clone(), lowering.lower(value, 0)))
479            .collect();
480        match lowering.refusals.is_empty() {
481            true => Ok(Defined { body, defaults }),
482            false => Err(lowering.refusals),
483        }
484    }
485}
486
487struct Lowering<'a> {
488    values: &'a Values,
489    key: String,
490    refuse: &'a dyn Fn(ByteSpan, String) -> Refusal,
491    refusals: Vec<Refusal>,
492    ratio: Option<&'a dyn Fn(i64) -> Expr>,
493}
494
495impl Lowering<'_> {
496    /// `e` with each read lowered; `from` is where `e`'s spans start in its file. A degree it
497    /// cannot read is refused and left as written.
498    fn lower(&mut self, e: &Expr, from: usize) -> Expr {
499        match e {
500            Expr::Lit(Literal::Bars(bars)) => self
501                .values
502                .tempo
503                .as_ref()
504                .expect("a node counting in bars is refused with no tempo")
505                .seconds(*bars),
506            Expr::Call { name, args, span } if name == DEGREE && self.ratio.is_some() => {
507                match degree(args) {
508                    Ok(n) => {
509                        let ratio = (self.ratio.expect("matched"))(n);
510                        let tonic = tonic_times(&self.key, 1.0, *span);
511                        let Expr::Bin(_, tonic, _) = tonic else {
512                            unreachable!("a product")
513                        };
514                        Expr::Bin(BinOp::Mul, tonic, Box::new(ratio))
515                    }
516                    Err(message) => {
517                        let at = ByteSpan::new(span.start + from, span.end + from);
518                        self.refusals.push((self.refuse)(at, message));
519                        e.clone()
520                    }
521                }
522            }
523            Expr::Call { name, args, span } if name == DEGREE => {
524                let ratio = degree(args).and_then(|n| match &self.values.scale {
525                    None => Err(
526                        "`deg` counts in the scale `variables/scale` names, and this \
527                         composition has none; write a built-in's name in `variables/scale`, \
528                         as `major`, or a ref to a scale file"
529                            .to_string(),
530                    ),
531                    Some(scale) => scale.ratio(n).ok_or_else(|| {
532                        format!("`deg` {n} lies so far from the tonic its ratio is no number")
533                    }),
534                });
535                match ratio {
536                    Ok(ratio) => tonic_times(&self.key, ratio, *span),
537                    Err(message) => {
538                        let at = ByteSpan::new(span.start + from, span.end + from);
539                        self.refusals.push((self.refuse)(at, message));
540                        e.clone()
541                    }
542                }
543            }
544            _ => map_children_ok(e, Binds::Substitute, |c| self.lower(c, from)),
545        }
546    }
547}