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