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}
144
145impl SuiteAnalysis {
146 fn push_diags(&mut self, name: &str, diags: impl IntoIterator<Item = Diag>) {
147 self.diagnostics.entry(name.to_owned()).or_default();
150 for d in diags {
151 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
159pub fn analyze_suite(ctx: &AnalyzeCtx<'_>) -> SuiteAnalysis {
165 let mut out = SuiteAnalysis::default();
166
167 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 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 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; }
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 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
284fn 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
302fn 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 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
339fn 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 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; }
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
393pub 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 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 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 #[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 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 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 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 #[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 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 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 assert!(
715 analysis.macros.iter().any(|m| m.name == "greet"),
716 "the good pack's macro must survive the broken sibling"
717 );
718 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 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 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 #[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 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 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 fn provider_text(p: &MemProvider, name: &str) -> String {
825 p.read(name).expect("provider source").to_string()
826 }
827}