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!(
269 "match a sentence the suite's packs already bind, or \
270 add a macro to a pack:\n\nmacros:\n \
271 newMacro:\n match: {pattern}\n steps:\n - hurl: |\n \
272 GET ${{url:base}}/PATH\n HTTP 200"
273 )
274}
275
276#[cfg(test)]
277mod tests {
278 #![allow(clippy::unwrap_used)]
279
280 use super::*;
281 use crate::engine::StepKindSpec;
282 use crate::pack::{self, PackSource};
283
284 const KINDS: &[StepKindSpec] = &[StepKindSpec {
285 prefix: "hurl",
286 schema: "true",
287 validate: None,
288 }];
289
290 fn packs() -> PackSet {
291 let sources = vec![PackSource {
292 name: "test.yaml".into(),
293 text: Arc::from(
294 "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",
295 ),
296 }];
297 pack::load(&sources, KINDS).unwrap()
298 }
299
300 fn make_feature(body: &str) -> FeatureFile {
301 crate::feature::parse("t.feature", &format!("Feature: F\n Scenario: S\n{body}")).unwrap()
302 }
303
304 #[test]
305 fn macro_stub_parametrizes_quoted_tokens() {
306 let stub = macro_stub("the operator searches for \"Acme\" in 'people'");
308 assert!(
309 stub.contains("match: the operator searches for {arg1} in {arg2}"),
310 "{stub}"
311 );
312 assert!(
314 macro_stub("all done").contains("match: all done"),
315 "no-quote stub"
316 );
317 }
318
319 #[test]
320 fn captures_tables_and_defaults_assemble_args() {
321 let feature = make_feature(" When I search for \"Jansen\"\n");
322 let bound = bind(&feature, &packs()).unwrap();
323 let step = &bound[0].steps[0];
324 assert_eq!(step.macro_name, "search");
325 assert_eq!(step.args["term"], "Jansen");
326 assert_eq!(step.args["index"], "records", "default filled");
327 }
328
329 #[test]
330 fn table_overrides_defaults_but_not_captures() {
331 let feature = make_feature(" When I search for Jansen\n | index | people |\n");
332 let bound = bind(&feature, &packs()).unwrap();
333 assert_eq!(bound[0].steps[0].args["index"], "people");
334
335 let feature = make_feature(" When I search for Jansen\n | term | other |\n");
336 let errs = bind(&feature, &packs()).unwrap_err();
337 assert_eq!(errs[0].code, "proef::bind::table_conflict");
338 }
339
340 #[test]
341 fn unbound_step_suggests_the_closest_pattern() {
342 let feature = make_feature(" When I serch for Jansen\n");
343 let errs = bind(&feature, &packs()).unwrap_err();
344 assert_eq!(errs[0].code, "proef::bind::unbound_step");
345 assert!(
346 errs[0].message.contains("I search for {term}"),
347 "{}",
348 errs[0].message
349 );
350 }
351
352 #[test]
353 fn unknown_table_key_and_bad_table_shape_error() {
354 let feature = make_feature(" When I search for Jansen\n | indx | people |\n");
355 let errs = bind(&feature, &packs()).unwrap_err();
356 assert_eq!(errs[0].code, "proef::bind::unknown_table_key");
357 assert!(errs[0].message.contains("did you mean `index`?"));
358
359 let feature = make_feature(" When I search for Jansen\n | a | b | c |\n");
360 let errs = bind(&feature, &packs()).unwrap_err();
361 assert_eq!(errs[0].code, "proef::bind::bad_table");
362 }
363
364 #[test]
365 fn ambiguity_lists_all_candidates() {
366 let sources = vec![PackSource {
367 name: "test.yaml".into(),
368 text: Arc::from(
369 "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",
370 ),
371 }];
372 let packs = pack::load(&sources, KINDS).unwrap();
373 let feature = make_feature(" When do it now\n");
374 let errs = bind(&feature, &packs).unwrap_err();
375 assert_eq!(errs[0].code, "proef::bind::ambiguous_step");
376 assert!(errs[0].message.contains("`a`") && errs[0].message.contains("`b`"));
377 }
378
379 #[test]
380 fn missing_required_param_is_reported() {
381 let sources = vec![PackSource {
382 name: "test.yaml".into(),
383 text: Arc::from(
384 "macros:\n create:\n params: [firstName, lastName]\n match: I create a record\n steps:\n - hurl: |\n POST http://x/${firstName}/${lastName}\n",
385 ),
386 }];
387 let packs = pack::load(&sources, KINDS).unwrap();
388 let feature = make_feature(" When I create a record\n");
389 let errs = bind(&feature, &packs).unwrap_err();
390 assert_eq!(errs.len(), 2);
391 assert!(errs.iter().all(|d| d.code == "proef::bind::missing_param"));
392 }
393
394 #[test]
395 fn bind_collect_returns_bindings_and_diags_without_early_return() {
396 let packs = crate::pack::load(
399 &[crate::pack::PackSource {
400 name: "packs/p.yaml".to_owned(),
401 text: std::sync::Arc::from(
402 "macros:\n greet:\n params: [who]\n match: \"I greet {who}\"\n steps:\n - hurl: |\n GET http://x\n",
403 ),
404 }],
405 KINDS,
406 )
407 .unwrap();
408 let file = crate::feature::parse(
409 "f.feature",
410 "Feature: F\n Scenario: S\n When I greet Sam\n And I xyzzy\n",
411 )
412 .unwrap();
413
414 let (scenarios, diags) = bind_collect(&file, &packs);
415 let bound_step_count: usize = scenarios.iter().map(|s| s.steps.len()).sum();
417 assert_eq!(bound_step_count, 1, "the bindable step must survive");
418 assert_eq!(scenarios[0].steps[0].macro_name, "greet");
419 assert!(diags.iter().any(|d| d.code == "proef::bind::unbound_step"));
421 }
422}