1use 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#[derive(Debug, Clone)]
24pub struct Binding {
25 pub feature: String,
27 pub step_span: Span,
29 pub macro_name: String,
31}
32
33#[derive(Debug, Clone)]
35pub struct MacroRef {
36 pub name: String,
38 pub pattern: Option<String>,
40 pub params: Vec<String>,
42 pub pack: String,
44 pub def_span: Option<Span>,
47 pub match_span: Option<Span>,
50}
51
52#[derive(Debug, Clone)]
55pub struct UseRef {
56 pub pack: String,
58 pub span: Span,
60 pub target_macro: String,
62}
63
64#[derive(Debug, Clone)]
67pub struct FragmentRef {
68 pub pack: String,
70 pub span: Span,
72 pub target_fragment: String,
74}
75
76#[derive(Debug, Clone)]
79pub struct FragmentDef {
80 pub name: String,
82 pub file: String,
84 pub span: Option<Span>,
87 pub source: Arc<str>,
91 pub placeholders: Vec<String>,
103 pub supplied_variables: Vec<String>,
109}
110
111#[derive(Debug, Default)]
113pub struct SuiteAnalysis {
114 pub diagnostics: BTreeMap<String, Vec<Diag>>,
116 pub bindings: Vec<Binding>,
118 pub macros: Vec<MacroRef>,
120 pub use_refs: Vec<UseRef>,
122 pub fragment_refs: Vec<FragmentRef>,
124 pub fragments: Vec<FragmentDef>,
126}
127
128pub struct AnalyzeCtx<'a> {
130 pub provider: &'a dyn SourceProvider,
132 pub kinds: &'a [StepKindSpec],
134 pub kind_to_engine: &'a BTreeMap<String, String>,
136 pub env: &'a BTreeMap<String, String>,
138 pub config_vars: &'a BTreeMap<String, String>,
141 pub run_id: &'a str,
143 pub fragments: &'a pack::FragmentCorpus,
152}
153
154impl SuiteAnalysis {
155 fn push_diags(&mut self, name: &str, diags: impl IntoIterator<Item = Diag>) {
156 self.diagnostics.entry(name.to_owned()).or_default();
159 for d in diags {
160 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
168pub fn analyze_suite(ctx: &AnalyzeCtx<'_>) -> SuiteAnalysis {
174 let mut out = SuiteAnalysis::default();
175
176 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 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 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; }
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 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
282fn 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
300fn 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 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
349fn 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
368fn 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 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
404pub 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 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 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 #[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 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 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 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 #[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 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 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 assert!(
734 analysis.macros.iter().any(|m| m.name == "greet"),
735 "the good pack's macro must survive the broken sibling"
736 );
737 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 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 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 #[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 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 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 fn provider_text(p: &MemProvider, name: &str) -> String {
844 p.read(name).expect("provider source").to_string()
845 }
846}