1use std::collections::BTreeMap;
11use std::fmt::Write as _;
12use std::sync::Arc;
13
14use crate::diag::{Diag, Severity};
15use crate::feature::{FeatureFile, ScenarioDef, StepDefn};
16use crate::matcher;
17use crate::pack::PackSet;
18
19#[derive(Debug, Clone)]
21pub struct BoundStep {
22 pub defn: StepDefn,
24 pub macro_name: String,
26 pub args: BTreeMap<String, String>,
28}
29
30#[derive(Debug, Clone)]
32pub struct BoundScenario {
33 pub name: String,
35 pub tags: Vec<String>,
37 pub line: usize,
39 pub steps: Vec<BoundStep>,
41}
42
43pub fn bind_collect(feature: &FeatureFile, packs: &PackSet) -> (Vec<BoundScenario>, Vec<Diag>) {
47 let mut diags: Vec<Diag> = Vec::new();
48 let mut scenarios = Vec::new();
49 let defs = packs.step_defs();
50 for scenario in &feature.scenarios {
51 scenarios.push(bind_scenario(scenario, feature, packs, &defs, &mut diags));
52 }
53 (scenarios, diags)
54}
55
56pub fn bind(feature: &FeatureFile, packs: &PackSet) -> Result<Vec<BoundScenario>, Vec<Diag>> {
59 let (scenarios, diags) = bind_collect(feature, packs);
60 if diags.iter().any(|d| d.severity == Severity::Error) {
61 Err(diags)
62 } else {
63 Ok(scenarios)
64 }
65}
66
67#[allow(clippy::too_many_lines)]
69fn bind_scenario(
70 scenario: &ScenarioDef,
71 feature: &FeatureFile,
72 packs: &PackSet,
73 defs: &[(&str, &str)],
74 diags: &mut Vec<Diag>,
75) -> BoundScenario {
76 let mut steps = Vec::new();
77 for step in &scenario.steps {
78 let at = |diag: Diag| {
79 diag.with_source(feature.path.clone(), Arc::clone(&feature.source))
80 .with_span(step.span)
81 };
82
83 let candidates: Vec<(&str, &str, BTreeMap<String, String>)> = defs
84 .iter()
85 .filter_map(|(pattern, macro_name)| {
86 matcher::match_pattern(pattern, &step.text)
87 .map(|args| (*pattern, *macro_name, args))
88 })
89 .collect();
90
91 match candidates.len() {
92 0 => {
93 let suggestion = closest_pattern(&step.text, defs)
94 .map(|p| format!(" — did you mean `{p}`?"))
95 .unwrap_or_default();
96 diags.push(
97 at(Diag::error(
98 "proef::bind::unbound_step",
99 format!("no macro matches `{}`{suggestion}", step.text),
100 ))
101 .with_help(macro_stub(&step.text)),
102 );
103 continue;
104 }
105 1 => {}
106 _ => {
107 let listing = candidates
108 .iter()
109 .map(|(pattern, macro_name, _)| format!("`{macro_name}` ({pattern})"))
110 .collect::<Vec<_>>()
111 .join(", ");
112 diags.push(at(Diag::error(
113 "proef::bind::ambiguous_step",
114 format!(
115 "`{}` matches {} macros: {listing}",
116 step.text,
117 candidates.len()
118 ),
119 )));
120 continue;
121 }
122 }
123
124 let (_, macro_name, mut args) = candidates.into_iter().next().unwrap_or_default();
125 let Some(macro_) = packs.macros.get(macro_name) else {
126 continue; };
128
129 if let Some(rows) = &step.table {
131 for row in rows {
132 let [key, value] = row.as_slice() else {
133 diags.push(at(Diag::error(
134 "proef::bind::bad_table",
135 format!(
136 "data tables merge as `| key | value |` — this row has {} cells",
137 row.len()
138 ),
139 )));
140 continue;
141 };
142 if args.contains_key(key) {
143 diags.push(at(Diag::error(
144 "proef::bind::table_conflict",
145 format!("`{key}` is set both by a `{{capture}}` and the data table"),
146 )));
147 continue;
148 }
149 if !macro_.params.contains(key) {
150 let suggestion =
151 matcher::closest(key, macro_.params.iter().map(String::as_str))
152 .map(|p| format!(" — did you mean `{p}`?"))
153 .unwrap_or_default();
154 diags.push(at(Diag::error(
155 "proef::bind::unknown_table_key",
156 format!(
157 "`{key}` is not a param of macro `{}`{suggestion}",
158 macro_.name
159 ),
160 )));
161 continue;
162 }
163 args.insert(key.clone(), value.clone());
164 }
165 }
166
167 if let Some(docstring) = &step.docstring {
170 if macro_.params.iter().any(|p| p == "docstring") {
171 args.insert("docstring".to_owned(), docstring.clone());
172 } else {
173 diags.push(at(Diag::warning(
174 "proef::bind::docstring_unused",
175 format!(
176 "this step has a docstring but macro `{}` declares no `docstring` param — ignored",
177 macro_.name
178 ),
179 )));
180 }
181 }
182
183 for (param, default) in ¯o_.defaults {
185 args.entry(param.clone()).or_insert_with(|| default.clone());
186 }
187 for param in ¯o_.params {
188 if !args.contains_key(param) {
189 diags.push(at(Diag::error(
190 "proef::bind::missing_param",
191 format!(
192 "macro `{}` needs `{param}` — add a `{{{param}}}` capture, a data-table row, or a default",
193 macro_.name
194 ),
195 )));
196 }
197 }
198
199 steps.push(BoundStep {
200 defn: step.clone(),
201 macro_name: macro_name.to_owned(),
202 args,
203 });
204 }
205
206 BoundScenario {
207 name: scenario.name.clone(),
208 tags: scenario.tags.clone(),
209 line: scenario.line,
210 steps,
211 }
212}
213
214fn closest_pattern<'a>(step_text: &str, defs: &[(&'a str, &str)]) -> Option<&'a str> {
222 defs.iter()
223 .map(|(pattern, _)| {
224 let skeleton = matcher::literal_skeleton(pattern);
225 let skeleton = skeleton.trim();
226 let clipped: String = step_text.chars().take(skeleton.chars().count()).collect();
227 let distance = matcher::levenshtein(step_text, skeleton)
228 .min(matcher::levenshtein(&clipped, skeleton));
229 (distance, *pattern)
230 })
231 .filter(|(distance, _)| *distance <= 3)
232 .min_by_key(|(distance, _)| *distance)
233 .map(|(_, pattern)| pattern)
234}
235
236fn macro_stub(step_text: &str) -> String {
241 let mut pattern = String::new();
242 let mut arg = 0u32;
243 let mut chars = step_text.chars();
244 while let Some(c) = chars.next() {
245 if c == '"' || c == '\'' {
246 for q in chars.by_ref() {
248 if q == c {
249 break;
250 }
251 }
252 arg += 1;
253 let _ = write!(pattern, "{{arg{arg}}}");
254 } else {
255 pattern.push(c);
256 }
257 }
258 format!(
259 "add a macro to a pack (or fix the step text), e.g.:\n\nmacros:\n \
260 newMacro:\n match: {pattern}\n steps:\n - hurl: |\n \
261 GET ${{url:base}}/PATH\n HTTP 200"
262 )
263}
264
265#[cfg(test)]
266mod tests {
267 #![allow(clippy::unwrap_used)]
268
269 use super::*;
270 use crate::engine::StepKindSpec;
271 use crate::pack::{self, PackSource};
272
273 const KINDS: &[StepKindSpec] = &[StepKindSpec {
274 prefix: "hurl",
275 schema: "true",
276 validate: None,
277 }];
278
279 fn packs() -> PackSet {
280 let sources = vec![PackSource {
281 name: "test.yaml".into(),
282 text: Arc::from(
283 "macros:\n search:\n params: [term, index]\n defaults: { index: records }\n match: \"I search for {term}\"\n steps:\n - hurl: |\n GET http://x/${index}?q=${term}\n HTTP 200\n",
284 ),
285 }];
286 pack::load(&sources, KINDS).unwrap()
287 }
288
289 fn make_feature(body: &str) -> FeatureFile {
290 crate::feature::parse("t.feature", &format!("Feature: F\n Scenario: S\n{body}")).unwrap()
291 }
292
293 #[test]
294 fn macro_stub_parametrizes_quoted_tokens() {
295 let stub = macro_stub("the operator searches for \"Acme\" in 'people'");
297 assert!(
298 stub.contains("match: the operator searches for {arg1} in {arg2}"),
299 "{stub}"
300 );
301 assert!(
303 macro_stub("all done").contains("match: all done"),
304 "no-quote stub"
305 );
306 }
307
308 #[test]
309 fn captures_tables_and_defaults_assemble_args() {
310 let feature = make_feature(" When I search for \"Jansen\"\n");
311 let bound = bind(&feature, &packs()).unwrap();
312 let step = &bound[0].steps[0];
313 assert_eq!(step.macro_name, "search");
314 assert_eq!(step.args["term"], "Jansen");
315 assert_eq!(step.args["index"], "records", "default filled");
316 }
317
318 #[test]
319 fn table_overrides_defaults_but_not_captures() {
320 let feature = make_feature(" When I search for Jansen\n | index | people |\n");
321 let bound = bind(&feature, &packs()).unwrap();
322 assert_eq!(bound[0].steps[0].args["index"], "people");
323
324 let feature = make_feature(" When I search for Jansen\n | term | other |\n");
325 let errs = bind(&feature, &packs()).unwrap_err();
326 assert_eq!(errs[0].code, "proef::bind::table_conflict");
327 }
328
329 #[test]
330 fn unbound_step_suggests_the_closest_pattern() {
331 let feature = make_feature(" When I serch for Jansen\n");
332 let errs = bind(&feature, &packs()).unwrap_err();
333 assert_eq!(errs[0].code, "proef::bind::unbound_step");
334 assert!(
335 errs[0].message.contains("I search for {term}"),
336 "{}",
337 errs[0].message
338 );
339 }
340
341 #[test]
342 fn unknown_table_key_and_bad_table_shape_error() {
343 let feature = make_feature(" When I search for Jansen\n | indx | people |\n");
344 let errs = bind(&feature, &packs()).unwrap_err();
345 assert_eq!(errs[0].code, "proef::bind::unknown_table_key");
346 assert!(errs[0].message.contains("did you mean `index`?"));
347
348 let feature = make_feature(" When I search for Jansen\n | a | b | c |\n");
349 let errs = bind(&feature, &packs()).unwrap_err();
350 assert_eq!(errs[0].code, "proef::bind::bad_table");
351 }
352
353 #[test]
354 fn ambiguity_lists_all_candidates() {
355 let sources = vec![PackSource {
356 name: "test.yaml".into(),
357 text: Arc::from(
358 "macros:\n a:\n params: [x]\n match: \"do {x} now\"\n steps:\n - hurl: |\n GET http://x\n b:\n params: [x]\n match: \"do {x} now\"\n steps:\n - hurl: |\n GET http://y\n",
359 ),
360 }];
361 let packs = pack::load(&sources, KINDS).unwrap();
362 let feature = make_feature(" When do it now\n");
363 let errs = bind(&feature, &packs).unwrap_err();
364 assert_eq!(errs[0].code, "proef::bind::ambiguous_step");
365 assert!(errs[0].message.contains("`a`") && errs[0].message.contains("`b`"));
366 }
367
368 #[test]
369 fn missing_required_param_is_reported() {
370 let sources = vec![PackSource {
371 name: "test.yaml".into(),
372 text: Arc::from(
373 "macros:\n create:\n params: [firstName, lastName]\n match: I create a record\n steps:\n - hurl: |\n POST http://x/${firstName}/${lastName}\n",
374 ),
375 }];
376 let packs = pack::load(&sources, KINDS).unwrap();
377 let feature = make_feature(" When I create a record\n");
378 let errs = bind(&feature, &packs).unwrap_err();
379 assert_eq!(errs.len(), 2);
380 assert!(errs.iter().all(|d| d.code == "proef::bind::missing_param"));
381 }
382
383 #[test]
384 fn bind_collect_returns_bindings_and_diags_without_early_return() {
385 let packs = crate::pack::load(
388 &[crate::pack::PackSource {
389 name: "packs/p.yaml".to_owned(),
390 text: std::sync::Arc::from(
391 "macros:\n greet:\n params: [who]\n match: \"I greet {who}\"\n steps:\n - hurl: |\n GET http://x\n",
392 ),
393 }],
394 KINDS,
395 )
396 .unwrap();
397 let file = crate::feature::parse(
398 "f.feature",
399 "Feature: F\n Scenario: S\n When I greet Sam\n And I xyzzy\n",
400 )
401 .unwrap();
402
403 let (scenarios, diags) = bind_collect(&file, &packs);
404 let bound_step_count: usize = scenarios.iter().map(|s| s.steps.len()).sum();
406 assert_eq!(bound_step_count, 1, "the bindable step must survive");
407 assert_eq!(scenarios[0].steps[0].macro_name, "greet");
408 assert!(diags.iter().any(|d| d.code == "proef::bind::unbound_step"));
410 }
411}