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, 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}
48
49#[derive(Debug, Default)]
51pub struct SuiteAnalysis {
52 pub diagnostics: BTreeMap<String, Vec<Diag>>,
54 pub bindings: Vec<Binding>,
56 pub macros: Vec<MacroRef>,
58}
59
60pub struct AnalyzeCtx<'a> {
62 pub provider: &'a dyn SourceProvider,
64 pub kinds: &'a [StepKindSpec],
66 pub kind_to_engine: &'a BTreeMap<String, String>,
68 pub env: &'a BTreeMap<String, String>,
70 pub config_vars: &'a BTreeMap<String, String>,
73 pub run_id: &'a str,
75}
76
77impl SuiteAnalysis {
78 fn push_diags(&mut self, name: &str, diags: impl IntoIterator<Item = Diag>) {
79 self.diagnostics.entry(name.to_owned()).or_default();
82 for d in diags {
83 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
91pub fn analyze_suite(ctx: &AnalyzeCtx<'_>) -> SuiteAnalysis {
96 let mut out = SuiteAnalysis::default();
97
98 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; }
121 };
122
123 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; }
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 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
221pub 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 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 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 #[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 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 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 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 #[test]
495 fn analyze_broken_pack_short_circuits_before_feature_binding() {
496 let mut files = BTreeMap::new();
497 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 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 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}