Skip to main content

proef_core/
analyze.rs

1//! Collect-all suite analysis: the LSP's whole-suite recompute in one function.
2//!
3//! Where `front::run` fails fast and emits artifacts, `analyze_suite`
4//! accumulates every diagnostic and emits the relations editors need
5//! (`bindings` for go-to-def/references, `macros` for completion/def targets).
6//! A parse-failed unit reports its own diagnostic and is skipped downstream —
7//! no cascade of bogus follow-on errors.
8
9use std::collections::BTreeMap;
10use std::sync::Arc;
11
12use crate::bind;
13use crate::diag::{Diag, Span};
14use crate::emit;
15use crate::engine::StepKindSpec;
16use crate::feature;
17use crate::lower::{self, LowerCtx};
18use crate::pack::{self, MacroBody, MacroStepKind, PackSet, PackSource};
19use crate::provider::SourceProvider;
20use crate::world::{GlobalStore, World};
21
22/// One prose step bound to a macro — powers go-to-definition and references.
23#[derive(Debug, Clone)]
24pub struct Binding {
25    /// Source name of the feature file the step lives in.
26    pub feature: String,
27    /// Byte span of the step text in the *normalized* feature source.
28    pub step_span: Span,
29    /// The macro this step resolved to.
30    pub macro_name: String,
31}
32
33/// A macro definition — powers completion and is the go-to-definition target.
34#[derive(Debug, Clone)]
35pub struct MacroRef {
36    /// Macro name (globally unique across the loaded packs).
37    pub name: String,
38    /// The `match:` pattern (`None` for `use:`-only macros).
39    pub pattern: Option<String>,
40    /// Declared params, in declaration order.
41    pub params: Vec<String>,
42    /// Source name of the pack the macro is defined in.
43    pub pack: String,
44    /// Byte span of the macro's name key in the *normalized* pack source, when
45    /// locatable. This is the definition anchor go-to-definition jumps to.
46    pub def_span: Option<Span>,
47    /// Byte span of the macro's `match:` line, when locatable — the preferred
48    /// go-to-definition landing anchor (falls back to `def_span`).
49    pub match_span: Option<Span>,
50}
51
52/// One `use:` reference inside a pack → the macro it resolves to. Powers
53/// go-to-definition from a `use:` line to the target macro's definition.
54#[derive(Debug, Clone)]
55pub struct UseRef {
56    /// Source name of the pack the `use:` line lives in.
57    pub pack: String,
58    /// Byte span of the `use:` line in the *normalized* pack source.
59    pub span: Span,
60    /// The macro the reference resolves to (globally unique name).
61    pub target_macro: String,
62}
63
64/// One `ref:` reference inside a pack → the fragment it resolves to. Powers
65/// go-to-definition from a `ref:` line to the annotation in the `.hurl` file.
66#[derive(Debug, Clone)]
67pub struct FragmentRef {
68    /// Source name of the pack the `ref:` line lives in.
69    pub pack: String,
70    /// Byte span of the `ref:` line in the *normalized* pack source.
71    pub span: Span,
72    /// The fragment the reference resolves to (globally unique name).
73    pub target_fragment: String,
74}
75
76/// A fragment definition — the go-to-definition target for a `ref:`, and the
77/// vocabulary a `ref:` line completes against.
78#[derive(Debug, Clone)]
79pub struct FragmentDef {
80    /// Fragment name (globally unique across the scanned files).
81    pub name: String,
82    /// Source name of the file declaring it.
83    pub file: String,
84    /// Byte span of its `# @proef` annotation line, when locatable — the
85    /// landing anchor.
86    pub span: Option<Span>,
87    /// The exact text `span` was measured against. Carried rather than re-read:
88    /// a consumer converting the span needs a line index built from *these*
89    /// bytes, and a fresh read could observe a newer edit and mis-anchor.
90    pub source: Arc<str>,
91    /// Every variable the entry reads, in first-seen order — exactly the names a
92    /// `bind:` in scope has to supply (ADR-0018).
93    ///
94    /// Read off the engine's own AST at scan time, so an editor offering them is
95    /// offering the file's real interface rather than a second description of it
96    /// that could disagree. Without this the only way to learn a foreign
97    /// corpus's variable names is to run the suite and read
98    /// `proef::lower::unbound_placeholder`.
99    ///
100    /// Faithful to what the entry *reads*, so subtract [`Self::supplied_variables`]
101    /// before offering these as `bind:` keys.
102    pub placeholders: Vec<String>,
103    /// Every variable the entry supplies to itself (`[Options] variable:`).
104    ///
105    /// A name here needs no `bind:` and may not have one
106    /// (`proef::pack::option_declared_twice`), so an editor offering it as a
107    /// completion would be proposing an edit its own diagnostics then reject.
108    pub supplied_variables: Vec<String>,
109}
110
111/// The product of one wholesale recompute: every feature's read from here.
112#[derive(Debug, Default)]
113pub struct SuiteAnalysis {
114    /// source name → its diagnostics (features and packs alike).
115    pub diagnostics: BTreeMap<String, Vec<Diag>>,
116    /// Every prose-step-to-macro binding across the suite.
117    pub bindings: Vec<Binding>,
118    /// Every macro definition across the loaded packs.
119    pub macros: Vec<MacroRef>,
120    /// Every `use:` reference across the loaded packs, resolved to its target.
121    pub use_refs: Vec<UseRef>,
122    /// Every `ref:` reference across the loaded packs, resolved to its target.
123    pub fragment_refs: Vec<FragmentRef>,
124    /// Every fragment definition across the scanned files.
125    pub fragments: Vec<FragmentDef>,
126}
127
128/// Everything `analyze_suite` needs, injected at the IO edge (sans-IO core).
129pub struct AnalyzeCtx<'a> {
130    /// The source of feature and pack bytes (the IO edge lives behind it).
131    pub provider: &'a dyn SourceProvider,
132    /// Registered engine step kinds (drives pack validation and artifact probes).
133    pub kinds: &'a [StepKindSpec],
134    /// Step-kind prefix → engine id, the lowering routing table.
135    pub kind_to_engine: &'a BTreeMap<String, String>,
136    /// Injected environment snapshot (`${env:…}`).
137    pub env: &'a BTreeMap<String, String>,
138    /// Injected `proef.toml` config scope (`${url:…}` / `${vars:…}`), with the
139    /// active `[env.<name>]` already deep-merged in.
140    pub config_vars: &'a BTreeMap<String, String>,
141    /// Injected run identifier (`${run:id}`).
142    pub run_id: &'a str,
143}
144
145impl SuiteAnalysis {
146    fn push_diags(&mut self, name: &str, diags: impl IntoIterator<Item = Diag>) {
147        // Ensure the primary source has a bucket even with no diagnostics, so a
148        // now-clean file still surfaces an empty set that clears stale marks.
149        self.diagnostics.entry(name.to_owned()).or_default();
150        for d in diags {
151            // Prefer the diagnostic's own source name when it carries one, so a
152            // pack error raised while analyzing a feature lands on the pack.
153            let target = d.source_name.clone().unwrap_or_else(|| name.to_owned());
154            self.diagnostics.entry(target).or_default().push(d);
155        }
156    }
157}
158
159/// Recompute the whole suite in one pass: read every pack and feature through
160/// the provider, accumulate every diagnostic per source name, and record the
161/// binding and macro relations editors need. A broken pack contributes its own
162/// diagnostic and is excluded from the loaded set, but does not stop the rest
163/// of the suite from binding; a parse-failed feature is skipped, not fatal.
164pub fn analyze_suite(ctx: &AnalyzeCtx<'_>) -> SuiteAnalysis {
165    let mut out = SuiteAnalysis::default();
166
167    // Packs first: a broken pack contributes its own diagnostic below and is
168    // excluded from the loaded set, but its siblings still load.
169    let mut sources = pack::builtin_sources();
170    let pack_names = ctx.provider.discover_packs().unwrap_or_default();
171    for name in &pack_names {
172        match ctx.provider.read(name) {
173            Ok(text) => sources.push(PackSource {
174                name: name.clone(),
175                text,
176            }),
177            Err(e) => out.push_diags(name, [read_error_diag(name, &e.0)]),
178        }
179    }
180
181    // Collect-all load: a broken pack contributes its diagnostic and is
182    // excluded from the set, but its siblings still load — the editor keeps
183    // binding against the good packs instead of going dark (v0.5.1 fix).
184    // Fragments too, or every `ref:` reads as unknown in the editor while the
185    // same suite runs green — the drift that makes diagnostics untrustworthy.
186    let mut fragments = Vec::new();
187    for name in ctx.provider.discover_fragments().unwrap_or_default() {
188        match ctx.provider.read(&name) {
189            Ok(text) => fragments.push(PackSource {
190                name: name.clone(),
191                text,
192            }),
193            Err(e) => out.push_diags(&name, [read_error_diag(&name, &e.0)]),
194        }
195    }
196
197    let fragments = pack::FragmentCorpus::new(fragments, ctx.kinds);
198    let (loaded, pack_diags) = pack::load_collecting(&sources, &fragments, ctx.kinds);
199    for d in pack_diags {
200        let name = d.source_name.clone().unwrap_or_default();
201        out.push_diags(&name, [d]);
202    }
203    let packs: Arc<PackSet> = Arc::new(loaded);
204
205    // Macro vocabulary for completion / go-to-def targets.
206    for m in packs.macros.values() {
207        out.macros.push(MacroRef {
208            name: m.name.clone(),
209            pattern: m.pattern.clone(),
210            params: m.params.clone(),
211            pack: m.pack.clone(),
212            def_span: m.span,
213            match_span: m.match_span,
214        });
215    }
216
217    out.use_refs = index_use_refs(&packs);
218    out.fragment_refs = index_fragment_refs(&packs);
219    out.fragments = index_fragments(&packs);
220
221    let world = World::new(GlobalStore::default());
222
223    let feature_names = ctx.provider.discover_features().unwrap_or_default();
224    for name in &feature_names {
225        let text = match ctx.provider.read(name) {
226            Ok(t) => t,
227            Err(e) => {
228                out.push_diags(name, [read_error_diag(name, &e.0)]);
229                continue;
230            }
231        };
232        let file = match feature::parse(name, &text) {
233            Ok(f) => f,
234            Err(errs) => {
235                out.push_diags(name, errs);
236                continue; // parse failed → skip downstream, no cascade
237            }
238        };
239
240        let (bound, bind_diags) = bind::bind_collect(&file, &packs);
241        out.push_diags(name, bind_diags);
242
243        for scenario in &bound {
244            for step in &scenario.steps {
245                out.bindings.push(Binding {
246                    feature: name.clone(),
247                    step_span: step.defn.span,
248                    macro_name: step.macro_name.clone(),
249                });
250            }
251        }
252
253        let ctx_lower = LowerCtx {
254            feature: &file,
255            packs: &packs,
256            kind_to_engine: ctx.kind_to_engine,
257            env: ctx.env,
258            config_vars: ctx.config_vars,
259            run_id: ctx.run_id,
260            world: &world,
261            mode: crate::resolve::ResolveMode::DryRun,
262        };
263        for scenario in &bound {
264            match lower::lower(scenario, &ctx_lower) {
265                Ok(lowered) => {
266                    out.push_diags(name, lowered.warnings.iter().cloned());
267                    // Emit + artifact validation is executed for its diagnostics
268                    // only; the artifact text is discarded.
269                    let stem = feature_stem(name);
270                    if let Some(artifact) = emit::emit(&lowered, &stem, &world) {
271                        let mut diags = Vec::new();
272                        validate_artifact(&artifact, &lowered, ctx.kinds, &mut diags);
273                        out.push_diags(name, diags);
274                    }
275                }
276                Err(errs) => out.push_diags(name, errs),
277            }
278        }
279    }
280
281    out
282}
283
284/// Every fragment definition, with its annotation line as the landing anchor.
285/// Names are unique across the corpus (pass 10), so unlike the `use:`/`ref:`
286/// indexes below this one needs no positional pairing and no guard.
287fn index_fragments(packs: &PackSet) -> Vec<FragmentDef> {
288    packs
289        .fragments
290        .values()
291        .map(|f| FragmentDef {
292            name: f.name.clone(),
293            file: f.file.clone(),
294            span: crate::pack::locate::line_span(&f.source, f.line),
295            source: Arc::clone(&f.source),
296            placeholders: f.placeholders.clone(),
297            supplied_variables: f.supplied_variables.clone(),
298        })
299        .collect()
300}
301
302/// Index every `ref:` reference → its resolved fragment, for go-to-def from a
303/// `ref:` line. Pairs positionally with the `ref:` line spans exactly as
304/// `index_use_refs` does, and carries the same guard: a flow-style step parses
305/// to a `Ref` but contributes no line, so a count mismatch means the pairing
306/// cannot be trusted and the macro is skipped rather than mis-anchored.
307fn index_fragment_refs(packs: &PackSet) -> Vec<FragmentRef> {
308    let mut refs = Vec::new();
309    for m in packs.macros.values() {
310        let MacroBody::Steps(steps) = &m.body else {
311            continue;
312        };
313        let targets: Vec<&str> = steps
314            .iter()
315            .filter_map(|step| match &step.kind {
316                MacroStepKind::Ref { target } => Some(target.as_str()),
317                MacroStepKind::Use { .. } | MacroStepKind::Payload { .. } => None,
318            })
319            .collect();
320        let spans = crate::pack::locate::ref_line_spans(&m.source, &m.name);
321        if spans.len() != targets.len() {
322            continue;
323        }
324        for (span, target) in spans.into_iter().zip(targets) {
325            // Resolved by name, so an unknown target simply contributes no
326            // reference — pack validation already reported it.
327            if let Some(fragment) = packs.find_fragment(target) {
328                refs.push(FragmentRef {
329                    pack: m.pack.clone(),
330                    span,
331                    target_fragment: fragment.name.clone(),
332                });
333            }
334        }
335    }
336    refs
337}
338
339/// Index every `use:` reference → its resolved target, for go-to-def from a
340/// `use:` line. Each macro's parsed `MacroStepKind::Use` targets pair positionally
341/// with the `use:` line spans `pack::locate::use_line_spans` finds. Because both
342/// counts come from the macro's own steps and source, a mismatch means the line
343/// scanner missed a step it can't see (e.g. a flow-style `- {use: base}`), so the
344/// pairing is unreliable and that macro is skipped entirely rather than risk a
345/// wrong `Some`.
346fn index_use_refs(packs: &PackSet) -> Vec<UseRef> {
347    let mut use_refs = Vec::new();
348    for m in packs.macros.values() {
349        let MacroBody::Steps(steps) = &m.body else {
350            continue;
351        };
352        let targets: Vec<&str> = steps
353            .iter()
354            .filter_map(|step| match &step.kind {
355                MacroStepKind::Use { target, .. } => Some(target.as_str()),
356                // Only `use:` lines are indexed here; a `ref:` resolves to a
357                // fragment, not a macro, so it is not a go-to-macro target.
358                MacroStepKind::Payload { .. } | MacroStepKind::Ref { .. } => None,
359            })
360            .collect();
361        let spans = crate::pack::locate::use_line_spans(&m.source, &m.name);
362        if spans.len() != targets.len() {
363            continue; // line scan and parsed steps disagree → pairing unreliable, skip
364        }
365        for (span, target) in spans.into_iter().zip(targets) {
366            if let Some(target_macro) = packs.find_use_target(target) {
367                use_refs.push(UseRef {
368                    pack: m.pack.clone(),
369                    span,
370                    target_macro: target_macro.name.clone(),
371                });
372            }
373        }
374    }
375    use_refs
376}
377
378fn feature_stem(name: &str) -> String {
379    std::path::Path::new(name).file_stem().map_or_else(
380        || "feature".to_owned(),
381        |s| s.to_string_lossy().into_owned(),
382    )
383}
384
385fn read_error_diag(name: &str, msg: &str) -> Diag {
386    Diag::error(
387        "proef::source::unreadable",
388        format!("cannot read {name}: {msg}"),
389    )
390    .with_source(name.to_owned(), Arc::from(""))
391}
392
393/// Parse-validate the exact emitted artifact text with the claiming engine's
394/// real parser (`--dry-run` = §4.1–4.5 including artifact parse-validation).
395/// The diagnostic's source is the emitted text itself, span at the broken line.
396///
397/// This is the single implementation of artifact parse-validation, shared by the
398/// CLI's fail-fast `front::run` and the LSP's collect-all `analyze_suite`. It
399/// reaches the hurl parser only through the injected [`StepKindSpec::validate`]
400/// function pointer, so it stays engine-agnostic and lives in the sans-IO core.
401pub fn validate_artifact(
402    artifact: &emit::Artifact,
403    lowered: &lower::LoweredScenario,
404    kinds: &[StepKindSpec],
405    diags: &mut Vec<Diag>,
406) {
407    let Some(kind) = lowered
408        .batches
409        .iter()
410        .flat_map(|b| b.steps.iter())
411        .find(|s| matches!(s.payload, crate::step::StepPayload::HurlEntries(_)))
412        .map(|s| s.kind.as_str().to_owned())
413    else {
414        return;
415    };
416    let Some(validate) = kinds
417        .iter()
418        .find(|k| k.prefix == kind)
419        .and_then(|k| k.validate)
420    else {
421        return;
422    };
423    if let Err(err) = validate(&artifact.hurl_text) {
424        let offset: usize = artifact
425            .hurl_text
426            .split_inclusive('\n')
427            .take(err.line.saturating_sub(1))
428            .map(str::len)
429            .sum();
430        let line_len = artifact.hurl_text[offset..]
431            .lines()
432            .next()
433            .unwrap_or("")
434            .len();
435        diags.push(
436            Diag::error(
437                "proef::emit::invalid_artifact",
438                format!(
439                    "emitted artifact `{}.hurl` does not parse: {} (line {}, column {})",
440                    artifact.slug, err.message, err.line, err.column
441                ),
442            )
443            .with_source(
444                format!("{}.hurl (emitted)", artifact.slug),
445                std::sync::Arc::from(artifact.hurl_text.as_str()),
446            )
447            .with_span(Span::clamped(
448                offset,
449                offset + line_len.max(1),
450                artifact.hurl_text.len(),
451            )),
452        );
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    #![allow(clippy::expect_used)]
459
460    use super::*;
461    use crate::provider::{ProviderError, SourceProvider};
462    use std::collections::BTreeMap;
463    use std::sync::Arc;
464
465    /// A provider backed by an in-memory map — keeps the test sans-IO.
466    struct MemProvider {
467        features: Vec<String>,
468        packs: Vec<String>,
469        fragments: Vec<String>,
470        files: BTreeMap<String, Arc<str>>,
471    }
472    impl SourceProvider for MemProvider {
473        fn discover_features(&self) -> Result<Vec<String>, ProviderError> {
474            Ok(self.features.clone())
475        }
476        fn discover_packs(&self) -> Result<Vec<String>, ProviderError> {
477            Ok(self.packs.clone())
478        }
479        fn discover_fragments(&self) -> Result<Vec<String>, ProviderError> {
480            Ok(self.fragments.clone())
481        }
482        fn read(&self, name: &str) -> Result<Arc<str>, ProviderError> {
483            self.files
484                .get(name)
485                .cloned()
486                .ok_or_else(|| ProviderError(format!("no source {name}")))
487        }
488    }
489
490    // Pack validation (step-kind claim, pass 8) and lowering's routing invariant
491    // both require `hurl` to be a registered kind: with an empty registry a
492    // `hurl:` step is `unknown_step_kind` and an unrouted lowered step. The
493    // analyzer needs no *live* engine, though — a spec with no `validate` probe
494    // (artifact parse-validation is skipped) plus a `hurl → hurl` route is enough
495    // to bind, lower, and extract spans.
496    const KINDS: &[StepKindSpec] = &[StepKindSpec {
497        prefix: "hurl",
498        schema: "true",
499        validate: None,
500        fragments: None,
501    }];
502
503    fn hurl_kind_map() -> &'static BTreeMap<String, String> {
504        use std::sync::OnceLock;
505        static M: OnceLock<BTreeMap<String, String>> = OnceLock::new();
506        M.get_or_init(|| BTreeMap::from([("hurl".to_owned(), "hurl".to_owned())]))
507    }
508
509    fn ctx_over<'a>(
510        provider: &'a dyn SourceProvider,
511        empty: &'a BTreeMap<String, String>,
512    ) -> AnalyzeCtx<'a> {
513        AnalyzeCtx {
514            provider,
515            kinds: KINDS,
516            kind_to_engine: hurl_kind_map(),
517            env: empty,
518            config_vars: empty,
519            run_id: "lsp",
520        }
521    }
522
523    #[test]
524    fn analyze_surfaces_bindings_and_no_errors_on_a_clean_suite() {
525        let mut files = BTreeMap::new();
526        files.insert(
527            "packs/p.yaml".to_owned(),
528            Arc::from(
529                "macros:\n  greet:\n    params: [who]\n    match: \"I greet {who}\"\n    steps:\n      - hurl: |\n          GET http://x\n",
530            ),
531        );
532        files.insert(
533            "f.feature".to_owned(),
534            Arc::from("Feature: F\n  Scenario: S\n    When I greet Sam\n"),
535        );
536        let provider = MemProvider {
537            features: vec!["f.feature".to_owned()],
538            packs: vec!["packs/p.yaml".to_owned()],
539            fragments: Vec::new(),
540            files,
541        };
542        let empty = BTreeMap::new();
543        let analysis = analyze_suite(&ctx_over(&provider, &empty));
544
545        let errors: usize = analysis
546            .diagnostics
547            .values()
548            .flatten()
549            .filter(|d| d.severity == crate::diag::Severity::Error)
550            .count();
551        assert_eq!(
552            errors, 0,
553            "clean suite must have zero errors: {:?}",
554            analysis.diagnostics
555        );
556
557        assert!(
558            analysis
559                .bindings
560                .iter()
561                .any(|b| b.macro_name == "greet" && b.feature == "f.feature"),
562            "the greet step must be recorded as a binding"
563        );
564        assert!(
565            analysis
566                .macros
567                .iter()
568                .any(|m| m.name == "greet" && m.pattern.is_some())
569        );
570    }
571
572    #[test]
573    fn analyze_collects_unbound_without_cascade() {
574        let mut files = BTreeMap::new();
575        files.insert("packs/p.yaml".to_owned(), Arc::from("macros: {}\n"));
576        files.insert(
577            "f.feature".to_owned(),
578            Arc::from("Feature: F\n  Scenario: S\n    When nothing matches this\n"),
579        );
580        let provider = MemProvider {
581            features: vec!["f.feature".to_owned()],
582            packs: vec!["packs/p.yaml".to_owned()],
583            fragments: Vec::new(),
584            files,
585        };
586        let empty = BTreeMap::new();
587        let analysis = analyze_suite(&ctx_over(&provider, &empty));
588        let feature_diags = analysis
589            .diagnostics
590            .get("f.feature")
591            .expect("feature bucket");
592        assert!(
593            feature_diags
594                .iter()
595                .any(|d| d.code == "proef::bind::unbound_step")
596        );
597        let errors: Vec<_> = feature_diags
598            .iter()
599            .filter(|d| d.severity == crate::diag::Severity::Error)
600            .collect();
601        assert_eq!(
602            errors.len(),
603            1,
604            "the unbound step must be the only error-severity diagnostic, no spurious extras: {feature_diags:?}"
605        );
606        assert_eq!(errors[0].code, "proef::bind::unbound_step");
607    }
608
609    // §9's headline robustness property: a parse failure suppresses only its own
610    // file's downstream diagnostics — it must not cascade into a sibling feature's
611    // binding. Two features share one valid pack; only one of them fails to parse.
612    #[test]
613    fn analyze_parse_failed_feature_does_not_cascade_to_sibling() {
614        let mut files = BTreeMap::new();
615        files.insert(
616            "packs/p.yaml".to_owned(),
617            Arc::from(
618                "macros:\n  greet:\n    params: [who]\n    match: \"I greet {who}\"\n    steps:\n      - hurl: |\n          GET http://x\n",
619            ),
620        );
621        // Whitespace-only text: feature::parse's own empty-file guard
622        // (`normalized.trim().is_empty()`) returns `Err` before the gherkin
623        // parser even runs — a guaranteed, deliberate parse failure.
624        files.insert("bad.feature".to_owned(), Arc::from("   \n"));
625        files.insert(
626            "good.feature".to_owned(),
627            Arc::from("Feature: F\n  Scenario: S\n    When I greet Sam\n"),
628        );
629        let provider = MemProvider {
630            features: vec!["bad.feature".to_owned(), "good.feature".to_owned()],
631            packs: vec!["packs/p.yaml".to_owned()],
632            fragments: Vec::new(),
633            files,
634        };
635        let empty = BTreeMap::new();
636        let analysis = analyze_suite(&ctx_over(&provider, &empty));
637
638        // The parse-failed feature carries its own parse-error diagnostic —
639        // proof the `feature::parse` `Err` branch was actually hit.
640        let bad_diags = analysis
641            .diagnostics
642            .get("bad.feature")
643            .expect("bad.feature bucket");
644        assert!(
645            bad_diags
646                .iter()
647                .any(|d| d.code == "proef::feature::empty_file"),
648            "the parse-failed feature must carry its parse-error diagnostic: {bad_diags:?}"
649        );
650
651        // The sibling feature is untouched: no error diagnostics, and its
652        // binding still made it through — proof there was no cascade.
653        let good_diags = analysis
654            .diagnostics
655            .get("good.feature")
656            .expect("good.feature bucket");
657        assert!(
658            good_diags
659                .iter()
660                .all(|d| d.severity != crate::diag::Severity::Error),
661            "the valid sibling feature must have no error diagnostics despite the parse failure next to it: {good_diags:?}"
662        );
663        assert!(
664            analysis
665                .bindings
666                .iter()
667                .any(|b| b.macro_name == "greet" && b.feature == "good.feature"),
668            "the valid sibling feature must still produce its binding — no cascade from the parse-failed feature"
669        );
670    }
671
672    // A broken pack degrades gracefully: it reports its own error, but the good
673    // pack still loads, so a feature binding against a good-pack macro survives.
674    // One broken pack must never zero the whole suite's analysis (v0.5.1 fix).
675    #[test]
676    fn analyze_degrades_when_one_pack_is_broken() {
677        let mut files = BTreeMap::new();
678        files.insert(
679            "packs/good.yaml".to_owned(),
680            Arc::from(
681                "macros:\n  greet:\n    params: [who]\n    match: \"I greet {who}\"\n    steps:\n      - hurl: |\n          GET http://x\n",
682            ),
683        );
684        // `bogus` is not a recognized root key → deny_unknown_fields → this pack
685        // fails to parse and contributes proef::pack::yaml, but must not sink the rest.
686        files.insert(
687            "packs/broken.yaml".to_owned(),
688            Arc::from("macros: {}\nbogus: true\n"),
689        );
690        files.insert(
691            "f.feature".to_owned(),
692            Arc::from("Feature: F\n  Scenario: S\n    When I greet Sam\n"),
693        );
694        let provider = MemProvider {
695            features: vec!["f.feature".to_owned()],
696            packs: vec!["packs/good.yaml".to_owned(), "packs/broken.yaml".to_owned()],
697            fragments: Vec::new(),
698            files,
699        };
700        let empty = BTreeMap::new();
701        let analysis = analyze_suite(&ctx_over(&provider, &empty));
702
703        // The broken pack still reports its own diagnostic.
704        let pack_diags = analysis
705            .diagnostics
706            .get("packs/broken.yaml")
707            .expect("broken pack bucket");
708        assert!(
709            pack_diags.iter().any(|d| d.code == "proef::pack::yaml"),
710            "the broken pack must carry its yaml diagnostic: {pack_diags:?}"
711        );
712
713        // The good pack still loaded: its macro is in the vocabulary...
714        assert!(
715            analysis.macros.iter().any(|m| m.name == "greet"),
716            "the good pack's macro must survive the broken sibling"
717        );
718        // ...and the feature bound against it — no cascade, no zeroing.
719        assert!(
720            analysis
721                .bindings
722                .iter()
723                .any(|b| b.macro_name == "greet" && b.feature == "f.feature"),
724            "the feature must still bind to the good-pack macro despite the broken pack"
725        );
726    }
727
728    #[test]
729    fn analyze_records_use_refs_and_match_spans() {
730        let mut files = BTreeMap::new();
731        files.insert(
732            "packs/p.yaml".to_owned(),
733            Arc::from(
734                "macros:\n  base:\n    match: the base\n    steps:\n      - hurl: |\n          GET http://x\n  wrapper:\n    steps:\n      - use: base\n",
735            ),
736        );
737        let provider = MemProvider {
738            features: vec![],
739            packs: vec!["packs/p.yaml".to_owned()],
740            fragments: Vec::new(),
741            files,
742        };
743        let empty = BTreeMap::new();
744        let analysis = analyze_suite(&ctx_over(&provider, &empty));
745
746        // The `use: base` line is indexed, resolved to `base`.
747        let u = analysis
748            .use_refs
749            .iter()
750            .find(|u| u.target_macro == "base")
751            .expect("use_ref for base");
752        assert_eq!(u.pack, "packs/p.yaml");
753        let src = provider_text(&provider, "packs/p.yaml");
754        assert_eq!(&src[u.span.start..u.span.end], "use: base");
755
756        // `base` carries a match_span; `wrapper` (use-only) does not.
757        let base = analysis
758            .macros
759            .iter()
760            .find(|m| m.name == "base")
761            .expect("macro base");
762        assert!(base.match_span.is_some());
763        let wrapper = analysis
764            .macros
765            .iter()
766            .find(|m| m.name == "wrapper")
767            .expect("macro wrapper");
768        assert!(wrapper.match_span.is_none());
769    }
770
771    // Guards the ordinal alignment between parsed `Use` steps and textual `use:`
772    // lines: a flow-style step (`- {use: base}`) is valid YAML and parses to a
773    // `Use` step, but the line scanner's `use:`-prefix match does not see it (the
774    // line reads `- {use: base}`, not a `use:`-prefixed line after the dash
775    // strip). Mixed with a block-style `use:` line in the same macro, the parsed
776    // count (2) and textual count (1) diverge — per-ordinal pairing would silently
777    // attribute the wrong textual span to a step. `index_use_refs` must skip
778    // `UseRef` generation for that macro entirely rather than emit a wrong `Some`.
779    #[test]
780    fn analyze_skips_use_refs_when_flow_and_block_style_counts_diverge() {
781        let mut files = BTreeMap::new();
782        files.insert(
783            "packs/p.yaml".to_owned(),
784            Arc::from(
785                "macros:\n  base:\n    match: the base\n    steps:\n      - hurl: |\n          GET http://x\n  wrapper:\n    steps:\n      - {use: base}\n      - use: base\n",
786            ),
787        );
788        let provider = MemProvider {
789            features: vec![],
790            packs: vec!["packs/p.yaml".to_owned()],
791            fragments: Vec::new(),
792            files,
793        };
794        let empty = BTreeMap::new();
795        let analysis = analyze_suite(&ctx_over(&provider, &empty));
796
797        // The pack is valid (flow-style `use:` is legal YAML) — no error
798        // diagnostics, so `analyze_suite` did not short-circuit before indexing.
799        let errors: usize = analysis
800            .diagnostics
801            .values()
802            .flatten()
803            .filter(|d| d.severity == crate::diag::Severity::Error)
804            .count();
805        assert_eq!(
806            errors, 0,
807            "the mixed-style pack must be valid, zero errors: {:?}",
808            analysis.diagnostics
809        );
810
811        // `base` has no `use:` steps of its own, so any `UseRef` in this suite
812        // would have to come from `wrapper` — the count mismatch must suppress
813        // all of them (no wrong-target/wrong-span `UseRef`, per the "never a
814        // wrong `Some`" contract).
815        assert!(
816            analysis.use_refs.is_empty(),
817            "count mismatch must skip UseRef generation for wrapper entirely, \
818             not emit a misaligned pairing: {:?}",
819            analysis.use_refs
820        );
821    }
822
823    // Small helper to read a source back for span assertions.
824    fn provider_text(p: &MemProvider, name: &str) -> String {
825        p.read(name).expect("provider source").to_string()
826    }
827}