Skip to main content

proef_core/pack/
mod.rs

1//! Macro packs: the YAML binding skeleton with embedded raw payload blocks
2//! (ADR-0004, TECH-SPEC §6).
3//!
4//! Packs are parsed with `serde_norway` (`deny_unknown_fields` on the fixed
5//! schema; the *payload* key of a step — `hurl:`, or a future engine's kind — is
6//! dynamic and checked against the registered engines' [`StepKindSpec`]s in
7//! validation pass 8). Loading is pure: the CLI discovers files and hands
8//! [`PackSource`]s in; built-in packs are embedded at build time.
9
10pub(crate) mod locate;
11mod schema;
12mod validate;
13
14pub use schema::json_schema;
15
16use std::collections::BTreeMap;
17use std::sync::Arc;
18
19use serde::Deserialize;
20
21use crate::diag::{Diag, FrontError, Span};
22use crate::engine::StepKindSpec;
23use crate::step::Retry;
24
25/// One pack input: a name (path as authored, or `builtin:…`) plus its text.
26#[derive(Debug, Clone)]
27pub struct PackSource {
28    /// Display name (file path as authored, or `builtin:<name>`).
29    pub name: String,
30    /// The raw YAML text.
31    pub text: Arc<str>,
32}
33
34/// Every fragment file's text, scanned **at most once** however many times the
35/// packs around it are loaded (ADR-0018).
36///
37/// One `proef test` loads packs up to four times — the suite, then `[run] setup`
38/// and `[run] teardown`, each validated and then run — against different feature
39/// paths but always the *same* corpus. Rescanning per load measured ~75% of a
40/// run's total work on a 200-file corpus, and it grows with the corpus, which is
41/// the direction adoption goes.
42///
43/// The memo lives here rather than in the caller because the scan must stay
44/// **lazy**: `load_collecting` runs it only when some pack actually has a
45/// `ref:`, which is what makes CONFIG.md's "pointing at a corpus you did not
46/// write costs nothing" true of the scan. A caller that scanned eagerly in
47/// order to share the result would buy speed by breaking that promise. Nothing
48/// here reads a file — the texts arrive already read, so core stays sans-IO.
49#[derive(Debug)]
50pub struct FragmentCorpus {
51    sources: Vec<PackSource>,
52    /// Captured at construction so the memo cannot be filled under one set of
53    /// kinds and then read under another.
54    kinds: Vec<StepKindSpec>,
55    /// Files the caller could not read, already shaped as diagnostics.
56    ///
57    /// Held rather than raised at read time because a corpus is *foreign by
58    /// design*: one unreadable file — a binary, a latin-1 export — must not
59    /// take down commands that never look at fragments at all. They surface
60    /// through the same gate as scan diagnostics, so a suite with no `ref:`
61    /// stays silent and "pointing at a corpus you did not write costs nothing"
62    /// keeps meaning what it says.
63    read_errors: Vec<Diag>,
64    scanned: std::sync::OnceLock<Scanned>,
65}
66
67/// One scan's product: the named fragments, plus what was wrong with the files.
68#[derive(Debug, Default)]
69pub(crate) struct Scanned {
70    pub(crate) fragments: Arc<BTreeMap<String, Fragment>>,
71    pub(crate) diags: Vec<Diag>,
72}
73
74impl FragmentCorpus {
75    /// A corpus over already-read file texts.
76    pub fn new(sources: Vec<PackSource>, kinds: &[StepKindSpec]) -> Self {
77        Self {
78            sources,
79            kinds: kinds.to_vec(),
80            read_errors: Vec::new(),
81            scanned: std::sync::OnceLock::new(),
82        }
83    }
84
85    /// Record files the caller could not read. They are reported like any other
86    /// per-file corpus problem — never sinking their siblings, and never at all
87    /// unless something `ref:`s the corpus.
88    #[must_use]
89    pub fn with_read_errors(mut self, errors: Vec<Diag>) -> Self {
90        self.read_errors = errors;
91        self
92    }
93
94    /// The empty corpus — no `[run] fragments` configured, so no `ref:` can
95    /// resolve and nothing is ever scanned.
96    pub fn empty() -> Self {
97        Self::new(Vec::new(), &[])
98    }
99
100    /// The scan, run on first use and shared by every load thereafter.
101    pub(crate) fn scanned(&self) -> &Scanned {
102        self.scanned.get_or_init(|| {
103            let mut scanned = scan_fragments(&self.sources, &self.kinds);
104            // Unreadable files first: they explain an `unknown_ref` that would
105            // otherwise read as a typo.
106            let mut diags = self.read_errors.clone();
107            diags.append(&mut scanned.diags);
108            scanned.diags = diags;
109            scanned
110        })
111    }
112}
113
114/// The built-in packs embedded into every proef binary.
115pub fn builtin_sources() -> Vec<PackSource> {
116    vec![PackSource {
117        name: "builtin:core.yaml".to_owned(),
118        text: Arc::from(include_str!("../../helpers/core.yaml")),
119    }]
120}
121
122// ---------------------------------------------------------------------------
123// Raw serde model (wire shape — TECH-SPEC §6)
124// ---------------------------------------------------------------------------
125
126#[derive(Debug, Deserialize, schemars::JsonSchema)]
127#[serde(deny_unknown_fields)]
128pub(crate) struct RawPack {
129    pub(crate) macros: BTreeMap<String, RawMacro>,
130    /// Pack-scope fragment bindings (ADR-0018): the plumbing every macro in the
131    /// file needs, written once. Macro and step scope override it.
132    #[serde(default)]
133    pub(crate) bind: BTreeMap<String, String>,
134}
135
136#[derive(Debug, Deserialize, schemars::JsonSchema)]
137#[serde(deny_unknown_fields)]
138pub(crate) struct RawMacro {
139    #[serde(default)]
140    pub(crate) params: Vec<String>,
141    #[serde(default)]
142    pub(crate) defaults: BTreeMap<String, String>,
143    #[serde(rename = "match")]
144    pub(crate) match_: Option<String>,
145    pub(crate) description: Option<String>,
146    #[serde(default)]
147    pub(crate) tags: Vec<String>,
148    #[serde(default)]
149    pub(crate) steps: Vec<RawStep>,
150    pub(crate) expect: Option<Vec<RawExpectItem>>,
151    /// Macro-scope fragment bindings (ADR-0018).
152    #[serde(default)]
153    pub(crate) bind: BTreeMap<String, String>,
154}
155
156#[derive(Debug, Deserialize, schemars::JsonSchema)]
157pub(crate) struct RawStep {
158    pub(crate) name: Option<String>,
159    #[serde(default)]
160    pub(crate) optional: bool,
161    pub(crate) when: Option<String>,
162    pub(crate) retry: Option<RawRetry>,
163    /// Delay before the request, in milliseconds (baked into `[Options]`).
164    pub(crate) delay: Option<u64>,
165    #[serde(rename = "saveAs")]
166    pub(crate) save_as: Option<BTreeMap<String, String>>,
167    #[serde(rename = "use")]
168    pub(crate) use_: Option<String>,
169    pub(crate) with: Option<BTreeMap<String, String>>,
170    /// A named fragment this step executes (ADR-0018) — the alternative to an
171    /// inline payload, never both on one step.
172    #[serde(rename = "ref")]
173    pub(crate) ref_: Option<String>,
174    /// Step-scope fragment bindings, the most specific of the three.
175    #[serde(default)]
176    pub(crate) bind: BTreeMap<String, String>,
177    /// The dynamic payload key (`hurl:`, or a future engine's kind) — validated
178    /// against registered engine step kinds in pass 8.
179    #[serde(flatten)]
180    #[schemars(with = "BTreeMap<String, serde_json::Value>")]
181    pub(crate) payload: BTreeMap<String, serde_norway::Value>,
182}
183
184#[derive(Debug, Deserialize, schemars::JsonSchema)]
185#[serde(deny_unknown_fields)]
186pub(crate) struct RawRetry {
187    pub(crate) count: u32,
188    #[serde(default = "default_retry_interval")]
189    pub(crate) interval_ms: u64,
190}
191
192fn default_retry_interval() -> u64 {
193    1000
194}
195
196#[derive(Debug, Deserialize, schemars::JsonSchema)]
197#[serde(deny_unknown_fields)]
198pub(crate) struct RawExpectItem {
199    pub(crate) status: Option<String>,
200    /// Raw hurl assert lines appended to the previous entry's `[Asserts]`.
201    pub(crate) hurl: Option<String>,
202}
203
204// ---------------------------------------------------------------------------
205// Loaded model (what binding and lowering consume)
206// ---------------------------------------------------------------------------
207
208/// A validated set of packs: every macro, indexed by (globally unique) name,
209/// plus the fragments packs may reference and the pack-scope bindings.
210#[derive(Debug, Default)]
211pub struct PackSet {
212    /// All macros by name (pass 3 guarantees global uniqueness).
213    pub macros: BTreeMap<String, Macro>,
214    /// All named fragments by name — globally unique for the same reason macro
215    /// names are: a `ref:` names one thing, wherever it was declared (ADR-0018).
216    ///
217    /// Shared rather than owned: one scan serves every load of the same corpus
218    /// (see [`FragmentCorpus`]), so handing it to four loads costs four
219    /// refcounts, not four copies of the corpus.
220    pub fragments: Arc<BTreeMap<String, Fragment>>,
221    /// Pack-scope `bind:` tables, keyed by pack name so a binding stays
222    /// attributable to the file that declared it.
223    pub bind: BTreeMap<String, BTreeMap<String, String>>,
224}
225
226impl Fragment {
227    /// This fragment as `file.hurl#name` — the spelling a `ref:` accepts, and
228    /// what a run record carries so it can be read back long after the pack
229    /// that named it changed.
230    ///
231    /// Next to [`PackSet::find_fragment`], which parses the same form via
232    /// `pack_ref_matches`: producing it 400 lines from where it is consumed is
233    /// how the two stop agreeing on what the separator means.
234    #[must_use]
235    pub fn qualified(&self) -> String {
236        format!("{}#{}", self.file, self.name)
237    }
238}
239
240impl PackSet {
241    /// `(pattern, macro name)` pairs for the step binder — macros with a
242    /// `match:` only.
243    pub fn step_defs(&self) -> Vec<(&str, &str)> {
244        self.macros
245            .values()
246            .filter_map(|m| m.pattern.as_deref().map(|p| (p, m.name.as_str())))
247            .collect()
248    }
249
250    /// Resolve a `use:` target (`name` or `pack.yaml#name`) to a macro.
251    pub fn find_use_target(&self, target: &str) -> Option<&Macro> {
252        match target.split_once('#') {
253            Some((pack_ref, name)) => self
254                .macros
255                .get(name)
256                .filter(|m| pack_ref_matches(&m.pack, pack_ref)),
257            None => self.macros.get(target),
258        }
259    }
260
261    /// Resolve a `ref:` target (`name` or `file.hurl#name`) to a fragment —
262    /// the same two spellings `use:` accepts, qualified the same way, because
263    /// they answer the same question.
264    pub fn find_fragment(&self, target: &str) -> Option<&Fragment> {
265        match target.split_once('#') {
266            Some((file_ref, name)) => self
267                .fragments
268                .get(name)
269                .filter(|f| pack_ref_matches(&f.file, file_ref)),
270            None => self.fragments.get(target),
271        }
272    }
273}
274
275/// One named entry of a fragment file (ADR-0018).
276///
277/// Every field but `name` is *read* from the entry by the claiming engine's own
278/// parser — nothing is declared twice, so nothing can drift from the file.
279#[derive(Debug, Clone)]
280pub struct Fragment {
281    /// The name its `# @proef` annotation gave it (globally unique).
282    pub name: String,
283    /// Source file name as authored — the `file.hurl#name` qualifier and the
284    /// diagnostic source.
285    pub file: String,
286    /// The step kind whose engine scanned this file, taken from the
287    /// `StepKindSpec` whose `fragments.ext` claimed it. A `ref:` step routes by
288    /// this exactly as an inline step routes by its payload key (ADR-0002).
289    pub kind: String,
290    /// The entry's own text, annotation included.
291    pub text: String,
292    /// 1-based line the entry starts on.
293    pub line: usize,
294    /// Variables the entry reads: its required inputs.
295    pub placeholders: Vec<String>,
296    /// Option families the entry sets for itself (`"retry"`, `"delay"`).
297    pub declared_options: Vec<String>,
298    /// Variables the entry supplies to itself (`[Options] variable:`): both an
299    /// answer to its own placeholders and a clash with a `bind:` of that name.
300    pub supplied_variables: Vec<String>,
301    /// The fragment file's text (for diagnostics).
302    pub source: Arc<str>,
303}
304
305/// Path-boundary-aware pack-qualifier match: `api.yaml` qualifies
306/// `packs/api.yaml` but never `legacy-api.yaml` — a suffix only counts when
307/// it starts at a `/` boundary (or spans the whole name).
308fn pack_ref_matches(pack: &str, pack_ref: &str) -> bool {
309    let bounded_suffix = |hay: &str, needle: &str| {
310        hay.strip_suffix(needle)
311            .is_some_and(|rest| rest.is_empty() || rest.ends_with('/'))
312    };
313    bounded_suffix(pack, pack_ref) || bounded_suffix(pack_ref, pack)
314}
315
316/// One loaded macro.
317#[derive(Debug, Clone)]
318pub struct Macro {
319    /// Macro name (globally unique across loaded packs).
320    pub name: String,
321    /// Source pack name this macro came from.
322    pub pack: String,
323    /// Declared params (required unless defaulted).
324    pub params: Vec<String>,
325    /// Default values for optional params.
326    pub defaults: BTreeMap<String, String>,
327    /// The Gherkin-reachable `match:` pattern (absent = `use:`-only macro).
328    pub pattern: Option<String>,
329    /// Documentation string.
330    pub description: Option<String>,
331    /// Macro tags.
332    pub tags: Vec<String>,
333    /// Request steps or assert-only body.
334    pub body: MacroBody,
335    /// Macro-scope fragment bindings (ADR-0018), overriding pack scope.
336    pub bind: BTreeMap<String, String>,
337    /// The pack source text (for diagnostics).
338    pub source: Arc<str>,
339    /// Span of the macro's name in the pack file, when locatable.
340    pub span: Option<Span>,
341    /// Span of the macro's `match:` line in the pack file, when locatable.
342    pub match_span: Option<Span>,
343}
344
345/// A macro is either a sequence of request steps or an assert-only `expect:`
346/// (merged into the previous request entry — the Then-step rule, ADR-0004).
347#[derive(Debug, Clone)]
348pub enum MacroBody {
349    /// Request steps.
350    Steps(Vec<MacroStep>),
351    /// Assert-only items.
352    Expect(Vec<ExpectItem>),
353}
354
355/// One step of a request macro.
356#[derive(Debug, Clone)]
357pub struct MacroStep {
358    /// Entry label (events/console).
359    pub name: Option<String>,
360    /// Delay before the request in milliseconds (baked into `[Options]`).
361    pub delay_ms: Option<u64>,
362    /// Payload or composition.
363    pub kind: MacroStepKind,
364    /// `optional:` — failure warns and the batch segments around it.
365    pub optional: bool,
366    /// `when:` skip guard (runs iff non-empty after resolution).
367    pub when: Option<String>,
368    /// Finite retry policy.
369    pub retry: Option<Retry>,
370    /// `saveAs:` promotions (capture name → `global`).
371    pub save_as: BTreeMap<String, String>,
372    /// Step-scope fragment bindings (ADR-0018), the most specific of the three.
373    pub bind: BTreeMap<String, String>,
374}
375
376impl MacroStep {
377    /// The [`crate::engine::OPTION_FAMILIES`] this step sets for itself.
378    ///
379    /// The single derivation of "which options does the YAML declare", so the
380    /// double-declaration rule reads the same answer for both body forms —
381    /// an inline block's `[Options]` and a fragment's `declared_options` are
382    /// checked against *this*, not against two hand-written lists that could
383    /// drift apart and let hurl's silent last-wins back in.
384    ///
385    /// A new family is added here and in `OPTION_FAMILIES` together; both
386    /// call sites then cover it with no further edit.
387    pub fn declared_options(&self) -> impl Iterator<Item = &'static str> {
388        [
389            ("retry", self.retry.is_some()),
390            ("delay", self.delay_ms.is_some()),
391        ]
392        .into_iter()
393        .filter_map(|(family, declared)| declared.then_some(family))
394    }
395}
396
397/// Payload or composition of a [`MacroStep`].
398#[derive(Debug, Clone)]
399pub enum MacroStepKind {
400    /// An engine payload (`hurl: |` raw block, or structured for future engines).
401    Payload {
402        /// The step kind key as written (`hurl`, …).
403        kind: String,
404        /// The payload itself.
405        payload: PayloadForm,
406    },
407    /// Composition: inline another macro's steps.
408    Use {
409        /// Target macro (`name` or `pack.yaml#name`).
410        target: String,
411        /// Arguments for the target's params.
412        with: BTreeMap<String, String>,
413    },
414    /// A named fragment declared in an engine-native file (ADR-0018).
415    Ref {
416        /// Target fragment (`name` or `file.hurl#name`).
417        target: String,
418    },
419}
420
421/// The two payload shapes (ADR-0004: raw text is primary; structured is
422/// reserved for future non-hurl engines).
423#[derive(Debug, Clone)]
424pub enum PayloadForm {
425    /// Raw engine text (`hurl:` block scalar), `${…}` still unresolved.
426    Raw(String),
427    /// Structured payload for future engines.
428    Structured(serde_json::Value),
429}
430
431/// One assert-only item: a `status:` shorthand and/or raw hurl assert lines
432/// (both may contain `${…}`).
433#[derive(Debug, Clone)]
434pub struct ExpectItem {
435    /// Expected HTTP status.
436    pub status: Option<String>,
437    /// Raw assert lines appended to the previous entry's `[Asserts]`.
438    pub fragment: Option<String>,
439}
440
441// ---------------------------------------------------------------------------
442// Loading
443// ---------------------------------------------------------------------------
444
445/// Parse and validate `sources` against the registered engine step `kinds`
446/// (validation passes 1–13, TECH-SPEC §4.1), returning the partial [`PackSet`]
447/// built from every pack that parses+normalizes AND all diagnostics collected
448/// along the way. A pack that fails to parse contributes only its diagnostic
449/// and is excluded from the set — it never sinks its siblings. This is the
450/// collect-all half that the LSP's `analyze_suite` needs so one broken pack
451/// does not zero the whole suite; `load` is the fail-fast wrapper for a run.
452pub(crate) fn load_collecting(
453    sources: &[PackSource],
454    fragments: &FragmentCorpus,
455    kinds: &[StepKindSpec],
456) -> (PackSet, Vec<Diag>) {
457    let mut diags: Vec<Diag> = Vec::new();
458    let mut set = PackSet::default();
459    let mut raw_packs: Vec<(usize, String, RawPack)> = Vec::new();
460
461    for (index, source) in sources.iter().enumerate() {
462        match serde_norway::from_str::<RawPack>(&source.text) {
463            Ok(raw) => raw_packs.push((index, source.name.clone(), raw)),
464            Err(err) => {
465                let span = err
466                    .location()
467                    .map(|loc| Span::clamped(loc.index(), loc.index() + 1, source.text.len()));
468                let mut diag = Diag::error(
469                    "proef::pack::yaml",
470                    format!("pack is not valid YAML for the pack schema: {err}"),
471                )
472                .with_source(source.name.clone(), Arc::clone(&source.text));
473                if let Some(span) = span {
474                    diag = diag.with_span(span);
475                }
476                diags.push(diag);
477            }
478        }
479    }
480
481    // Fragments parse only when some pack actually names one. hurl-parsing a
482    // corpus is the dominant cost of loading it and the root may be large and
483    // external, so a suite that references none must not pay it on every
484    // `test`, `dry-run`, `flows` and `macros`.
485    //
486    // This skips the *parse*, not the read: `sources` and `fragments` both
487    // arrive already read, because core performs no IO (the caller does, and
488    // hands the bytes in). So "pointing at a corpus you did not write costs
489    // nothing" (CONFIG.md) is exact about the scan and approximate about the
490    // file read — do not restate it here as though the whole cost were gated.
491    if raw_packs.iter().any(|(_, _, raw)| {
492        raw.macros
493            .values()
494            .any(|m| m.steps.iter().any(|s| s.ref_.is_some()))
495    }) {
496        let scanned = fragments.scanned();
497        set.fragments = Arc::clone(&scanned.fragments);
498        diags.extend(scanned.diags.iter().cloned());
499    }
500
501    // Normalize each raw macro (structural checks happen inline).
502    for (source_index, pack_name, raw) in &raw_packs {
503        let source = &sources[*source_index];
504        for (macro_name, raw_macro) in &raw.macros {
505            let normalized =
506                validate::normalize_macro(macro_name, raw_macro, pack_name, source, &mut diags);
507            if let Some(macro_) = normalized {
508                // Pass 3: duplicate macro names across packs.
509                if let Some(existing) = set.macros.get(macro_name) {
510                    diags.push(
511                        Diag::error(
512                            "proef::pack::duplicate_macro",
513                            format!(
514                                "macro `{macro_name}` is defined in both `{}` and `{pack_name}`",
515                                existing.pack
516                            ),
517                        )
518                        .with_source(source.name.clone(), Arc::clone(&source.text))
519                        .maybe_span(macro_.span)
520                        .with_help("macro names are global — rename one of the definitions"),
521                    );
522                } else {
523                    set.macros.insert(macro_name.clone(), macro_);
524                }
525            }
526        }
527        if !raw.bind.is_empty() {
528            set.bind.insert(pack_name.clone(), raw.bind.clone());
529        }
530    }
531
532    validate::run_cross_macro_passes(&set, kinds, &mut diags);
533    (set, diags)
534}
535
536/// Scan every fragment file through the claiming engine's parser, indexing the
537/// annotated entries by name. Unannotated entries are dropped without comment:
538/// a corpus proef did not write is mostly those, and naming is the author's
539/// way of saying which ones proef may use.
540///
541/// A file the engine cannot parse contributes its diagnostic and nothing else —
542/// the same "never sinks its siblings" rule packs get.
543fn scan_fragments(sources: &[PackSource], kinds: &[StepKindSpec]) -> Scanned {
544    let mut fragments: BTreeMap<String, Fragment> = BTreeMap::new();
545    let mut diags: Vec<Diag> = Vec::new();
546    for source in sources {
547        // The extension decides which kind claims the file. A file no kind
548        // claims is skipped rather than handed to whichever scanner happens to
549        // be first: that guess would blame one engine's parser for another
550        // engine's file, and route the fragment to the wrong engine at run time.
551        let Some((kind_name, scan)) = kinds.iter().find_map(|kind| {
552            let support = kind.fragments?;
553            (source.name.rsplit('.').next() == Some(support.ext))
554                .then_some((kind.prefix, support.scan))
555        }) else {
556            continue;
557        };
558        let scanned = match scan(&source.text) {
559            Ok(scanned) => scanned,
560            Err(err) => {
561                diags.push(
562                    Diag::error(
563                        "proef::pack::bad_annotation",
564                        format!("{}: {}", source.name, err.message),
565                    )
566                    .with_source(source.name.clone(), Arc::clone(&source.text))
567                    .maybe_span(locate::line_span(&source.text, err.line)),
568                );
569                continue;
570            }
571        };
572        for entry in scanned {
573            let name = entry.name;
574            if let Some(existing) = fragments.get(&name) {
575                diags.push(
576                    Diag::error(
577                        "proef::pack::duplicate_fragment",
578                        format!(
579                            "fragment `{name}` is declared in both `{}` and `{}`",
580                            existing.file, source.name
581                        ),
582                    )
583                    .with_source(source.name.clone(), Arc::clone(&source.text))
584                    .maybe_span(locate::line_span(&source.text, entry.line))
585                    .with_help(
586                        "fragment names are global — rename one, or qualify the `ref:` \
587                         as `file.hurl#name`",
588                    ),
589                );
590                continue;
591            }
592            fragments.insert(
593                name.clone(),
594                Fragment {
595                    name,
596                    file: source.name.clone(),
597                    kind: kind_name.to_owned(),
598                    text: entry.text,
599                    line: entry.line,
600                    placeholders: entry.placeholders,
601                    declared_options: entry.declared_options,
602                    supplied_variables: entry.supplied_variables,
603                    source: Arc::clone(&source.text),
604                },
605            );
606        }
607    }
608    Scanned {
609        fragments: Arc::new(fragments),
610        diags,
611    }
612}
613
614/// Parse and validate `sources`, failing on the first error-severity diagnostic
615/// (the fail-fast contract a real `proef` run depends on). All diagnostics are
616/// still collected — one bad pack does not hide problems in another.
617pub fn load(
618    sources: &[PackSource],
619    fragments: &FragmentCorpus,
620    kinds: &[StepKindSpec],
621) -> Result<PackSet, FrontError> {
622    let (set, diags) = load_collecting(sources, fragments, kinds);
623    if diags
624        .iter()
625        .any(|d| d.severity == crate::diag::Severity::Error)
626    {
627        Err(FrontError::Diagnostics(diags))
628    } else {
629        Ok(set)
630    }
631}
632
633impl Diag {
634    /// Attach a span when one is available (loader convenience).
635    #[must_use]
636    pub(crate) fn maybe_span(self, span: Option<Span>) -> Self {
637        match span {
638            Some(span) => self.with_span(span),
639            None => self,
640        }
641    }
642}