Skip to main content

sva_core/
lib.rs

1// Concern: the parse->tempo->render pipeline shared by both front ends | Non-concern: argv (sva-cli), JS bindings (sva-wasm) | IO: (a Source, a target) -> Rendered, a Stream or CliError
2
3mod analyze;
4mod answer;
5mod builtins;
6mod cli_error;
7mod decode;
8pub mod encode;
9pub mod json;
10mod lint_code;
11mod outline;
12mod output;
13mod query;
14mod set;
15pub mod solve;
16mod song;
17mod target;
18mod until;
19
20pub use analyze::{analyzable, analyzed};
21pub use answer::{
22    Played, Printed, Report, SAMPLE_LIMIT, answer_json, counters_json, engine_stats_json,
23    label_json, query_data, resampled_json, stats_json, stream_stats_json, value_json, work_json,
24};
25pub use builtins::{Builtins, Callable, Crossing, builtins, builtins_data};
26pub use cli_error::{CliError, LintViolation, lint_diagnostic, warning_diagnostic};
27pub use lint_code::LintCode;
28pub use outline::{events_data, outline_data, song_data};
29pub use output::{
30    Diagnostic, Severity, diagnostics_json, error_envelope, error_envelope_with, success_envelope,
31};
32pub use query::{
33    Asked, Call, Destination, RETIRED, Spelled, Taken, asked, asked_all, call, calls, is_wav,
34    retired, seconds, wav_path,
35};
36pub use set::{overlaid, set};
37pub use song::{as_lane, sung};
38pub use sva_ast::RESERVED_VARIABLES;
39pub use target::{Edge, Target, target};
40pub use until::until;
41
42use std::cell::RefCell;
43use std::num::NonZeroUsize;
44use std::path::Path;
45
46use sva_ast::song::Song;
47use sva_ast::{Dir, Graph, Refusal, Source};
48use sva_engine::{
49    Ask, Change, Changed, DEFAULT_SAMPLE_RATE, EngineError, Kind, PSYCHOACOUSTIC_V1, Phase, Range,
50    RenderConfig, StreamConfig, render, render_called,
51};
52
53pub use sva_engine::{Abandon, Handle, Hear, Never, Out, Placed, Session, Stream, Until};
54
55pub use sva_engine::{Answer, Extent, Label, Output, Representation};
56pub use sva_engine::{
57    Backend, CacheStats, Counters, DEFAULT_CACHE_BYTES, DEFAULT_STORE_BYTES, EngineStats,
58    FETCH_READS, Nothing, Persisted, Store, Tier,
59};
60
61pub const ROOT: &str = "master";
62pub const PROBE: &str = "probe";
63
64pub struct Rendered {
65    pub config: RenderConfig,
66    /// What the song's files earn: each prints, and the render goes on.
67    pub warnings: Vec<sva_ast::song::Warning>,
68    pub expression: String,
69    pub target: String,
70    pub render: sva_engine::Render,
71    /// So a structural check needs no second parse, and an ad-hoc target is the same `probe`.
72    pub graph: Graph,
73}
74
75impl Rendered {
76    pub fn answer(&self, node: &str, representation: Representation) -> Result<Answer, CliError> {
77        let written = |e| CliError::Engine(as_written(e, &self.expression));
78        let id = self.render.node(node).map_err(written)?;
79        let mut answer = sva_engine::answer(&self.render, id, representation).map_err(written)?;
80        self.attribute(&mut answer)?;
81        Ok(answer)
82    }
83
84    /// What a bar lasts in this render's tempo; `None` where it has none.
85    pub fn seconds_per_bar(&self) -> Option<f64> {
86        self.graph.tempo().map(|tempo| tempo.seconds_per_bar())
87    }
88
89    /// `asked`'s reading, its bars in this render's tempo.
90    pub fn answered(&self, asked: &Asked) -> Result<Answer, CliError> {
91        let representation = asked.representation(self.config.rate, self.seconds_per_bar())?;
92        self.answer(
93            asked.node.as_deref().unwrap_or(&self.target),
94            representation,
95        )
96    }
97
98    /// An alias score reads beside how many instances the render holds.
99    fn attribute(&self, answer: &mut Answer) -> Result<(), CliError> {
100        let Output::Alias(alias) = &mut answer.value else {
101            return Ok(());
102        };
103        alias.instances = self.render.tys.paths().count();
104        Ok(())
105    }
106
107    pub fn label(&self) -> Option<&sva_engine::Label> {
108        self.render.labels.get(&self.render.root)
109    }
110}
111
112/// One render: `target` an expression over the composition `source` holds, its own ref
113/// read over an interval where it writes one.
114pub struct Job<'a> {
115    pub source: &'a dyn Source,
116    pub target: &'a str,
117    /// `None` ends the render where the interval or the target's support ends.
118    pub until: Option<&'a str>,
119    pub rate: Option<u32>,
120    pub bits: Option<i32>,
121    pub asked: &'a [Asked],
122    pub volatile: &'a [String],
123    pub out: Out,
124    pub abandon: &'a dyn Abandon,
125    pub hear: Option<&'a dyn Hear>,
126    /// The most threads it computes on; `None` takes every core the platform reports.
127    pub threads: Option<NonZeroUsize>,
128}
129
130impl<'a> Job<'a> {
131    pub fn over(source: &'a dyn Source, target: &'a str) -> Job<'a> {
132        Job {
133            source,
134            target,
135            until: None,
136            rate: None,
137            bits: None,
138            asked: &[],
139            volatile: &[],
140            out: Out::Kept,
141            abandon: &Never,
142            hear: None,
143            threads: None,
144        }
145    }
146}
147
148fn settle(job: &Job) -> Result<(Graph, RenderConfig, Song), CliError> {
149    let Target { expr, interval } = target(job.target)?;
150    let roots = roots_of(job.source, &expr)?;
151    let hinted = |e| solve::hinted(e, job.source, Some(job.target));
152    let mut graph = settled(sva_ast::load_reaching(
153        job.source,
154        &roots.iter().map(String::as_str).collect::<Vec<_>>(),
155    ))
156    .map_err(hinted)?;
157    let parsed = sva_ast::parse_expr(&expr)
158        .map_err(|d| {
159            CliError::BadProbe(format!(
160                "`{}` does not parse as an expression: {}",
161                job.target, d.message
162            ))
163        })
164        .map_err(hinted)?;
165    let parsed = sva_ast::resolve_recordings(&parsed, job.source)
166        .map_err(|unheld| CliError::NotFound(unheld.message()))?;
167    define_probe(&mut graph, parsed)?;
168    let rate = job.rate.unwrap_or(DEFAULT_SAMPLE_RATE);
169    let per_bar = graph.tempo().map(|tempo| tempo.seconds_per_bar());
170    let range = match interval {
171        None => Range::default(),
172        Some((start, end)) => Range {
173            start: start.sample(rate, per_bar)?,
174            end: end.sample(rate, per_bar)?,
175        },
176    };
177    if let Range {
178        start: Some(start),
179        end: Some(end),
180    } = range
181        && end <= start
182    {
183        return Err(CliError::usage(
184            "render.empty_interval",
185            format!(
186                "`{}` reads an interval from sample {start} to {end}, which holds none",
187                job.target
188            ),
189        ));
190    }
191    let until = match job.until {
192        Some(text) => Some(until(text, rate, per_bar)?),
193        None => None,
194    };
195    let mut config = RenderConfig {
196        range,
197        until,
198        ..RenderConfig::at(rate)
199    };
200    config.profile.precision_bits = precision_bits(job.bits)?;
201    config.volatile = job.volatile.to_vec();
202    config.out = job.out;
203    if let Some(threads) = job.threads {
204        config.threads = threads;
205    }
206    config.asks = job
207        .asked
208        .iter()
209        .map(|asked| {
210            Ok(Ask {
211                node: asked.node.clone().unwrap_or_else(|| PROBE.to_string()),
212                representation: asked.representation(rate, per_bar)?,
213            })
214        })
215        .collect::<Result<_, CliError>>()?;
216    let song = sung(&mut graph, PROBE, rate)?;
217    Ok((graph, config, song))
218}
219
220/// An engine refusal as the song names it where a moving lane reaches it, else as written.
221fn refused(song: &Song, graph: &Graph, e: EngineError, target: &str) -> CliError {
222    match as_lane(song, graph, e) {
223        CliError::Engine(e) => CliError::Engine(as_written(e, target)),
224        other => other,
225    }
226}
227
228/// `trace` names one instance of a parameterized file as `<path>(<name>=<value>, ..)`.
229fn instance_call(text: &str) -> Option<(&str, &str)> {
230    let (path, rest) = text.split_once('(')?;
231    let binds = rest.strip_suffix(')')?.trim();
232    (!binds.is_empty() && all_named(binds)).then_some((path, binds))
233}
234
235/// Only commas and equals outside a bind's own parens count: a bind's value may be a call.
236fn all_named(binds: &str) -> bool {
237    let mut depth = 0i32;
238    let mut named = false;
239    for c in binds.chars() {
240        match c {
241            '(' => depth += 1,
242            ')' if depth == 0 => return false,
243            ')' => depth -= 1,
244            '=' if depth == 0 => named = true,
245            ',' if depth == 0 && !std::mem::take(&mut named) => return false,
246            _ => {}
247        }
248    }
249    named && depth == 0
250}
251
252/// The ref an instance name stands for, so `lint` and `trace` answer for the same node.
253fn instance_read(graph: &Graph, text: &str) -> Option<sva_ast::Expr> {
254    let (path, binds) = instance_call(text)?;
255    if !graph.defines(path) {
256        return None;
257    }
258    sva_ast::parse_expr(&format!("@{path}(t, {binds})")).ok()
259}
260
261pub fn execute(job: Job, tier: &Tier) -> Result<Rendered, CliError> {
262    let (graph, config, song) = settle(&job)?;
263    let render = render(&graph, PROBE, config, tier);
264    rendered(&job, (graph, song), render)
265}
266
267/// `job` over `tier`, typing anew only what `session` typed otherwise; only `persist` commits
268/// what it offers a disk beneath.
269pub async fn execute_over<B: Backend>(
270    job: Job<'_>,
271    tier: &Tier<B>,
272    session: &mut Session,
273) -> Result<Rendered, CliError> {
274    let call = tier.call(Kind::Render, job.target, Phase::Parsing);
275    let (graph, config, song) = settle(&job)?;
276    let hooks = (job.abandon, job.hear);
277    let render = render_called(session, &graph, PROBE, config, tier, hooks, call).await;
278    rendered(&job, (graph, song), render)
279}
280
281fn rendered(
282    job: &Job,
283    (graph, song): (Graph, Song),
284    render: Result<sva_engine::Render, EngineError>,
285) -> Result<Rendered, CliError> {
286    let render = render.map_err(|e| refused(&song, &graph, e, job.target))?;
287    Ok(Rendered {
288        warnings: song.warnings().to_vec(),
289        config: render.config.clone(),
290        expression: job.target.to_string(),
291        target: PROBE.to_string(),
292        render,
293        graph,
294    })
295}
296
297/// The render a job would run, and the warnings its song earns.
298pub fn plan(job: &Job) -> Result<(sva_engine::Render, Vec<sva_ast::song::Warning>), CliError> {
299    let (planned, song, _) = planned(job)?;
300    Ok((planned, song.warnings().to_vec()))
301}
302
303/// The render a job would run, its song, and the seconds a bar of its tempo lasts.
304pub(crate) fn planned(job: &Job) -> Result<(sva_engine::Render, Song, Option<f64>), CliError> {
305    let (graph, config, song) = settle(job)?;
306    let per_bar = graph.tempo().map(|tempo| tempo.seconds_per_bar());
307    let planned = sva_engine::plan(&graph, PROBE, config);
308    let planned = planned.map_err(|e| refused(&song, &graph, e, job.target))?;
309    Ok((planned, song, per_bar))
310}
311
312/// Every refusal typing the target reaches, and no render's; the warnings its song earns.
313pub fn types(job: &Job) -> Result<Vec<sva_ast::song::Warning>, CliError> {
314    let (graph, config, song) = settle(job)?;
315    sva_engine::types_at(&graph, PROBE, config.rate)
316        .map_err(|e| refused(&song, &graph, e, job.target))?;
317    Ok(song.warnings().to_vec())
318}
319
320/// `root`'s song derived and laid over `graph`, as `outline` reads it.
321pub fn song_of(source: &dyn Source, target: &str) -> Result<(Graph, Song), CliError> {
322    let job = Job::over(source, target);
323    let (graph, _, song) = settle(&job)?;
324    Ok((graph, song))
325}
326
327/// `channels`: what it plays, the target's own where `None`.
328pub async fn stream<B: Backend>(
329    job: &Job<'_>,
330    (block, channels): (usize, Option<usize>),
331    tier: &Tier<B>,
332) -> Result<Stream, CliError> {
333    let call = tier.call(Kind::Stream, job.target, Phase::Parsing);
334    let (graph, config, _) = settle(job)?;
335    let target = graph
336        .expr(PROBE)
337        .cloned()
338        .expect("the target was defined as the probe");
339    let config = StreamConfig {
340        block,
341        channels,
342        render: config,
343    };
344    Stream::open_called(&graph, &target, config, tier, call)
345        .await
346        .map_err(|e| CliError::Engine(as_written(e, job.target)))
347}
348
349/// `target`, an expression over `source` with no interval of its own, in place of what
350/// `stream` plays. Each edit here is `sva_engine::change`'s: the stream plays on meanwhile.
351pub async fn edit<B: Backend>(
352    stream: &RefCell<Stream>,
353    source: &dyn Source,
354    target: &str,
355    tier: &Tier<B>,
356) -> Result<(), CliError> {
357    let build = |s: &Stream| {
358        let (graph, expr) = streamed(s, source, target, None)?;
359        Ok(Change::Target(graph, expr))
360    };
361    changed(stream, build, Some(target), tier).await.map(|_| ())
362}
363
364/// `term` summed into the stream's `@notes`, its sample 0 placed `at`.
365pub async fn add<B: Backend>(
366    stream: &RefCell<Stream>,
367    source: &dyn Source,
368    (term, at): (&str, Placed),
369    tier: &Tier<B>,
370) -> Result<Handle, CliError> {
371    let build = |s: &Stream| {
372        let (graph, expr) = streamed(s, source, term, Some(at))?;
373        Ok(Change::Add(graph, expr, at))
374    };
375    match changed(stream, build, Some(term), tier).await? {
376        Changed::Added(handle) => Ok(handle),
377        _ => unreachable!("an add answers its handle"),
378    }
379}
380
381/// False where the stream no longer holds `handle`.
382pub async fn replace<B: Backend>(
383    stream: &RefCell<Stream>,
384    source: &dyn Source,
385    (handle, term, at): (Handle, &str, Placed),
386    tier: &Tier<B>,
387) -> Result<bool, CliError> {
388    let build = |s: &Stream| {
389        let (graph, expr) = streamed(s, source, term, Some(at))?;
390        Ok(Change::Replace(handle, graph, expr, at))
391    };
392    Ok(changed(stream, build, Some(term), tier).await? == Changed::Held(true))
393}
394
395/// False where the stream no longer holds `handle`.
396pub async fn remove<B: Backend>(
397    stream: &RefCell<Stream>,
398    handle: Handle,
399    tier: &Tier<B>,
400) -> Result<bool, CliError> {
401    let build = |_: &Stream| Ok(Change::Remove(handle));
402    Ok(changed(stream, build, None, tier).await? == Changed::Held(true))
403}
404
405async fn changed<B: Backend>(
406    stream: &RefCell<Stream>,
407    build: impl FnMut(&Stream) -> Result<Change, CliError>,
408    text: Option<&str>,
409    tier: &Tier<B>,
410) -> Result<Changed, CliError> {
411    let changed = sva_engine::change(stream, build, tier).await;
412    changed.map_err(|e| match (e, text) {
413        (CliError::Engine(e), Some(text)) => CliError::Engine(as_written(e, text)),
414        (e, _) => e,
415    })
416}
417
418pub async fn fetch<B: Backend>(stream: &RefCell<Stream>, tier: &Tier<B>) {
419    sva_engine::fetch(stream, tier).await;
420}
421
422/// `text` with no interval, and each node it reads that `stream` does not hold, or holds at an
423/// earlier edit of `source`, read and parsed off `source`: an edit is a version the stream plays.
424fn streamed(
425    stream: &Stream,
426    source: &dyn Source,
427    text: &str,
428    term: Option<Placed>,
429) -> Result<(Graph, sva_ast::Expr), CliError> {
430    let Target { expr, interval } = target(text)?;
431    if interval.is_some() {
432        return Err(CliError::usage(
433            "stream.interval",
434            format!("`{text}` reads an interval, and a stream keeps its own"),
435        ));
436    }
437    let held = stream.graph();
438    let roots = roots_of(source, &expr)?;
439    let roots: Vec<&str> = roots.iter().map(String::as_str).collect();
440    let mut delta = sva_ast::load_beside(source, &roots, held).map_err(CliError::Refusals)?;
441    delta
442        .lower_reserved(Some(held))
443        .map_err(CliError::Refusals)?;
444    let parsed = sva_ast::parse_expr(&expr).map_err(|d| {
445        CliError::BadProbe(format!(
446            "`{text}` does not parse as an expression: {}",
447            d.message
448        ))
449    })?;
450    let mut view = held.sourced();
451    for path in delta.paths() {
452        view.set(path, delta.held(path));
453    }
454    let rate = stream.config().render.rate;
455    let lowered = match term {
456        None => {
457            view.set(PROBE, None);
458            define_probe(&mut view, parsed)?;
459            sung(&mut view, PROBE, rate)?;
460            view.expr(PROBE)
461                .cloned()
462                .expect("the target was defined as the probe")
463        }
464        Some(placed) => termed(&mut view, parsed, placed, rate)?,
465    };
466    let mut out = delta;
467    let changed: Vec<String> = view
468        .paths()
469        .filter(|path| !held.holds_as(path, &view) || !held.read_as(path, &view))
470        .map(str::to_string)
471        .collect();
472    for path in changed {
473        out.set(&path, view.held(&path));
474    }
475    Ok((out, lowered))
476}
477
478/// A stream term lowered beside the stream's song: its instances read the song's lanes at
479/// their position, and a term reaching a move of its own refuses, since lanes derive from the
480/// song's files.
481fn termed(
482    view: &mut Graph,
483    term: sva_ast::Expr,
484    placed: Placed,
485    rate: u32,
486) -> Result<sva_ast::Expr, CliError> {
487    const ALONE: &str = "#term";
488    const BESIDE: &str = "#song";
489    let mut alone = view.clone();
490    alone
491        .define_settled(None, ALONE, term)
492        .map_err(CliError::Refusals)?;
493    let written = alone.expr(ALONE).cloned().expect("just defined");
494    let own = alone.song(ALONE).map_err(CliError::Refusals)?;
495    if let Some(lane) = own.lanes().first() {
496        let first = &lane.moves[0].reached;
497        return Err(CliError::Refusals(vec![Refusal::new(
498            format!("{}{}", first.score, sva_ast::score::EXTENSION),
499            sva_ast::ByteSpan::at(0),
500            sva_ast::DiagCode::BadLane,
501            format!(
502                "a stream term reaches this move on `{}`; a term adds sound and never moves, \
503                 since lanes derive from the song's files: move it in one",
504                lane.id.name()
505            ),
506        )]));
507    }
508    let target = view.source_expr(PROBE).expect("a stream holds its target");
509    let both = sva_ast::Expr::Bin(
510        sva_ast::BinOp::Add,
511        Box::new(target),
512        Box::new(written.clone()),
513    );
514    view.define_settled(None, BESIDE, both)
515        .map_err(CliError::Refusals)?;
516    sung(view, BESIDE, rate)?;
517    let Some(sva_ast::Expr::Bin(_, _, lowered)) = view.expr(BESIDE).cloned() else {
518        unreachable!("a sum lowers to a sum")
519    };
520    view.set(BESIDE, None);
521    if placed == Placed::Landing && *lowered != written {
522        return Err(CliError::Refusals(vec![Refusal::new(
523            ALONE,
524            sva_ast::ByteSpan::at(0),
525            sva_ast::DiagCode::BadLane,
526            "this term lands where the stream is when it arrives, so it has no song time, and a \
527             lane of the song moves what it plays; a lane reaches an instance at its song \
528             position, so add it at a written time"
529                .to_string(),
530        )]));
531    }
532    Ok(*lowered)
533}
534
535/// The precision a render writes every sample to: `bits`, else the profile's own.
536pub fn precision_bits(bits: Option<i32>) -> Result<i32, CliError> {
537    bits.map_or(Ok(PSYCHOACOUSTIC_V1.precision_bits), precision)
538}
539
540/// A double holds no bit past its own mantissa, and one bit writes only zero.
541fn precision(bits: i32) -> Result<i32, CliError> {
542    match (2..=52).contains(&bits) {
543        true => Ok(bits),
544        false => Err(CliError::usage(
545            "cli.bad_value",
546            format!("`--bits` takes a precision from 2 to 52 bits, not {bits}"),
547        )),
548    }
549}
550
551/// The engine knows a target only as the node it was defined as, so a refusal that names
552/// that node names the target as the caller wrote it instead.
553fn as_written(refused: EngineError, target: &str) -> EngineError {
554    match refused {
555        EngineError::Refused(mut d) => {
556            if d.location.node == PROBE {
557                d.location.node = target.to_string();
558            }
559            d.message = d
560                .message
561                .replace(&format!("`{PROBE}`"), &format!("`{target}`"));
562            EngineError::Refused(d)
563        }
564        EngineError::Binding { node, span, fault } if node == PROBE => EngineError::Binding {
565            node: target.to_string(),
566            span,
567            fault,
568        },
569        other => other,
570    }
571}
572
573pub fn probe(dir: &Path, expression: &str) -> Result<Rendered, CliError> {
574    execute(Job::over(&Dir::at(dir), expression), &Tier::default())
575}
576
577pub fn cwd() -> Result<std::path::PathBuf, CliError> {
578    std::env::current_dir()
579        .map_err(|e| CliError::Io(format!("could not read the current directory: {e}")))
580}
581
582/// Every node `source` holds; a solve's `fits/` costs are no nodes, so the load never sees them.
583pub fn prepared(source: &dyn Source) -> Result<Graph, CliError> {
584    settled(sva_ast::load(&Unfitted(source))).map_err(|e| solve::hinted(e, source, None))
585}
586
587struct Unfitted<'a>(&'a dyn Source);
588
589impl Source for Unfitted<'_> {
590    fn paths(&self) -> Result<sva_ast::Listing, String> {
591        let fits = format!("{}/", solve::FITS);
592        let mut listing = self.0.paths()?;
593        listing.found.retain(|path| !path.starts_with(&fits));
594        listing.unreadable.retain(|path| !path.starts_with(&fits));
595        Ok(listing)
596    }
597
598    fn get(&self, path: &str) -> Result<Option<std::borrow::Cow<'_, str>>, String> {
599        self.0.get(path)
600    }
601
602    fn generation(&self, path: &str) -> Option<u64> {
603        self.0.generation(path)
604    }
605
606    fn name(&self) -> String {
607        self.0.name()
608    }
609
610    fn recording(&self, path: &str) -> Result<Option<std::sync::Arc<sva_ast::Recording>>, String> {
611        self.0.recording(path)
612    }
613}
614
615/// Argv math: `trace` and `lint` each refuse a target no node answers for here, under one
616/// code and one message.
617pub fn define_probe_for(graph: &mut Graph, text: &str) -> Result<(), CliError> {
618    let expr = match instance_read(graph, text) {
619        Some(read) => read,
620        None if names_a_missing_node(text) => {
621            return Err(CliError::NotFound(format!(
622                "`{text}` is not a node this composition defines"
623            )));
624        }
625        None => sva_ast::parse_expr(text).map_err(|d| {
626            CliError::BadProbe(format!(
627                "`{text}` is not a node in this composition, and does not parse as an \
628                 expression: {}",
629                d.message
630            ))
631        })?,
632    };
633    define_probe(graph, expr)
634}
635
636/// Only a path, or instance syntax, is a node; the rest is math the parser and engine answer
637/// for. `t` and `A4` are spelled like paths and read as values.
638fn names_a_missing_node(text: &str) -> bool {
639    match instance_call(text) {
640        Some((path, _)) => sva_ast::names_a_node(path),
641        None => !text.contains('(') && sva_ast::names_a_node(text) && !reads_as_a_value(text),
642    }
643}
644
645fn reads_as_a_value(text: &str) -> bool {
646    match sva_ast::parse_expr(text) {
647        Ok(sva_ast::Expr::Lit(_)) => true,
648        Ok(sva_ast::Expr::Var(name)) => sva_ast::is_reserved(&name),
649        _ => false,
650    }
651}
652
653/// Argv math settled the way a file is; `concat` is no file-only dialect.
654fn define_probe(graph: &mut Graph, expr: sva_ast::Expr) -> Result<(), CliError> {
655    let defined = graph
656        .define_settled(None, PROBE, expr)
657        .map_err(CliError::Refusals)?;
658    if !defined {
659        return Err(CliError::BadProbe(format!(
660            "this composition already has a node named `{PROBE}`"
661        )));
662    }
663    Ok(())
664}
665
666/// Loaded with its reserved variables lowered; a song lowers its scores once a root is named.
667pub fn settled(loaded: Result<Graph, Vec<Refusal>>) -> Result<Graph, CliError> {
668    let mut graph = loaded.map_err(CliError::Refusals)?;
669    graph.lower_reserved(None).map_err(CliError::Refusals)?;
670    Ok(graph)
671}
672
673/// A node path the source answers for is a node; anything else is math, and its reads are
674/// roots. Only a node path is asked of the source: math is never looked up as a file.
675pub fn roots_of(source: &dyn Source, target: &str) -> Result<Vec<String>, CliError> {
676    let mut roots: Vec<String> = RESERVED_VARIABLES.map(sva_ast::variable).to_vec();
677    let listed = source.paths().map_err(CliError::Io)?.found;
678    let variables = format!("{}/", sva_ast::VARIABLES);
679    roots.extend(listed.into_iter().filter(|p| p.starts_with(&variables)));
680    roots.sort();
681    roots.dedup();
682    let held = |path: &str| -> Result<bool, CliError> {
683        let file = format!("{path}{}", sva_ast::score::EXTENSION);
684        let get = |at: &str| {
685            source
686                .get(at)
687                .map(|text| text.is_some())
688                .map_err(CliError::Io)
689        };
690        Ok(sva_ast::whole_ref_path(path) && (get(path)? || get(&file)?))
691    };
692    match target {
693        target if held(target)? => {
694            roots.push(target.to_string());
695        }
696        text => match instance_call(text).map(|(path, _)| path) {
697            Some(path) if held(path)? => {
698                roots.push(path.to_string());
699            }
700            _ => {
701                if let Ok(expr) = sva_ast::parse_expr(text) {
702                    roots.extend(sva_ast::reads_of(PROBE, &expr));
703                }
704            }
705        },
706    }
707    Ok(roots)
708}