Skip to main content

sva_ast/
graph.rs

1// Concern: folds a source's node texts into a graph, a song-lowered node kept beside its body | Non-concern: where the texts come from (source.rs), lowering a song (song/) | IO: (&dyn Source) -> Graph
2
3use std::collections::{BTreeMap, BTreeSet, HashMap};
4use std::sync::Arc;
5
6use crate::diag::{ByteSpan, DiagCode};
7use crate::expr::{Binds, Expr, children, map_children_ok};
8use crate::filename::FileSpan;
9use crate::ingest::{Data, Parsed, base_name, parse_file, parse_score_file};
10use crate::refusal::Refusal;
11use crate::reserved::{Values, variable};
12use crate::score::EXTENSION;
13use crate::skipped::{Skip, Skipped};
14use crate::source::Source;
15
16/// The one reserved folder name; its contents are ordinary nodes, so `@variables/bpm` refs.
17pub const VARIABLES: &str = "variables";
18
19#[derive(Clone, Debug)]
20pub struct Graph {
21    nodes: BTreeMap<String, Arc<Defined>>,
22    data: HashMap<String, Data>,
23    spans: HashMap<String, Option<FileSpan>>,
24    /// The reserved variables each node's own text reads, and the values they were lowered to.
25    reserved: HashMap<String, Vec<&'static str>>,
26    lowered: HashMap<String, Values>,
27    skipped: Vec<Skipped>,
28    /// Each node's text as its source answered it, or as a defined one prints.
29    texts: HashMap<String, String>,
30    origins: HashMap<String, Origin>,
31    /// The edit of its source each node's text was read at, where the source counts them.
32    generations: HashMap<String, u64>,
33}
34
35/// A node's body and the default lines written above it, held whole so a reader may keep it.
36#[derive(Clone, Debug, PartialEq)]
37pub struct Defined {
38    pub body: Expr,
39    pub defaults: Vec<(String, Expr)>,
40}
41
42#[derive(Clone, Debug)]
43enum Origin {
44    Lowered(Arc<Defined>),
45    Made,
46}
47
48/// A node's parse with its text, span and data, as one graph hands it to another.
49#[derive(Clone, Debug)]
50pub struct Held {
51    defined: Arc<Defined>,
52    origin: Option<Origin>,
53    text: String,
54    generation: Option<u64>,
55    span: Option<FileSpan>,
56    data: Option<Data>,
57    reserved: Vec<&'static str>,
58    lowered: Option<Values>,
59}
60
61impl Held {
62    pub fn body(&self) -> &Expr {
63        &self.defined.body
64    }
65}
66
67fn rebind(e: &Expr, to: &dyn Fn(&str) -> Option<Expr>) -> Expr {
68    match e {
69        Expr::Ref {
70            path,
71            arg,
72            binds,
73            address,
74            span,
75        } => Expr::Ref {
76            path: path.clone(),
77            arg: Box::new(rebind(arg, to)),
78            binds: binds
79                .iter()
80                .map(|(k, v)| (k.clone(), to(k).unwrap_or_else(|| rebind(v, to))))
81                .collect(),
82            address: *address,
83            span: *span,
84        },
85        _ => map_children_ok(e, Binds::Substitute, |c| rebind(c, to)),
86    }
87}
88
89impl Graph {
90    /// Every binding of a parameter `to` answers, by a ref or a default, bound to that answer.
91    pub fn rebound(&self, to: &dyn Fn(&str) -> Option<Expr>) -> Graph {
92        let mut out = self.clone();
93        for defined in out.nodes.values_mut() {
94            let defaults = defined.defaults.iter();
95            let defaults =
96                defaults.map(|(k, v)| (k.clone(), to(k).unwrap_or_else(|| rebind(v, to))));
97            *defined = Arc::new(Defined {
98                body: rebind(&defined.body, to),
99                defaults: defaults.collect(),
100            });
101        }
102        out
103    }
104
105    pub fn defines(&self, path: &str) -> bool {
106        self.nodes.contains_key(path)
107    }
108
109    pub fn skipped(&self) -> &[Skipped] {
110        &self.skipped
111    }
112
113    pub fn expr(&self, path: &str) -> Option<&Expr> {
114        self.nodes.get(path).map(|defined| &defined.body)
115    }
116
117    pub fn defined(&self, path: &str) -> Option<&Arc<Defined>> {
118        self.nodes.get(path)
119    }
120
121    pub fn source(&self, path: &str) -> Option<&Arc<Defined>> {
122        match self.origins.get(path) {
123            Some(Origin::Lowered(source)) => Some(source),
124            Some(Origin::Made) => None,
125            None => self.nodes.get(path),
126        }
127    }
128
129    pub fn source_expr(&self, path: &str) -> Option<Expr> {
130        self.source(path).map(|d| d.body.clone())
131    }
132
133    /// Each node as its file holds it, what only a song made gone.
134    pub fn sourced(&self) -> Graph {
135        let mut out = self.clone();
136        for (path, origin) in std::mem::take(&mut out.origins) {
137            match origin {
138                Origin::Lowered(source) => {
139                    out.nodes.insert(path, source);
140                }
141                Origin::Made => {
142                    out.set(&path, None);
143                }
144            }
145        }
146        out
147    }
148
149    pub(crate) fn derive(&mut self, path: &str, defined: Defined) {
150        match self.nodes.get(path) {
151            Some(held) => {
152                if !self.origins.contains_key(path) {
153                    let source = Origin::Lowered(Arc::clone(held));
154                    self.origins.insert(path.to_string(), source);
155                }
156                self.nodes.insert(path.to_string(), Arc::new(defined));
157            }
158            None => {
159                let mut held = self.defining(defined.body);
160                held.defined = Arc::new(Defined {
161                    body: held.defined.body.clone(),
162                    defaults: defined.defaults,
163                });
164                held.origin = Some(Origin::Made);
165                self.set(path, Some(held));
166            }
167        }
168    }
169
170    pub fn text(&self, path: &str) -> Option<&str> {
171        self.texts.get(path).map(String::as_str)
172    }
173
174    pub fn generation(&self, path: &str) -> Option<u64> {
175        self.generations.get(path).copied()
176    }
177
178    /// What an invocation binding nothing for a name gets, in written order. A caller's own
179    /// binding wins, so this is only ever consulted for names the invocation left out.
180    pub fn defaults(&self, path: &str) -> &[(String, Expr)] {
181        self.nodes
182            .get(path)
183            .map_or(&[], |defined| defined.defaults.as_slice())
184    }
185
186    pub fn span(&self, path: &str) -> Option<FileSpan> {
187        self.spans.get(path).copied().flatten()
188    }
189
190    /// The events a score file holds, `None` for every node an equation or a number writes.
191    pub fn score(&self, path: &str) -> Option<&crate::score::Score> {
192        match self.data.get(path)? {
193            Data::Score(score) => Some(score),
194            _ => None,
195        }
196    }
197
198    /// The cents a scale file holds: data a score's degrees read, never a signal.
199    pub fn scale(&self, path: &str) -> Option<&crate::scale::Scale> {
200        match self.data.get(path)? {
201            Data::Scale(scale) => Some(scale),
202            _ => None,
203        }
204    }
205
206    pub(crate) fn reserved_reads(&self, path: &str) -> &[&'static str] {
207        self.reserved.get(path).map_or(&[], Vec::as_slice)
208    }
209
210    pub(crate) fn lowered_under(&self, path: &str) -> Option<&Values> {
211        self.lowered.get(path)
212    }
213
214    pub(crate) fn lower(&mut self, path: &str, defined: Defined, under: &Values) {
215        let held = self.nodes.get_mut(path).expect("a node this graph holds");
216        *Arc::make_mut(held) = defined;
217        self.lowered.insert(path.to_string(), under.clone());
218    }
219
220    /// `from`'s node at `path`, parsed again from its own text, before any pass rewrote it.
221    pub(crate) fn reread(&mut self, path: &str, from: &Graph) -> Result<(), Refusal> {
222        let mut held = from.held(path).expect("a node `from` holds");
223        let parsed = match held.data {
224            Some(Data::Score(_)) => parse_score_file(path, &held.text),
225            _ => parse_file(&held.text),
226        };
227        let parsed = parsed.map_err(|d| Refusal::new(path, d.span, d.code, d.message))?;
228        held.span = crate::filename::parse_filename(base_name(path)).1;
229        held.reserved = crate::reserved::parsed_reads(&parsed, held.span);
230        held.lowered = None;
231        held.defined = Arc::new(Defined {
232            body: parsed.expr,
233            defaults: parsed.defaults,
234        });
235        held.data = parsed.data;
236        self.set(path, Some(held));
237        Ok(())
238    }
239
240    pub fn paths(&self) -> impl Iterator<Item = &str> {
241        self.nodes.keys().map(String::as_str)
242    }
243
244    /// What `variables/bpm` and `variables/meter` hold, where this composition declares them.
245    pub fn tempo(&self) -> Option<crate::reserved::Tempo> {
246        self.values(None)
247            .expect("a lowered graph's reserved variables hold values")
248            .tempo
249    }
250
251    /// Adds a node no file backs — an ad-hoc expression read in this graph's namespace.
252    /// `false` when `path` is taken, so a probe can never shadow a composition's own file.
253    pub fn define(&mut self, path: &str, expr: Expr) -> bool {
254        if self.nodes.contains_key(path) {
255            return false;
256        }
257        let held = self.defining(expr);
258        self.set(path, Some(held));
259        true
260    }
261
262    /// A node no file backs, its reads of reserved variables already lowered.
263    pub fn defining(&self, body: Expr) -> Held {
264        Held {
265            origin: None,
266            text: crate::print::render(&body),
267            generation: None,
268            defined: Arc::new(Defined {
269                body,
270                defaults: Vec::new(),
271            }),
272            span: None,
273            data: None,
274            reserved: Vec::new(),
275            lowered: None,
276        }
277    }
278
279    pub fn held(&self, path: &str) -> Option<Held> {
280        Some(Held {
281            defined: Arc::clone(self.nodes.get(path)?),
282            origin: self.origins.get(path).cloned(),
283            text: self
284                .texts
285                .get(path)
286                .cloned()
287                .expect("every node holds its text"),
288            generation: self.generations.get(path).copied(),
289            span: self.spans.get(path).copied().flatten(),
290            data: self.data.get(path).cloned(),
291            reserved: self.reserved_reads(path).to_vec(),
292            lowered: self.lowered.get(path).cloned(),
293        })
294    }
295
296    pub fn set(&mut self, path: &str, held: Option<Held>) -> Option<Held> {
297        let before = self.held(path);
298        match held {
299            Some(held) => {
300                self.nodes.insert(path.to_string(), held.defined);
301                self.texts.insert(path.to_string(), held.text);
302                match held.generation {
303                    Some(at) => self.generations.insert(path.to_string(), at),
304                    None => self.generations.remove(path),
305                };
306                self.spans.insert(path.to_string(), held.span);
307                match held.data {
308                    Some(data) => self.data.insert(path.to_string(), data),
309                    None => self.data.remove(path),
310                };
311                match held.reserved.is_empty() {
312                    true => self.reserved.remove(path),
313                    false => self.reserved.insert(path.to_string(), held.reserved),
314                };
315                match held.lowered {
316                    Some(values) => self.lowered.insert(path.to_string(), values),
317                    None => self.lowered.remove(path),
318                };
319                match held.origin {
320                    Some(origin) => self.origins.insert(path.to_string(), origin),
321                    None => self.origins.remove(path),
322                };
323            }
324            None => {
325                self.nodes.remove(path);
326                self.texts.remove(path);
327                self.generations.remove(path);
328                self.spans.remove(path);
329                self.data.remove(path);
330                self.reserved.remove(path);
331                self.lowered.remove(path);
332                self.origins.remove(path);
333            }
334        }
335        before
336    }
337
338    pub fn holds_as(&self, path: &str, other: &Graph) -> bool {
339        match (self.nodes.get(path), other.nodes.get(path)) {
340            (Some(a), Some(b)) => Arc::ptr_eq(a, b) || a == b,
341            (None, None) => true,
342            _ => false,
343        }
344    }
345
346    pub fn read_as(&self, path: &str, other: &Graph) -> bool {
347        self.texts.get(path) == other.texts.get(path)
348            && self.generations.get(path) == other.generations.get(path)
349    }
350
351    /// Each node `path` reads directly, by its body, a default or a reserved variable.
352    fn reaching_from(&self, path: &str) -> Vec<String> {
353        let Some(defined) = self.nodes.get(path) else {
354            return Vec::new();
355        };
356        let mut out = reads_of(path, &defined.body);
357        for (_, value) in &defined.defaults {
358            out.extend(reads_of(path, value));
359        }
360        out.extend(self.reserved_reads(path).iter().map(|name| variable(name)));
361        out
362    }
363
364    pub fn reaching(&self, roots: &[String]) -> BTreeSet<String> {
365        let (mut seen, mut open) = (BTreeSet::new(), roots.to_vec());
366        while let Some(path) = open.pop() {
367            if self.defines(&path) && seen.insert(path.clone()) {
368                open.extend(self.reaching_from(&path));
369            }
370        }
371        seen
372    }
373
374    pub fn adopt(&mut self, from: &Graph, roots: &[String]) -> Vec<String> {
375        let lacking: Vec<String> = from
376            .reaching(roots)
377            .into_iter()
378            .filter(|path| !self.defines(path))
379            .collect();
380        for path in &lacking {
381            self.set(path, from.held(path));
382        }
383        lacking
384    }
385
386    pub fn define_settled(
387        &mut self,
388        beside: Option<&Graph>,
389        path: &str,
390        expr: Expr,
391    ) -> Result<bool, Vec<Refusal>> {
392        if self.defines(path) {
393            return Ok(false);
394        }
395        let written = crate::series::written_out(&expr)
396            .map_err(|d| vec![Refusal::new(path, d.span, d.code, d.message)])?;
397        let mut defined = Defined {
398            body: written,
399            defaults: Vec::new(),
400        };
401        if !crate::reserved::reads(&defined.body, &[], None).is_empty() {
402            let values = self.values(beside)?;
403            defined = self.lowered(path, &defined, &values)?;
404        }
405        Ok(self.define(path, defined.body))
406    }
407}
408
409pub(crate) fn variable_text<'g>(graph: &'g Graph, name: &str) -> Option<&'g str> {
410    let text = graph.text(&format!("{VARIABLES}/{name}"))?;
411    Some(&text[crate::ingest::body_at(text)..])
412}
413
414/// Every node the source names. What a whole composition can be told about itself — `lint`, an
415/// entry-point set — needs all of them, so this is what a directory load is.
416pub fn load(source: &dyn Source) -> Result<Graph, Vec<Refusal>> {
417    let mut loading = Loading::new(source.name());
418    let listing = source
419        .paths()
420        .map_err(|reason| vec![loading.directory(reason)])?;
421    for path in &listing.found {
422        let node = path.strip_suffix(EXTENSION).unwrap_or(path);
423        match names_a_node(node) {
424            true if !loading.holds(node) => {
425                loading.pull(source, node);
426            }
427            true => {}
428            false => loading.skipped.push(Skipped {
429                path: path.clone(),
430                reason: Skip::Unnameable,
431            }),
432        }
433    }
434    for path in &listing.unreadable {
435        loading.skipped.push(Skipped {
436            path: path.clone(),
437            reason: Skip::Special,
438        });
439    }
440    loading.finish(None)
441}
442
443/// Whether anything names this path: a ref path, or one an instance's binds follow, which
444/// a render still addresses by name though no `@ref` spells it.
445pub fn names_a_node(path: &str) -> bool {
446    let bare = match path.split_once('(') {
447        Some((before, binds)) if binds.ends_with(')') => before,
448        _ => path,
449    };
450    !bare.is_empty() && crate::lexer::whole_ref_path(bare)
451}
452
453/// The root set [`load_reaching`] follows, for a caller whose expression is not in the source
454/// yet. A bareword call names a file too, so every call name is named speculatively.
455pub fn reads_of(referencing: &str, expr: &Expr) -> Vec<String> {
456    let mut named = Vec::new();
457    collect_named(expr, &mut named);
458    named
459        .iter()
460        .filter_map(|name| resolve_ref_path(referencing, name))
461        .collect()
462}
463
464/// Only what `roots` reach, following each parsed node's own reads: an unreferenced node is
465/// never asked for, and one the source cannot answer for is a located dangling-ref refusal.
466pub fn load_reaching(source: &dyn Source, roots: &[&str]) -> Result<Graph, Vec<Refusal>> {
467    let mut loading = Loading::new(source.name());
468    let mut work: Vec<String> = roots.iter().rev().map(|r| (*r).to_string()).collect();
469    while let Some(path) = work.pop() {
470        if loading.holds(&path) || !loading.pull(source, &path) {
471            continue;
472        }
473        work.extend(loading.reads(&path));
474    }
475    loading.finish(None)
476}
477
478/// What `roots` reach that `base` does not hold, or holds at an earlier edit of `source`:
479/// each node read and parsed once, a ref into `base` resolving there. A node `base` holds as
480/// `source` still does is walked through, never read.
481pub fn load_beside(
482    source: &dyn Source,
483    roots: &[&str],
484    base: &Graph,
485) -> Result<Graph, Vec<Refusal>> {
486    let mut loading = Loading::new(source.name());
487    let mut walked = BTreeSet::new();
488    let mut work: Vec<String> = roots.iter().rev().map(|r| (*r).to_string()).collect();
489    while let Some(path) = work.pop() {
490        let current = base.defines(&path)
491            && generation(source, &path).is_none_or(|at| base.generation(&path) == Some(at));
492        if current {
493            if walked.insert(path.clone()) {
494                work.extend(base.reaching_from(&path));
495            }
496            continue;
497        }
498        if loading.holds(&path) || !loading.pull(source, &path) {
499            continue;
500        }
501        work.extend(loading.reads(&path));
502    }
503    loading.finish(Some(base))
504}
505
506/// A body or default reading `sample("<path>")` holds the recording the source answers for.
507fn recorded(mut parsed: Parsed, source: &dyn Source) -> Result<Parsed, crate::diag::Diag> {
508    let unheld = |e: crate::recording::Unheld| {
509        let code = match e.why {
510            None => DiagCode::MissingRecording,
511            Some(_) => DiagCode::Io,
512        };
513        crate::diag::Diag::new(code, ByteSpan::at(0), e.message())
514    };
515    parsed.expr = crate::recording::resolve_recordings(&parsed.expr, source).map_err(unheld)?;
516    for (_, value) in &mut parsed.defaults {
517        *value = crate::recording::resolve_recordings(value, source).map_err(unheld)?;
518    }
519    Ok(parsed)
520}
521
522fn generation(source: &dyn Source, path: &str) -> Option<u64> {
523    source
524        .generation(path)
525        .or_else(|| source.generation(&format!("{path}{EXTENSION}")))
526}
527
528/// One parsed node at a time, so pulling everything and pulling a closure are the same walk.
529struct Loading {
530    name: String,
531    nodes: BTreeMap<String, Expr>,
532    defaults: BTreeMap<String, Vec<(String, Expr)>>,
533    data: HashMap<String, Data>,
534    spans: HashMap<String, Option<FileSpan>>,
535    missing: BTreeSet<String>,
536    refusals: Vec<Refusal>,
537    skipped: Vec<Skipped>,
538    texts: HashMap<String, String>,
539    generations: HashMap<String, u64>,
540    reserved: HashMap<String, Vec<&'static str>>,
541}
542
543impl Loading {
544    fn new(name: String) -> Loading {
545        Loading {
546            name,
547            nodes: BTreeMap::new(),
548            defaults: BTreeMap::new(),
549            data: HashMap::new(),
550            spans: HashMap::new(),
551            missing: BTreeSet::new(),
552            refusals: Vec::new(),
553            skipped: Vec::new(),
554            texts: HashMap::new(),
555            generations: HashMap::new(),
556            reserved: HashMap::new(),
557        }
558    }
559
560    fn directory(&self, reason: String) -> Refusal {
561        Refusal::new(self.name.clone(), ByteSpan::at(0), DiagCode::Io, reason)
562    }
563
564    /// A miss counts as settled: a builtin name every node calls would otherwise cost the
565    /// source one failed read per referencing node.
566    fn holds(&self, path: &str) -> bool {
567        self.nodes.contains_key(path)
568            || self.spans.contains_key(path)
569            || self.missing.contains(path)
570    }
571
572    /// `false` where nothing was added: the source does not hold this path, or its text is not
573    /// a node — and the second is already recorded as its own located refusal.
574    fn pull(&mut self, source: &dyn Source, path: &str) -> bool {
575        let file = format!("{path}{EXTENSION}");
576        let read = |at: &str| {
577            source
578                .get(at)
579                .map(|text| text.map(|text| text.into_owned()))
580        };
581        let (text, score) = match (read(path), read(&file)) {
582            (Ok(Some(_)), Ok(Some(_))) => {
583                self.refusals.push(
584                    Refusal::new(
585                        file.as_str(),
586                        ByteSpan::at(0),
587                        DiagCode::BadScore,
588                        format!(
589                            "line 1, column 1: `@{path}` matches two files, `{path}` and \
590                             `{file}`; a ref names one file, so rename or remove one of them"
591                        ),
592                    )
593                    .placed((1, 1)),
594                );
595                self.spans.insert(path.to_string(), None);
596                return false;
597            }
598            (Ok(Some(text)), Ok(None)) => (text, false),
599            (Ok(None), Ok(Some(text))) => (text, true),
600            (Ok(None), Ok(None)) => {
601                self.missing.insert(path.to_string());
602                return false;
603            }
604            (Err(reason), _) | (_, Err(reason)) => {
605                self.refusals
606                    .push(Refusal::new(path, ByteSpan::at(0), DiagCode::Io, reason));
607                self.spans.insert(path.to_string(), None);
608                return false;
609            }
610        };
611        let (_, span) = crate::filename::parse_filename(base_name(path));
612        self.spans.insert(path.to_string(), span);
613        self.texts.insert(path.to_string(), text.clone());
614        let held = if score { file.as_str() } else { path };
615        if let Some(at) = generation(source, path) {
616            self.generations.insert(path.to_string(), at);
617        }
618        let parsed = match score {
619            true => parse_score_file(path, &text),
620            false => parse_file(&text).and_then(|parsed| recorded(parsed, source)),
621        };
622        match parsed {
623            Ok(parsed) => {
624                let reads = crate::reserved::parsed_reads(&parsed, span);
625                if !reads.is_empty() {
626                    self.reserved.insert(path.to_string(), reads);
627                }
628                self.nodes.insert(path.to_string(), parsed.expr);
629                if !parsed.defaults.is_empty() {
630                    self.defaults.insert(path.to_string(), parsed.defaults);
631                }
632                if let Some(data) = parsed.data {
633                    self.data.insert(path.to_string(), data);
634                }
635                true
636            }
637            Err(d) => {
638                let refusal = Refusal::new(held, d.span, d.code, d.message);
639                self.refusals.push(match score {
640                    true => refusal.placed(crate::score::line_column(&text, d.span.start)),
641                    false => refusal,
642                });
643                false
644            }
645        }
646    }
647
648    fn reads(&self, path: &str) -> Vec<String> {
649        let mut out = match self.nodes.get(path) {
650            Some(expr) => reads_of(path, expr),
651            None => Vec::new(),
652        };
653        let reserved = self.reserved.get(path).into_iter().flatten();
654        out.extend(reserved.map(|name| variable(name)));
655        out
656    }
657
658    /// A refusal in a score names its file, line and column.
659    fn on_its_line(&self, refusal: Refusal) -> Refusal {
660        let path = refusal.at.path.as_str();
661        match (self.data.get(path), self.texts.get(path)) {
662            (Some(Data::Score(_)), Some(text)) => crate::score::refused(
663                format!("{path}{EXTENSION}"),
664                text,
665                refusal.at.span.start,
666                crate::diag::DiagCode::BadScore,
667                &refusal.reason,
668            ),
669            _ => refusal,
670        }
671    }
672
673    /// Every ref resolving here or in `base`.
674    fn finish(self, base: Option<&Graph>) -> Result<Graph, Vec<Refusal>> {
675        if !self.refusals.is_empty() {
676            return Err(self.refusals);
677        }
678        let held =
679            |path: &str| self.nodes.contains_key(path) || base.is_some_and(|b| b.defines(path));
680        let is_score = |path: &str| matches!(self.data.get(path), Some(Data::Score(_)));
681        let dangling: Vec<Refusal> = check_refs((&self.nodes, &held), &self.texts, &self.name)
682            .into_iter()
683            .filter(|r| !(is_score(&r.at.path) && r.reason.contains(&format!("`{VARIABLES}/"))))
684            .collect();
685        if !dangling.is_empty() {
686            return Err(dangling.into_iter().map(|r| self.on_its_line(r)).collect());
687        }
688        let cycles = check_cycles(&self.nodes);
689        if !cycles.is_empty() {
690            let placed = |r: Refusal| match is_score(&r.at.path) {
691                true => Refusal {
692                    reason: format!(
693                        "a score places itself, directly or through others: `{}` places what \
694                         places it back ({})",
695                        r.at.path, r.reason
696                    ),
697                    ..r
698                },
699                false => r,
700            };
701            return Err(cycles
702                .into_iter()
703                .map(|r| self.on_its_line(placed(r)))
704                .collect());
705        }
706        let mut defaults = self.defaults;
707        let nodes = self.nodes.into_iter().map(|(path, body)| {
708            let defaults = defaults.remove(&path).unwrap_or_default();
709            (path, Arc::new(Defined { body, defaults }))
710        });
711        Ok(Graph {
712            nodes: nodes.collect(),
713            data: self.data,
714            spans: self.spans,
715            reserved: self.reserved,
716            lowered: HashMap::new(),
717            skipped: self.skipped,
718            texts: self.texts,
719            generations: self.generations,
720            origins: HashMap::new(),
721        })
722    }
723}
724
725/// Resolves against the REFERENCING file's own directory, `./` and a bare path alike. A `.`
726/// or `..` normalizes anywhere, so `@a/x/../b` and `@a/b` are one node. `None` above the root,
727/// and for an absolute path, which only a command line resolves.
728pub fn resolve_ref_path(referencing: &str, ref_path: &str) -> Option<String> {
729    if ref_path.starts_with('/') {
730        return None;
731    }
732    let dir = match referencing.rsplit_once('/') {
733        Some((dir, _)) => dir,
734        None => "",
735    };
736    let mut segments: Vec<&str> = dir.split('/').filter(|s| !s.is_empty()).collect();
737    for segment in ref_path.split('/') {
738        match segment {
739            "." => {}
740            ".." => {
741                segments.pop()?;
742            }
743            other => segments.push(other),
744        }
745    }
746    Some(segments.join("/"))
747}
748
749fn above_root(referencing: &str, span: ByteSpan, ref_path: &str, root: &str) -> Refusal {
750    Refusal::new(
751        referencing,
752        span,
753        DiagCode::RefAboveRoot,
754        format!(
755            "`@{ref_path}` in `{referencing}` walks above the composition root `{root}`; a \
756             ref may reach anywhere inside the root and nowhere outside it"
757        ),
758    )
759}
760
761/// One `@path(...)` read, and whether it reads at the referencing node's own `t`.
762struct RefSite {
763    path: String,
764    span: ByteSpan,
765    at_t: bool,
766}
767
768fn collect_refs(e: &Expr, out: &mut Vec<RefSite>) {
769    if let Expr::Ref {
770        path, arg, span, ..
771    } = e
772    {
773        out.push(RefSite {
774            path: path.clone(),
775            span: *span,
776            at_t: **arg == Expr::Var("t".to_string()),
777        });
778    }
779    for c in children(e, Binds::Substitute) {
780        collect_refs(c, out);
781    }
782}
783
784/// Every path this expression names, `@ref` or bareword call alike.
785fn collect_named(e: &Expr, out: &mut Vec<String>) {
786    match e {
787        Expr::Call { name, .. } => out.push(name.clone()),
788        Expr::Ref { path, .. } => out.push(path.clone()),
789        _ => {}
790    }
791    for c in children(e, Binds::Substitute) {
792        collect_named(c, out);
793    }
794}
795
796type Holds<'a> = &'a dyn Fn(&str) -> bool;
797
798fn check_refs(
799    (nodes, held): (&BTreeMap<String, Expr>, Holds),
800    texts: &HashMap<String, String>,
801    root: &str,
802) -> Vec<Refusal> {
803    let mut refusals = Vec::new();
804    for (path, expr) in nodes {
805        let mut refs = Vec::new();
806        collect_refs(expr, &mut refs);
807        for site in refs {
808            let Some(target) = resolve_ref_path(path, &site.path) else {
809                refusals.push(above_root(path, site.span, &site.path, root));
810                continue;
811            };
812            if !held(&target) {
813                let hint = texts
814                    .get(path)
815                    .and_then(|text| digit_segment_hint(text, site.span.end))
816                    .map(|seg| {
817                        format!(
818                            "; a path segment cannot begin with a digit, so `{seg}` was not \
819                             read as part of this reference"
820                        )
821                    })
822                    .unwrap_or_default();
823                refusals.push(Refusal::new(
824                    path.clone(),
825                    site.span,
826                    DiagCode::DanglingRef,
827                    format!(
828                        "`@{}` does not resolve to any file (looked for `{target}`){hint}",
829                        site.path
830                    ),
831                ));
832            }
833        }
834    }
835    refusals
836}
837
838/// `end` is where the lexer's digit-segment rule stopped: on a `/` whose next byte is a digit.
839/// Re-inspecting the source there recovers the segment the path lost, e.g. `/1-raw`.
840fn digit_segment_hint(text: &str, end: usize) -> Option<String> {
841    let b = text.as_bytes();
842    if b.get(end) != Some(&b'/') || !b.get(end + 1).is_some_and(u8::is_ascii_digit) {
843        return None;
844    }
845    let mut j = end + 1;
846    while j < b.len() && (b[j].is_ascii_alphanumeric() || matches!(b[j], b'_' | b'-')) {
847        j += 1;
848    }
849    Some(text[end..j].to_string())
850}
851
852#[derive(Clone, Copy, PartialEq)]
853enum Color {
854    White,
855    Gray,
856    Black,
857}
858
859/// A node's resolved, in-graph edges (dangling refs are already refused before this runs).
860fn resolved_refs<'a>(node: &str, nodes: &'a BTreeMap<String, Expr>) -> Vec<Edge<'a>> {
861    let mut raw = Vec::new();
862    if let Some(expr) = nodes.get(node) {
863        collect_refs(expr, &mut raw);
864    }
865    raw.into_iter()
866        .filter_map(|site| {
867            let target = resolve_ref_path(node, &site.path)?;
868            nodes.get_key_value(&target).map(|(k, _)| Edge {
869                target: k.as_str(),
870                span: site.span,
871                at_t: site.at_t,
872            })
873        })
874        .collect()
875}
876
877#[derive(Clone, Copy)]
878struct Edge<'a> {
879    target: &'a str,
880    span: ByteSpan,
881    at_t: bool,
882}
883
884struct Frame<'a> {
885    node: &'a str,
886    refs: Vec<Edge<'a>>,
887    idx: usize,
888}
889
890/// An explicit-stack DFS: a hostile, arbitrarily long acyclic ref chain walks in a heap-sized
891/// `Vec`, never the OS call stack. It walks ONLY the reads written at bare `t`, so a cycle in
892/// it carries no delay at any sample rate; one holding a shifted read is the engine's to settle.
893fn check_cycles(nodes: &BTreeMap<String, Expr>) -> Vec<Refusal> {
894    let mut color: HashMap<&str, Color> =
895        nodes.keys().map(|k| (k.as_str(), Color::White)).collect();
896    let mut refusals = Vec::new();
897    let undelayed = |node: &str| -> Vec<Edge<'_>> {
898        resolved_refs(node, nodes)
899            .into_iter()
900            .filter(|e| e.at_t)
901            .collect()
902    };
903
904    for start in nodes.keys().map(String::as_str) {
905        if color[start] != Color::White {
906            continue;
907        }
908        color.insert(start, Color::Gray);
909        let mut stack = vec![Frame {
910            node: start,
911            refs: undelayed(start),
912            idx: 0,
913        }];
914
915        while let Some(frame) = stack.last_mut() {
916            if frame.idx >= frame.refs.len() {
917                color.insert(frame.node, Color::Black);
918                stack.pop();
919                continue;
920            }
921            let edge = frame.refs[frame.idx];
922            let from = frame.node;
923            frame.idx += 1;
924            match color.get(edge.target) {
925                Some(Color::Gray) => refusals.push(Refusal::new(
926                    from,
927                    edge.span,
928                    DiagCode::RefCycle,
929                    format!(
930                        "a delay-free ref cycle: `{from}` reads `@{}` at its own `t`, and the \
931                         reads leading back to `{from}` are all at `t` too, so this value is its \
932                         own argument. A loop is well-founded only when some read on it reaches \
933                         strictly into the past",
934                        edge.target
935                    ),
936                )),
937                Some(Color::White) => {
938                    color.insert(edge.target, Color::Gray);
939                    stack.push(Frame {
940                        node: edge.target,
941                        refs: undelayed(edge.target),
942                        idx: 0,
943                    });
944                }
945                _ => {}
946            }
947        }
948    }
949    refusals
950}
951
952#[cfg(test)]
953mod tests {
954    use super::*;
955
956    /// The graph's own semantics have nothing to do with a filesystem, so they are stated over
957    /// the in-memory form and a directory is tested where it is read (`fixtures.rs`).
958    fn of(files: &[(&str, &str)]) -> crate::source::Composition {
959        files.iter().copied().collect()
960    }
961
962    /// A source that counts what was asked of it: the pull property is not observable from the
963    /// graph, only from what the source was never opened for.
964    struct Counting {
965        held: crate::source::Composition,
966        asked: std::cell::RefCell<Vec<String>>,
967    }
968
969    impl Source for Counting {
970        fn paths(&self) -> Result<crate::source::Listing, String> {
971            panic!("a pull load must never enumerate the source")
972        }
973        fn get(&self, path: &str) -> Result<Option<std::borrow::Cow<'_, str>>, String> {
974            self.asked.borrow_mut().push(path.to_string());
975            self.held.get(path)
976        }
977    }
978
979    #[test]
980    fn a_pull_load_never_asks_for_a_node_the_roots_do_not_reach() {
981        let source = Counting {
982            held: of(&[
983                (
984                    "song",
985                    "sin(2*pi*t)*0.1 + @drums/kick*0.5 + gain(@drums/kick, k=2)\n",
986                ),
987                ("drums/kick", "sin(2*pi*50*t)\n"),
988                ("gain", "x*k*sin(2*pi*t)\n"),
989                ("zombie", "@nowhere\n"),
990            ]),
991            asked: std::cell::RefCell::new(Vec::new()),
992        };
993        let g = load_reaching(&source, &["song"]).expect("the reached set is whole");
994        let mut reached: Vec<&str> = g.paths().collect();
995        reached.sort();
996        assert_eq!(reached, ["drums/kick", "gain", "song"]);
997        let asked = source.asked.borrow().clone();
998        assert!(
999            !asked.contains(&"zombie".to_string()),
1000            "an unreferenced node was read: {asked:?}"
1001        );
1002        assert!(
1003            asked.contains(&"gain".to_string()),
1004            "a bareword invocation names a file too: {asked:?}"
1005        );
1006        let mut once = asked.clone();
1007        once.sort();
1008        once.dedup();
1009        assert_eq!(
1010            once.len(),
1011            asked.len(),
1012            "`sin` is called from two root nodes and must cost one read: {asked:?}"
1013        );
1014    }
1015
1016    /// Nodes arriving one at a time is the browser's shape, and a set still short of whole is
1017    /// the dangling-ref refusal it already was, not a new failure mode.
1018    #[test]
1019    fn a_composition_built_node_by_node_refuses_until_the_reached_set_is_whole() {
1020        let mut c = crate::source::Composition::new();
1021        c.insert("song", "@drums/kick*0.5\n");
1022        let errs = load_reaching(&c, &["song"]).unwrap_err();
1023        assert!(errs.iter().any(|r| r.code == DiagCode::DanglingRef));
1024
1025        c.insert("drums/kick", "sin(2*pi*50*t)\n");
1026        let g = load_reaching(&c, &["song"]).expect("the last node arrived");
1027        assert!(g.expr("drums/kick").is_some());
1028    }
1029
1030    /// The lexer's digit-segment rule truncates `@../growl/1-raw` to `@../growl`, and the
1031    /// dangling ref must say so, not just "looked for `growl`".
1032    #[test]
1033    fn a_dangling_ref_truncated_by_the_digit_segment_rule_names_what_fell_off() {
1034        let g = of(&[
1035            ("tracks/melody", "@../growl/1-raw\n"),
1036            ("growl/other", "0.2\n"),
1037        ]);
1038        let errs = load(&g).unwrap_err();
1039        let err = errs
1040            .iter()
1041            .find(|r| r.code == DiagCode::DanglingRef)
1042            .expect("the truncated ref must dangle");
1043        assert!(err.reason.contains("looked for `growl`"), "{}", err.reason);
1044        assert!(
1045            err.reason.contains("digit") && err.reason.contains("/1-raw"),
1046            "must explain the truncation, not just report `growl`: {}",
1047            err.reason
1048        );
1049    }
1050
1051    #[test]
1052    fn cross_file_refs_resolve() {
1053        let files: Vec<(&str, &str)> = vec![("kick", "sin(2*pi*50*t)\n"), ("lead", "@kick*0.5\n")];
1054        let g = load(&of(&files)).unwrap();
1055        assert!(g.expr("kick").is_some());
1056        assert!(g.defines("lead"));
1057    }
1058
1059    #[test]
1060    fn same_file_cycle_is_refused() {
1061        let files: Vec<(&str, &str)> = vec![("a", "@a + 1\n")];
1062        let errs = load(&of(&files)).unwrap_err();
1063        assert!(errs.iter().any(|r| r.code == DiagCode::RefCycle));
1064    }
1065
1066    #[test]
1067    fn a_node_merely_referencing_a_cycle_member_is_not_itself_flagged() {
1068        let files: Vec<(&str, &str)> = vec![("a", "@b\n"), ("b", "@a\n"), ("c", "@b\n")];
1069        let errs = load(&of(&files)).unwrap_err();
1070        assert!(errs.iter().any(|r| r.code == DiagCode::RefCycle));
1071        assert!(
1072            !errs.iter().any(|r| r.at.path == "c"),
1073            "c only reaches into the cycle, it does not loop back to itself: {errs:?}"
1074        );
1075    }
1076
1077    /// The relaxation this file owns: a loop with a shifted read on it is left for the engine,
1078    /// which alone knows how many samples that shift is worth.
1079    #[test]
1080    fn a_mutual_cycle_with_a_shifted_read_on_it_parses() {
1081        let files: Vec<(&str, &str)> =
1082            vec![("a", "@b(t - 0.01s)*0.5\n"), ("b", "@a*0.5 + sin(t)\n")];
1083        let g = load(&of(&files)).unwrap();
1084        assert!(g.expr("a").is_some() && g.expr("b").is_some());
1085
1086        let files: Vec<(&str, &str)> = vec![("ring", "@ring(t - 0.002s)*0.9 + sin(t)\n")];
1087        assert!(load(&of(&files)).is_ok());
1088    }
1089
1090    /// And the half that still refuses, located, whatever the sample rate turns out to be.
1091    #[test]
1092    fn a_cycle_read_only_at_bare_t_is_refused_where_the_read_is_written() {
1093        let files: Vec<(&str, &str)> =
1094            vec![("a", "@b(t - 0.01s)\n"), ("b", "@c\n"), ("c", "@b + 1\n")];
1095        let errs = load(&of(&files)).unwrap_err();
1096        let r = errs
1097            .iter()
1098            .find(|r| r.code == DiagCode::RefCycle)
1099            .expect("b <-> c carries no delay");
1100        assert!(r.at.path == "b" || r.at.path == "c", "{:?}", r.at.path);
1101        assert!(r.at.span.end > r.at.span.start);
1102    }
1103
1104    /// Reaching a node down a delayed leg first must not settle it: only the undelayed reads
1105    /// are a graph here, and `a -> c -> d -> a` is a loop in it whichever way `d` was found.
1106    #[test]
1107    fn a_delay_free_leg_is_found_even_when_a_delayed_one_reaches_it_first() {
1108        let mut files: Vec<(&str, &str)> = vec![
1109            ("a", "@b + @c\n"),
1110            ("b", "@d(t - 0.01s)\n"),
1111            ("c", "@d\n"),
1112            ("d", "@a\n"),
1113        ];
1114        let errs = load(&of(&files)).unwrap_err();
1115        assert!(
1116            errs.iter().any(|r| r.code == DiagCode::RefCycle),
1117            "a -> c -> d -> a is delay-free: {errs:?}"
1118        );
1119
1120        files.push(("c", "@d(t - 0.01s)\n"));
1121        assert!(
1122            load(&of(&files)).is_ok(),
1123            "every leg now carries a shift, so the engine sizes it"
1124        );
1125    }
1126
1127    #[test]
1128    fn deep_ref_chain_cycle_is_refused() {
1129        let files: Vec<(&str, &str)> =
1130            vec![("a", "@b\n"), ("b", "@c\n"), ("c", "@d\n"), ("d", "@a\n")];
1131        let errs = load(&of(&files)).unwrap_err();
1132        assert!(errs.iter().any(|r| r.code == DiagCode::RefCycle));
1133    }
1134
1135    /// A parameter list does not make a self-reference well-founded: an invocation is still a
1136    /// ref, and a ref cycle is still a cycle. `self(...)` stays the one exemption.
1137    #[test]
1138    fn a_function_invoking_itself_is_refused_but_self_inside_one_is_not() {
1139        let files: Vec<(&str, &str)> = vec![("f", "x + @f(t, x=x*0.5)\n")];
1140        let errs = load(&of(&files)).unwrap_err();
1141        assert!(errs.iter().any(|r| r.code == DiagCode::RefCycle));
1142
1143        let files: Vec<(&str, &str)> = vec![("a", "@b(t, x=x)\n"), ("b", "@a(t, x=x)\n")];
1144        let errs = load(&of(&files)).unwrap_err();
1145        assert!(errs.iter().any(|r| r.code == DiagCode::RefCycle));
1146
1147        let files: Vec<(&str, &str)> = vec![("comb", "x + g*self(t - delay)\n")];
1148        assert!(load(&of(&files)).is_ok());
1149    }
1150
1151    #[test]
1152    fn self_ref_is_not_flagged_a_cycle() {
1153        let files: Vec<(&str, &str)> = vec![
1154            ("filt", "0.3*@dry(t) + 0.7*self(t - 1sp)\n"),
1155            ("dry", "sin(2*pi*220*t)\n"),
1156        ];
1157        let g = load(&of(&files)).unwrap();
1158        assert!(g.expr("filt").is_some());
1159    }
1160
1161    #[test]
1162    fn two_graphs_over_the_same_text_hold_structurally_equal_expressions() {
1163        let mut files_a: Vec<(&str, &str)> = Vec::new();
1164        let mut files_b: Vec<(&str, &str)> = Vec::new();
1165        files_a.push(("kick", "sin(2*pi*50*t)\n"));
1166        files_b.push(("kick", "sin(2*pi*50*t)\n"));
1167        let ga = load(&of(&files_a)).unwrap();
1168        let gb = load(&of(&files_b)).unwrap();
1169
1170        assert_eq!(ga.expr("kick"), gb.expr("kick"));
1171    }
1172
1173    #[test]
1174    fn a_ref_resolves_relative_to_its_own_files_directory() {
1175        let files: Vec<(&str, &str)> = vec![
1176            ("drums/kick", "sin(2*pi*50*t)\n"),
1177            ("drums/pattern-1b", "@kick\n"),
1178        ];
1179        let g = load(&of(&files)).unwrap();
1180        assert!(g.expr("drums/pattern-1b").is_some());
1181    }
1182
1183    /// A bare path is exactly what it always was; `./` spells that out and `../` walks up.
1184    #[test]
1185    fn a_dot_segment_resolves_against_the_referencing_files_own_directory() {
1186        let at = |file: &str, path: &str| resolve_ref_path(file, path).unwrap();
1187        assert_eq!(at("lead", "kick"), "kick");
1188        assert_eq!(at("piano/bench", "partials"), "piano/partials");
1189        assert_eq!(at("piano/bench", "./partials"), "piano/partials");
1190        assert_eq!(at("piano/bench", "../variables/bpm"), "variables/bpm");
1191        assert_eq!(at("a/b/c/d", "../../x"), "a/x");
1192        assert_eq!(at("song", "./drums/kick"), "drums/kick");
1193    }
1194
1195    /// One rule wherever `..` appears, so two spellings of a node are one node.
1196    #[test]
1197    fn a_dot_segment_normalizes_in_the_middle_of_a_path_too() {
1198        let at = |file: &str, path: &str| resolve_ref_path(file, path).unwrap();
1199        assert_eq!(at("song", "a/x/../b"), at("song", "a/b"));
1200        assert_eq!(at("song", "a/./b"), "a/b");
1201        assert_eq!(at("drums/bus", "../lead/./pad/../pad"), "lead/pad");
1202    }
1203
1204    #[test]
1205    fn a_walk_above_the_composition_root_resolves_to_nothing() {
1206        assert_eq!(resolve_ref_path("piano/bench", "../../x"), None);
1207        assert_eq!(resolve_ref_path("song", "../x"), None);
1208        assert_eq!(resolve_ref_path("a/b", "../../../x"), None);
1209    }
1210
1211    #[test]
1212    fn a_ref_reaching_a_sibling_directory_resolves_and_renders_as_one_graph() {
1213        let files: Vec<(&str, &str)> = vec![
1214            ("variables/bpm", "120\n"),
1215            ("piano/partials", "sin(2*pi*440*t)\n"),
1216            ("piano/bench", "@./partials*0.5 + @../variables/bpm*0\n"),
1217            ("song", "@piano/bench\n"),
1218        ];
1219        let g = load(&of(&files)).unwrap();
1220        assert!(g.expr("piano/bench").is_some());
1221    }
1222
1223    #[test]
1224    fn a_ref_walking_above_the_root_is_a_located_refusal_naming_it() {
1225        let files: Vec<(&str, &str)> = vec![("piano/bench", "@../../secrets\n")];
1226        let errs = load(&of(&files).named("./song1")).unwrap_err();
1227        let r = errs
1228            .iter()
1229            .find(|r| r.code == DiagCode::RefAboveRoot)
1230            .expect("walking above the root must refuse");
1231        assert_eq!(r.at.path, "piano/bench");
1232        assert!(r.reason.contains("@../../secrets"), "{}", r.reason);
1233        assert!(r.reason.contains("./song1"), "{}", r.reason);
1234    }
1235}