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, Default)]
66pub struct SuiteAnalysis {
67 pub diagnostics: BTreeMap<String, Vec<Diag>>,
69 pub bindings: Vec<Binding>,
71 pub macros: Vec<MacroRef>,
73 pub use_refs: Vec<UseRef>,
75}
76
77pub struct AnalyzeCtx<'a> {
79 pub provider: &'a dyn SourceProvider,
81 pub kinds: &'a [StepKindSpec],
83 pub kind_to_engine: &'a BTreeMap<String, String>,
85 pub env: &'a BTreeMap<String, String>,
87 pub config_vars: &'a BTreeMap<String, String>,
90 pub run_id: &'a str,
92}
93
94impl SuiteAnalysis {
95 fn push_diags(&mut self, name: &str, diags: impl IntoIterator<Item = Diag>) {
96 self.diagnostics.entry(name.to_owned()).or_default();
99 for d in diags {
100 let target = d.source_name.clone().unwrap_or_else(|| name.to_owned());
103 self.diagnostics.entry(target).or_default().push(d);
104 }
105 }
106}
107
108pub fn analyze_suite(ctx: &AnalyzeCtx<'_>) -> SuiteAnalysis {
114 let mut out = SuiteAnalysis::default();
115
116 let mut sources = pack::builtin_sources();
119 let pack_names = ctx.provider.discover_packs().unwrap_or_default();
120 for name in &pack_names {
121 match ctx.provider.read(name) {
122 Ok(text) => sources.push(PackSource {
123 name: name.clone(),
124 text,
125 }),
126 Err(e) => out.push_diags(name, [read_error_diag(name, &e.0)]),
127 }
128 }
129
130 let (loaded, pack_diags) = pack::load_collecting(&sources, ctx.kinds);
134 for d in pack_diags {
135 let name = d.source_name.clone().unwrap_or_default();
136 out.push_diags(&name, [d]);
137 }
138 let packs: Arc<PackSet> = Arc::new(loaded);
139
140 for m in packs.macros.values() {
142 out.macros.push(MacroRef {
143 name: m.name.clone(),
144 pattern: m.pattern.clone(),
145 params: m.params.clone(),
146 pack: m.pack.clone(),
147 def_span: m.span,
148 match_span: m.match_span,
149 });
150 }
151
152 out.use_refs = index_use_refs(&packs);
153
154 let world = World::new(GlobalStore::default());
155
156 let feature_names = ctx.provider.discover_features().unwrap_or_default();
157 for name in &feature_names {
158 let text = match ctx.provider.read(name) {
159 Ok(t) => t,
160 Err(e) => {
161 out.push_diags(name, [read_error_diag(name, &e.0)]);
162 continue;
163 }
164 };
165 let file = match feature::parse(name, &text) {
166 Ok(f) => f,
167 Err(errs) => {
168 out.push_diags(name, errs);
169 continue; }
171 };
172
173 let (bound, bind_diags) = bind::bind_collect(&file, &packs);
174 out.push_diags(name, bind_diags);
175
176 for scenario in &bound {
177 for step in &scenario.steps {
178 out.bindings.push(Binding {
179 feature: name.clone(),
180 step_span: step.defn.span,
181 macro_name: step.macro_name.clone(),
182 });
183 }
184 }
185
186 let ctx_lower = LowerCtx {
187 feature: &file,
188 packs: &packs,
189 kind_to_engine: ctx.kind_to_engine,
190 env: ctx.env,
191 config_vars: ctx.config_vars,
192 run_id: ctx.run_id,
193 world: &world,
194 mode: crate::resolve::ResolveMode::DryRun,
195 };
196 for scenario in &bound {
197 match lower::lower(scenario, &ctx_lower) {
198 Ok(lowered) => {
199 out.push_diags(name, lowered.warnings.iter().cloned());
200 let stem = feature_stem(name);
203 if let Some(artifact) = emit::emit(&lowered, &stem, &world) {
204 let mut diags = Vec::new();
205 validate_artifact(&artifact, &lowered, ctx.kinds, &mut diags);
206 out.push_diags(name, diags);
207 }
208 }
209 Err(errs) => out.push_diags(name, errs),
210 }
211 }
212 }
213
214 out
215}
216
217fn index_use_refs(packs: &PackSet) -> Vec<UseRef> {
225 let mut use_refs = Vec::new();
226 for m in packs.macros.values() {
227 let MacroBody::Steps(steps) = &m.body else {
228 continue;
229 };
230 let targets: Vec<&str> = steps
231 .iter()
232 .filter_map(|step| match &step.kind {
233 MacroStepKind::Use { target, .. } => Some(target.as_str()),
234 MacroStepKind::Payload { .. } => None,
235 })
236 .collect();
237 let spans = crate::pack::locate::use_line_spans(&m.source, &m.name);
238 if spans.len() != targets.len() {
239 continue; }
241 for (span, target) in spans.into_iter().zip(targets) {
242 if let Some(target_macro) = packs.find_use_target(target) {
243 use_refs.push(UseRef {
244 pack: m.pack.clone(),
245 span,
246 target_macro: target_macro.name.clone(),
247 });
248 }
249 }
250 }
251 use_refs
252}
253
254fn feature_stem(name: &str) -> String {
255 std::path::Path::new(name).file_stem().map_or_else(
256 || "feature".to_owned(),
257 |s| s.to_string_lossy().into_owned(),
258 )
259}
260
261fn read_error_diag(name: &str, msg: &str) -> Diag {
262 Diag::error(
263 "proef::source::unreadable",
264 format!("cannot read {name}: {msg}"),
265 )
266 .with_source(name.to_owned(), Arc::from(""))
267}
268
269pub fn validate_artifact(
278 artifact: &emit::Artifact,
279 lowered: &lower::LoweredScenario,
280 kinds: &[StepKindSpec],
281 diags: &mut Vec<Diag>,
282) {
283 let Some(kind) = lowered
284 .batches
285 .iter()
286 .flat_map(|b| b.steps.iter())
287 .find(|s| matches!(s.payload, crate::step::StepPayload::HurlEntries(_)))
288 .map(|s| s.kind.as_str().to_owned())
289 else {
290 return;
291 };
292 let Some(validate) = kinds
293 .iter()
294 .find(|k| k.prefix == kind)
295 .and_then(|k| k.validate)
296 else {
297 return;
298 };
299 if let Err(err) = validate(&artifact.hurl_text) {
300 let offset: usize = artifact
301 .hurl_text
302 .split_inclusive('\n')
303 .take(err.line.saturating_sub(1))
304 .map(str::len)
305 .sum();
306 let line_len = artifact.hurl_text[offset..]
307 .lines()
308 .next()
309 .unwrap_or("")
310 .len();
311 diags.push(
312 Diag::error(
313 "proef::emit::invalid_artifact",
314 format!(
315 "emitted artifact `{}.hurl` does not parse: {} (line {}, column {})",
316 artifact.slug, err.message, err.line, err.column
317 ),
318 )
319 .with_source(
320 format!("{}.hurl (emitted)", artifact.slug),
321 std::sync::Arc::from(artifact.hurl_text.as_str()),
322 )
323 .with_span(Span::clamped(
324 offset,
325 offset + line_len.max(1),
326 artifact.hurl_text.len(),
327 )),
328 );
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 #![allow(clippy::expect_used)]
335
336 use super::*;
337 use crate::provider::{ProviderError, SourceProvider};
338 use std::collections::BTreeMap;
339 use std::sync::Arc;
340
341 struct MemProvider {
343 features: Vec<String>,
344 packs: Vec<String>,
345 files: BTreeMap<String, Arc<str>>,
346 }
347 impl SourceProvider for MemProvider {
348 fn discover_features(&self) -> Result<Vec<String>, ProviderError> {
349 Ok(self.features.clone())
350 }
351 fn discover_packs(&self) -> Result<Vec<String>, ProviderError> {
352 Ok(self.packs.clone())
353 }
354 fn read(&self, name: &str) -> Result<Arc<str>, ProviderError> {
355 self.files
356 .get(name)
357 .cloned()
358 .ok_or_else(|| ProviderError(format!("no source {name}")))
359 }
360 }
361
362 const KINDS: &[StepKindSpec] = &[StepKindSpec {
369 prefix: "hurl",
370 schema: "true",
371 validate: None,
372 }];
373
374 fn hurl_kind_map() -> &'static BTreeMap<String, String> {
375 use std::sync::OnceLock;
376 static M: OnceLock<BTreeMap<String, String>> = OnceLock::new();
377 M.get_or_init(|| BTreeMap::from([("hurl".to_owned(), "hurl".to_owned())]))
378 }
379
380 fn ctx_over<'a>(
381 provider: &'a dyn SourceProvider,
382 empty: &'a BTreeMap<String, String>,
383 ) -> AnalyzeCtx<'a> {
384 AnalyzeCtx {
385 provider,
386 kinds: KINDS,
387 kind_to_engine: hurl_kind_map(),
388 env: empty,
389 config_vars: empty,
390 run_id: "lsp",
391 }
392 }
393
394 #[test]
395 fn analyze_surfaces_bindings_and_no_errors_on_a_clean_suite() {
396 let mut files = BTreeMap::new();
397 files.insert(
398 "packs/p.yaml".to_owned(),
399 Arc::from(
400 "macros:\n greet:\n params: [who]\n match: \"I greet {who}\"\n steps:\n - hurl: |\n GET http://x\n",
401 ),
402 );
403 files.insert(
404 "f.feature".to_owned(),
405 Arc::from("Feature: F\n Scenario: S\n When I greet Sam\n"),
406 );
407 let provider = MemProvider {
408 features: vec!["f.feature".to_owned()],
409 packs: vec!["packs/p.yaml".to_owned()],
410 files,
411 };
412 let empty = BTreeMap::new();
413 let analysis = analyze_suite(&ctx_over(&provider, &empty));
414
415 let errors: usize = analysis
416 .diagnostics
417 .values()
418 .flatten()
419 .filter(|d| d.severity == crate::diag::Severity::Error)
420 .count();
421 assert_eq!(
422 errors, 0,
423 "clean suite must have zero errors: {:?}",
424 analysis.diagnostics
425 );
426
427 assert!(
428 analysis
429 .bindings
430 .iter()
431 .any(|b| b.macro_name == "greet" && b.feature == "f.feature"),
432 "the greet step must be recorded as a binding"
433 );
434 assert!(
435 analysis
436 .macros
437 .iter()
438 .any(|m| m.name == "greet" && m.pattern.is_some())
439 );
440 }
441
442 #[test]
443 fn analyze_collects_unbound_without_cascade() {
444 let mut files = BTreeMap::new();
445 files.insert("packs/p.yaml".to_owned(), Arc::from("macros: {}\n"));
446 files.insert(
447 "f.feature".to_owned(),
448 Arc::from("Feature: F\n Scenario: S\n When nothing matches this\n"),
449 );
450 let provider = MemProvider {
451 features: vec!["f.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 let feature_diags = analysis
458 .diagnostics
459 .get("f.feature")
460 .expect("feature bucket");
461 assert!(
462 feature_diags
463 .iter()
464 .any(|d| d.code == "proef::bind::unbound_step")
465 );
466 let errors: Vec<_> = feature_diags
467 .iter()
468 .filter(|d| d.severity == crate::diag::Severity::Error)
469 .collect();
470 assert_eq!(
471 errors.len(),
472 1,
473 "the unbound step must be the only error-severity diagnostic, no spurious extras: {feature_diags:?}"
474 );
475 assert_eq!(errors[0].code, "proef::bind::unbound_step");
476 }
477
478 #[test]
482 fn analyze_parse_failed_feature_does_not_cascade_to_sibling() {
483 let mut files = BTreeMap::new();
484 files.insert(
485 "packs/p.yaml".to_owned(),
486 Arc::from(
487 "macros:\n greet:\n params: [who]\n match: \"I greet {who}\"\n steps:\n - hurl: |\n GET http://x\n",
488 ),
489 );
490 files.insert("bad.feature".to_owned(), Arc::from(" \n"));
494 files.insert(
495 "good.feature".to_owned(),
496 Arc::from("Feature: F\n Scenario: S\n When I greet Sam\n"),
497 );
498 let provider = MemProvider {
499 features: vec!["bad.feature".to_owned(), "good.feature".to_owned()],
500 packs: vec!["packs/p.yaml".to_owned()],
501 files,
502 };
503 let empty = BTreeMap::new();
504 let analysis = analyze_suite(&ctx_over(&provider, &empty));
505
506 let bad_diags = analysis
509 .diagnostics
510 .get("bad.feature")
511 .expect("bad.feature bucket");
512 assert!(
513 bad_diags
514 .iter()
515 .any(|d| d.code == "proef::feature::empty_file"),
516 "the parse-failed feature must carry its parse-error diagnostic: {bad_diags:?}"
517 );
518
519 let good_diags = analysis
522 .diagnostics
523 .get("good.feature")
524 .expect("good.feature bucket");
525 assert!(
526 good_diags
527 .iter()
528 .all(|d| d.severity != crate::diag::Severity::Error),
529 "the valid sibling feature must have no error diagnostics despite the parse failure next to it: {good_diags:?}"
530 );
531 assert!(
532 analysis
533 .bindings
534 .iter()
535 .any(|b| b.macro_name == "greet" && b.feature == "good.feature"),
536 "the valid sibling feature must still produce its binding — no cascade from the parse-failed feature"
537 );
538 }
539
540 #[test]
544 fn analyze_degrades_when_one_pack_is_broken() {
545 let mut files = BTreeMap::new();
546 files.insert(
547 "packs/good.yaml".to_owned(),
548 Arc::from(
549 "macros:\n greet:\n params: [who]\n match: \"I greet {who}\"\n steps:\n - hurl: |\n GET http://x\n",
550 ),
551 );
552 files.insert(
555 "packs/broken.yaml".to_owned(),
556 Arc::from("macros: {}\nbogus: true\n"),
557 );
558 files.insert(
559 "f.feature".to_owned(),
560 Arc::from("Feature: F\n Scenario: S\n When I greet Sam\n"),
561 );
562 let provider = MemProvider {
563 features: vec!["f.feature".to_owned()],
564 packs: vec!["packs/good.yaml".to_owned(), "packs/broken.yaml".to_owned()],
565 files,
566 };
567 let empty = BTreeMap::new();
568 let analysis = analyze_suite(&ctx_over(&provider, &empty));
569
570 let pack_diags = analysis
572 .diagnostics
573 .get("packs/broken.yaml")
574 .expect("broken pack bucket");
575 assert!(
576 pack_diags.iter().any(|d| d.code == "proef::pack::yaml"),
577 "the broken pack must carry its yaml diagnostic: {pack_diags:?}"
578 );
579
580 assert!(
582 analysis.macros.iter().any(|m| m.name == "greet"),
583 "the good pack's macro must survive the broken sibling"
584 );
585 assert!(
587 analysis
588 .bindings
589 .iter()
590 .any(|b| b.macro_name == "greet" && b.feature == "f.feature"),
591 "the feature must still bind to the good-pack macro despite the broken pack"
592 );
593 }
594
595 #[test]
596 fn analyze_records_use_refs_and_match_spans() {
597 let mut files = BTreeMap::new();
598 files.insert(
599 "packs/p.yaml".to_owned(),
600 Arc::from(
601 "macros:\n base:\n match: the base\n steps:\n - hurl: |\n GET http://x\n wrapper:\n steps:\n - use: base\n",
602 ),
603 );
604 let provider = MemProvider {
605 features: vec![],
606 packs: vec!["packs/p.yaml".to_owned()],
607 files,
608 };
609 let empty = BTreeMap::new();
610 let analysis = analyze_suite(&ctx_over(&provider, &empty));
611
612 let u = analysis
614 .use_refs
615 .iter()
616 .find(|u| u.target_macro == "base")
617 .expect("use_ref for base");
618 assert_eq!(u.pack, "packs/p.yaml");
619 let src = provider_text(&provider, "packs/p.yaml");
620 assert_eq!(&src[u.span.start..u.span.end], "use: base");
621
622 let base = analysis
624 .macros
625 .iter()
626 .find(|m| m.name == "base")
627 .expect("macro base");
628 assert!(base.match_span.is_some());
629 let wrapper = analysis
630 .macros
631 .iter()
632 .find(|m| m.name == "wrapper")
633 .expect("macro wrapper");
634 assert!(wrapper.match_span.is_none());
635 }
636
637 #[test]
646 fn analyze_skips_use_refs_when_flow_and_block_style_counts_diverge() {
647 let mut files = BTreeMap::new();
648 files.insert(
649 "packs/p.yaml".to_owned(),
650 Arc::from(
651 "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",
652 ),
653 );
654 let provider = MemProvider {
655 features: vec![],
656 packs: vec!["packs/p.yaml".to_owned()],
657 files,
658 };
659 let empty = BTreeMap::new();
660 let analysis = analyze_suite(&ctx_over(&provider, &empty));
661
662 let errors: usize = analysis
665 .diagnostics
666 .values()
667 .flatten()
668 .filter(|d| d.severity == crate::diag::Severity::Error)
669 .count();
670 assert_eq!(
671 errors, 0,
672 "the mixed-style pack must be valid, zero errors: {:?}",
673 analysis.diagnostics
674 );
675
676 assert!(
681 analysis.use_refs.is_empty(),
682 "count mismatch must skip UseRef generation for wrapper entirely, \
683 not emit a misaligned pairing: {:?}",
684 analysis.use_refs
685 );
686 }
687
688 fn provider_text(p: &MemProvider, name: &str) -> String {
690 p.read(name).expect("provider source").to_string()
691 }
692}