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