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, 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}
48
49/// The product of one wholesale recompute: every feature's read from here.
50#[derive(Debug, Default)]
51pub struct SuiteAnalysis {
52    /// source name → its diagnostics (features and packs alike).
53    pub diagnostics: BTreeMap<String, Vec<Diag>>,
54    /// Every prose-step-to-macro binding across the suite.
55    pub bindings: Vec<Binding>,
56    /// Every macro definition across the loaded packs.
57    pub macros: Vec<MacroRef>,
58}
59
60/// Everything `analyze_suite` needs, injected at the IO edge (sans-IO core).
61pub struct AnalyzeCtx<'a> {
62    /// The source of feature and pack bytes (the IO edge lives behind it).
63    pub provider: &'a dyn SourceProvider,
64    /// Registered engine step kinds (drives pack validation and artifact probes).
65    pub kinds: &'a [StepKindSpec],
66    /// Step-kind prefix → engine id, the lowering routing table.
67    pub kind_to_engine: &'a BTreeMap<String, String>,
68    /// Injected environment snapshot (`${env:…}`).
69    pub env: &'a BTreeMap<String, String>,
70    /// Injected `proef.toml` config scope (`${url:…}` / `${vars:…}`), with the
71    /// active `[env.<name>]` already deep-merged in.
72    pub config_vars: &'a BTreeMap<String, String>,
73    /// Injected run identifier (`${run:id}`).
74    pub run_id: &'a str,
75}
76
77impl SuiteAnalysis {
78    fn push_diags(&mut self, name: &str, diags: impl IntoIterator<Item = Diag>) {
79        // Ensure the primary source has a bucket even with no diagnostics, so a
80        // now-clean file still surfaces an empty set that clears stale marks.
81        self.diagnostics.entry(name.to_owned()).or_default();
82        for d in diags {
83            // Prefer the diagnostic's own source name when it carries one, so a
84            // pack error raised while analyzing a feature lands on the pack.
85            let target = d.source_name.clone().unwrap_or_else(|| name.to_owned());
86            self.diagnostics.entry(target).or_default().push(d);
87        }
88    }
89}
90
91/// Recompute the whole suite in one pass: read every pack and feature through
92/// the provider, accumulate every diagnostic per source name, and record the
93/// binding and macro relations editors need. Broken packs short-circuit feature
94/// binding (no cascade); a parse-failed feature is skipped, not fatal.
95pub fn analyze_suite(ctx: &AnalyzeCtx<'_>) -> SuiteAnalysis {
96    let mut out = SuiteAnalysis::default();
97
98    // Packs first: a broken pack blocks binding, so on pack failure we publish
99    // pack diagnostics and skip feature binding (no cascade).
100    let mut sources = pack::builtin_sources();
101    let pack_names = ctx.provider.discover_packs().unwrap_or_default();
102    for name in &pack_names {
103        match ctx.provider.read(name) {
104            Ok(text) => sources.push(PackSource {
105                name: name.clone(),
106                text,
107            }),
108            Err(e) => out.push_diags(name, [read_error_diag(name, &e.0)]),
109        }
110    }
111
112    let packs: Arc<PackSet> = match pack::load(&sources, ctx.kinds) {
113        Ok(set) => Arc::new(set),
114        Err(err) => {
115            for d in front_error_diags(err) {
116                let name = d.source_name.clone().unwrap_or_default();
117                out.push_diags(&name, [d]);
118            }
119            return out; // packs broken → do not cascade into feature binding
120        }
121    };
122
123    // Macro vocabulary for completion / go-to-def targets.
124    for m in packs.macros.values() {
125        out.macros.push(MacroRef {
126            name: m.name.clone(),
127            pattern: m.pattern.clone(),
128            params: m.params.clone(),
129            pack: m.pack.clone(),
130            def_span: m.span,
131        });
132    }
133
134    let world = World::new(GlobalStore::default());
135
136    let feature_names = ctx.provider.discover_features().unwrap_or_default();
137    for name in &feature_names {
138        let text = match ctx.provider.read(name) {
139            Ok(t) => t,
140            Err(e) => {
141                out.push_diags(name, [read_error_diag(name, &e.0)]);
142                continue;
143            }
144        };
145        let file = match feature::parse(name, &text) {
146            Ok(f) => f,
147            Err(errs) => {
148                out.push_diags(name, errs);
149                continue; // parse failed → skip downstream, no cascade
150            }
151        };
152
153        let (bound, bind_diags) = bind::bind_collect(&file, &packs);
154        out.push_diags(name, bind_diags);
155
156        for scenario in &bound {
157            for step in &scenario.steps {
158                out.bindings.push(Binding {
159                    feature: name.clone(),
160                    step_span: step.defn.span,
161                    macro_name: step.macro_name.clone(),
162                });
163            }
164        }
165
166        let ctx_lower = LowerCtx {
167            feature: &file,
168            packs: &packs,
169            kind_to_engine: ctx.kind_to_engine,
170            env: ctx.env,
171            config_vars: ctx.config_vars,
172            run_id: ctx.run_id,
173            world: &world,
174            mode: crate::resolve::ResolveMode::DryRun,
175        };
176        for scenario in &bound {
177            match lower::lower(scenario, &ctx_lower) {
178                Ok(lowered) => {
179                    out.push_diags(name, lowered.warnings.iter().cloned());
180                    // Emit + artifact validation is executed for its diagnostics
181                    // only; the artifact text is discarded.
182                    let stem = feature_stem(name);
183                    if let Some(artifact) = emit::emit(&lowered, &stem, &world) {
184                        let mut diags = Vec::new();
185                        validate_artifact(&artifact, &lowered, ctx.kinds, &mut diags);
186                        out.push_diags(name, diags);
187                    }
188                }
189                Err(errs) => out.push_diags(name, errs),
190            }
191        }
192    }
193
194    out
195}
196
197fn feature_stem(name: &str) -> String {
198    std::path::Path::new(name).file_stem().map_or_else(
199        || "feature".to_owned(),
200        |s| s.to_string_lossy().into_owned(),
201    )
202}
203
204fn read_error_diag(name: &str, msg: &str) -> Diag {
205    Diag::error(
206        "proef::source::unreadable",
207        format!("cannot read {name}: {msg}"),
208    )
209    .with_source(name.to_owned(), Arc::from(""))
210}
211
212fn front_error_diags(err: crate::diag::FrontError) -> Vec<Diag> {
213    match err {
214        crate::diag::FrontError::Diagnostics(list) => list,
215        crate::diag::FrontError::Core(core) => {
216            vec![Diag::error("proef::pack::load", core.to_string())]
217        }
218    }
219}
220
221/// Parse-validate the exact emitted artifact text with the claiming engine's
222/// real parser (`--dry-run` = §4.1–4.5 including artifact parse-validation).
223/// The diagnostic's source is the emitted text itself, span at the broken line.
224///
225/// This is the single implementation of artifact parse-validation, shared by the
226/// CLI's fail-fast `front::run` and the LSP's collect-all `analyze_suite`. It
227/// reaches the hurl parser only through the injected [`StepKindSpec::validate`]
228/// function pointer, so it stays engine-agnostic and lives in the sans-IO core.
229pub fn validate_artifact(
230    artifact: &emit::Artifact,
231    lowered: &lower::LoweredScenario,
232    kinds: &[StepKindSpec],
233    diags: &mut Vec<Diag>,
234) {
235    let Some(kind) = lowered
236        .batches
237        .iter()
238        .flat_map(|b| b.steps.iter())
239        .find(|s| matches!(s.payload, crate::step::StepPayload::HurlEntries(_)))
240        .map(|s| s.kind.as_str().to_owned())
241    else {
242        return;
243    };
244    let Some(validate) = kinds
245        .iter()
246        .find(|k| k.prefix == kind)
247        .and_then(|k| k.validate)
248    else {
249        return;
250    };
251    if let Err(err) = validate(&artifact.hurl_text) {
252        let offset: usize = artifact
253            .hurl_text
254            .split_inclusive('\n')
255            .take(err.line.saturating_sub(1))
256            .map(str::len)
257            .sum();
258        let line_len = artifact.hurl_text[offset..]
259            .lines()
260            .next()
261            .unwrap_or("")
262            .len();
263        diags.push(
264            Diag::error(
265                "proef::emit::invalid_artifact",
266                format!(
267                    "emitted artifact `{}.hurl` does not parse: {} (line {}, column {})",
268                    artifact.slug, err.message, err.line, err.column
269                ),
270            )
271            .with_source(
272                format!("{}.hurl (emitted)", artifact.slug),
273                std::sync::Arc::from(artifact.hurl_text.as_str()),
274            )
275            .with_span(Span::clamped(
276                offset,
277                offset + line_len.max(1),
278                artifact.hurl_text.len(),
279            )),
280        );
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    #![allow(clippy::expect_used)]
287
288    use super::*;
289    use crate::provider::{ProviderError, SourceProvider};
290    use std::collections::BTreeMap;
291    use std::sync::Arc;
292
293    /// A provider backed by an in-memory map — keeps the test sans-IO.
294    struct MemProvider {
295        features: Vec<String>,
296        packs: Vec<String>,
297        files: BTreeMap<String, Arc<str>>,
298    }
299    impl SourceProvider for MemProvider {
300        fn discover_features(&self) -> Result<Vec<String>, ProviderError> {
301            Ok(self.features.clone())
302        }
303        fn discover_packs(&self) -> Result<Vec<String>, ProviderError> {
304            Ok(self.packs.clone())
305        }
306        fn read(&self, name: &str) -> Result<Arc<str>, ProviderError> {
307            self.files
308                .get(name)
309                .cloned()
310                .ok_or_else(|| ProviderError(format!("no source {name}")))
311        }
312    }
313
314    // Pack validation (step-kind claim, pass 8) and lowering's routing invariant
315    // both require `hurl` to be a registered kind: with an empty registry a
316    // `hurl:` step is `unknown_step_kind` and an unrouted lowered step. The
317    // analyzer needs no *live* engine, though — a spec with no `validate` probe
318    // (artifact parse-validation is skipped) plus a `hurl → hurl` route is enough
319    // to bind, lower, and extract spans.
320    const KINDS: &[StepKindSpec] = &[StepKindSpec {
321        prefix: "hurl",
322        schema: "true",
323        validate: None,
324    }];
325
326    fn hurl_kind_map() -> &'static BTreeMap<String, String> {
327        use std::sync::OnceLock;
328        static M: OnceLock<BTreeMap<String, String>> = OnceLock::new();
329        M.get_or_init(|| BTreeMap::from([("hurl".to_owned(), "hurl".to_owned())]))
330    }
331
332    fn ctx_over<'a>(
333        provider: &'a dyn SourceProvider,
334        empty: &'a BTreeMap<String, String>,
335    ) -> AnalyzeCtx<'a> {
336        AnalyzeCtx {
337            provider,
338            kinds: KINDS,
339            kind_to_engine: hurl_kind_map(),
340            env: empty,
341            config_vars: empty,
342            run_id: "lsp",
343        }
344    }
345
346    #[test]
347    fn analyze_surfaces_bindings_and_no_errors_on_a_clean_suite() {
348        let mut files = BTreeMap::new();
349        files.insert(
350            "packs/p.yaml".to_owned(),
351            Arc::from(
352                "macros:\n  greet:\n    params: [who]\n    match: \"I greet {who}\"\n    steps:\n      - hurl: |\n          GET http://x\n",
353            ),
354        );
355        files.insert(
356            "f.feature".to_owned(),
357            Arc::from("Feature: F\n  Scenario: S\n    When I greet Sam\n"),
358        );
359        let provider = MemProvider {
360            features: vec!["f.feature".to_owned()],
361            packs: vec!["packs/p.yaml".to_owned()],
362            files,
363        };
364        let empty = BTreeMap::new();
365        let analysis = analyze_suite(&ctx_over(&provider, &empty));
366
367        let errors: usize = analysis
368            .diagnostics
369            .values()
370            .flatten()
371            .filter(|d| d.severity == crate::diag::Severity::Error)
372            .count();
373        assert_eq!(
374            errors, 0,
375            "clean suite must have zero errors: {:?}",
376            analysis.diagnostics
377        );
378
379        assert!(
380            analysis
381                .bindings
382                .iter()
383                .any(|b| b.macro_name == "greet" && b.feature == "f.feature"),
384            "the greet step must be recorded as a binding"
385        );
386        assert!(
387            analysis
388                .macros
389                .iter()
390                .any(|m| m.name == "greet" && m.pattern.is_some())
391        );
392    }
393
394    #[test]
395    fn analyze_collects_unbound_without_cascade() {
396        let mut files = BTreeMap::new();
397        files.insert("packs/p.yaml".to_owned(), Arc::from("macros: {}\n"));
398        files.insert(
399            "f.feature".to_owned(),
400            Arc::from("Feature: F\n  Scenario: S\n    When nothing matches this\n"),
401        );
402        let provider = MemProvider {
403            features: vec!["f.feature".to_owned()],
404            packs: vec!["packs/p.yaml".to_owned()],
405            files,
406        };
407        let empty = BTreeMap::new();
408        let analysis = analyze_suite(&ctx_over(&provider, &empty));
409        let feature_diags = analysis
410            .diagnostics
411            .get("f.feature")
412            .expect("feature bucket");
413        assert!(
414            feature_diags
415                .iter()
416                .any(|d| d.code == "proef::bind::unbound_step")
417        );
418        let errors: Vec<_> = feature_diags
419            .iter()
420            .filter(|d| d.severity == crate::diag::Severity::Error)
421            .collect();
422        assert_eq!(
423            errors.len(),
424            1,
425            "the unbound step must be the only error-severity diagnostic, no spurious extras: {feature_diags:?}"
426        );
427        assert_eq!(errors[0].code, "proef::bind::unbound_step");
428    }
429
430    // §9's headline robustness property: a parse failure suppresses only its own
431    // file's downstream diagnostics — it must not cascade into a sibling feature's
432    // binding. Two features share one valid pack; only one of them fails to parse.
433    #[test]
434    fn analyze_parse_failed_feature_does_not_cascade_to_sibling() {
435        let mut files = BTreeMap::new();
436        files.insert(
437            "packs/p.yaml".to_owned(),
438            Arc::from(
439                "macros:\n  greet:\n    params: [who]\n    match: \"I greet {who}\"\n    steps:\n      - hurl: |\n          GET http://x\n",
440            ),
441        );
442        // Whitespace-only text: feature::parse's own empty-file guard
443        // (`normalized.trim().is_empty()`) returns `Err` before the gherkin
444        // parser even runs — a guaranteed, deliberate parse failure.
445        files.insert("bad.feature".to_owned(), Arc::from("   \n"));
446        files.insert(
447            "good.feature".to_owned(),
448            Arc::from("Feature: F\n  Scenario: S\n    When I greet Sam\n"),
449        );
450        let provider = MemProvider {
451            features: vec!["bad.feature".to_owned(), "good.feature".to_owned()],
452            packs: vec!["packs/p.yaml".to_owned()],
453            files,
454        };
455        let empty = BTreeMap::new();
456        let analysis = analyze_suite(&ctx_over(&provider, &empty));
457
458        // The parse-failed feature carries its own parse-error diagnostic —
459        // proof the `feature::parse` `Err` branch was actually hit.
460        let bad_diags = analysis
461            .diagnostics
462            .get("bad.feature")
463            .expect("bad.feature bucket");
464        assert!(
465            bad_diags
466                .iter()
467                .any(|d| d.code == "proef::feature::empty_file"),
468            "the parse-failed feature must carry its parse-error diagnostic: {bad_diags:?}"
469        );
470
471        // The sibling feature is untouched: no error diagnostics, and its
472        // binding still made it through — proof there was no cascade.
473        let good_diags = analysis
474            .diagnostics
475            .get("good.feature")
476            .expect("good.feature bucket");
477        assert!(
478            good_diags
479                .iter()
480                .all(|d| d.severity != crate::diag::Severity::Error),
481            "the valid sibling feature must have no error diagnostics despite the parse failure next to it: {good_diags:?}"
482        );
483        assert!(
484            analysis
485                .bindings
486                .iter()
487                .any(|b| b.macro_name == "greet" && b.feature == "good.feature"),
488            "the valid sibling feature must still produce its binding — no cascade from the parse-failed feature"
489        );
490    }
491
492    // §9's other half: a broken pack short-circuits before feature binding even
493    // starts, so a perfectly normal feature must not be falsely reported.
494    #[test]
495    fn analyze_broken_pack_short_circuits_before_feature_binding() {
496        let mut files = BTreeMap::new();
497        // `deny_unknown_fields` on the pack schema's root: `bogus` is not a
498        // recognized key, so `serde_norway::from_str::<RawPack>` returns `Err`
499        // and `pack::load` returns `Err(FrontError::Diagnostics(..))` carrying
500        // `proef::pack::yaml` (mirrors `tests/errors/pack__yaml`).
501        files.insert(
502            "packs/broken.yaml".to_owned(),
503            Arc::from("macros: {}\nbogus: true\n"),
504        );
505        files.insert(
506            "f.feature".to_owned(),
507            Arc::from("Feature: F\n  Scenario: S\n    When I greet Sam\n"),
508        );
509        let provider = MemProvider {
510            features: vec!["f.feature".to_owned()],
511            packs: vec!["packs/broken.yaml".to_owned()],
512            files,
513        };
514        let empty = BTreeMap::new();
515        let analysis = analyze_suite(&ctx_over(&provider, &empty));
516
517        // The broken pack carries its yaml diagnostic — proof `pack::load`'s
518        // `Err` branch was actually hit.
519        let pack_diags = analysis
520            .diagnostics
521            .get("packs/broken.yaml")
522            .expect("pack bucket");
523        assert!(
524            pack_diags.iter().any(|d| d.code == "proef::pack::yaml"),
525            "the broken pack must carry its yaml diagnostic: {pack_diags:?}"
526        );
527
528        // Feature binding never ran: no bindings recorded anywhere, and the
529        // feature was not even visited (no bucket, so certainly no
530        // `proef::bind::*` diagnostics) — proof `analyze_suite` returned early.
531        assert!(
532            analysis.bindings.is_empty(),
533            "no bindings should be produced when the pack fails to load: {:?}",
534            analysis.bindings
535        );
536        let feature_diags = analysis.diagnostics.get("f.feature");
537        assert!(
538            feature_diags
539                .is_none_or(|diags| !diags.iter().any(|d| d.code.starts_with("proef::bind::"))),
540            "the feature must not be falsely reported when the pack short-circuited binding: {feature_diags:?}"
541        );
542    }
543}