1use std::collections::BTreeMap;
11use std::sync::Arc;
12
13use crate::diag::{Diag, Severity};
14use crate::feature::{FeatureFile, ScenarioDef, StepDefn};
15use crate::matcher;
16use crate::pack::PackSet;
17
18#[derive(Debug, Clone)]
20pub struct BoundStep {
21 pub defn: StepDefn,
23 pub macro_name: String,
25 pub args: BTreeMap<String, String>,
27}
28
29#[derive(Debug, Clone)]
31pub struct BoundScenario {
32 pub name: String,
34 pub tags: Vec<String>,
36 pub line: usize,
38 pub steps: Vec<BoundStep>,
40}
41
42pub fn bind(feature: &FeatureFile, packs: &PackSet) -> Result<Vec<BoundScenario>, Vec<Diag>> {
45 let mut diags: Vec<Diag> = Vec::new();
46 let mut scenarios = Vec::new();
47 let defs = packs.step_defs();
48
49 for scenario in &feature.scenarios {
50 scenarios.push(bind_scenario(scenario, feature, packs, &defs, &mut diags));
51 }
52
53 if diags.iter().any(|d| d.severity == Severity::Error) {
54 Err(diags)
55 } else {
56 Ok(scenarios)
57 }
58}
59
60#[allow(clippy::too_many_lines)]
62fn bind_scenario(
63 scenario: &ScenarioDef,
64 feature: &FeatureFile,
65 packs: &PackSet,
66 defs: &[(&str, &str)],
67 diags: &mut Vec<Diag>,
68) -> BoundScenario {
69 let mut steps = Vec::new();
70 for step in &scenario.steps {
71 let at = |diag: Diag| {
72 diag.with_source(feature.path.clone(), Arc::clone(&feature.source))
73 .with_span(step.span)
74 };
75
76 let candidates: Vec<(&str, &str, BTreeMap<String, String>)> = defs
77 .iter()
78 .filter_map(|(pattern, macro_name)| {
79 matcher::match_pattern(pattern, &step.text)
80 .map(|args| (*pattern, *macro_name, args))
81 })
82 .collect();
83
84 match candidates.len() {
85 0 => {
86 let suggestion = closest_pattern(&step.text, defs)
87 .map(|p| format!(" — did you mean `{p}`?"))
88 .unwrap_or_default();
89 diags.push(
90 at(Diag::error(
91 "proef::bind::unbound_step",
92 format!("no macro matches `{}`{suggestion}", step.text),
93 ))
94 .with_help("add a `match:` pattern to a pack macro, or fix the step text"),
95 );
96 continue;
97 }
98 1 => {}
99 _ => {
100 let listing = candidates
101 .iter()
102 .map(|(pattern, macro_name, _)| format!("`{macro_name}` ({pattern})"))
103 .collect::<Vec<_>>()
104 .join(", ");
105 diags.push(at(Diag::error(
106 "proef::bind::ambiguous_step",
107 format!(
108 "`{}` matches {} macros: {listing}",
109 step.text,
110 candidates.len()
111 ),
112 )));
113 continue;
114 }
115 }
116
117 let (_, macro_name, mut args) = candidates.into_iter().next().unwrap_or_default();
118 let Some(macro_) = packs.macros.get(macro_name) else {
119 continue; };
121
122 if let Some(rows) = &step.table {
124 for row in rows {
125 let [key, value] = row.as_slice() else {
126 diags.push(at(Diag::error(
127 "proef::bind::bad_table",
128 format!(
129 "data tables merge as `| key | value |` — this row has {} cells",
130 row.len()
131 ),
132 )));
133 continue;
134 };
135 if args.contains_key(key) {
136 diags.push(at(Diag::error(
137 "proef::bind::table_conflict",
138 format!("`{key}` is set both by a `{{capture}}` and the data table"),
139 )));
140 continue;
141 }
142 if !macro_.params.contains(key) {
143 let suggestion =
144 matcher::closest(key, macro_.params.iter().map(String::as_str))
145 .map(|p| format!(" — did you mean `{p}`?"))
146 .unwrap_or_default();
147 diags.push(at(Diag::error(
148 "proef::bind::unknown_table_key",
149 format!(
150 "`{key}` is not a param of macro `{}`{suggestion}",
151 macro_.name
152 ),
153 )));
154 continue;
155 }
156 args.insert(key.clone(), value.clone());
157 }
158 }
159
160 if let Some(docstring) = &step.docstring {
163 if macro_.params.iter().any(|p| p == "docstring") {
164 args.insert("docstring".to_owned(), docstring.clone());
165 } else {
166 diags.push(at(Diag::warning(
167 "proef::bind::docstring_unused",
168 format!(
169 "this step has a docstring but macro `{}` declares no `docstring` param — ignored",
170 macro_.name
171 ),
172 )));
173 }
174 }
175
176 for (param, default) in ¯o_.defaults {
178 args.entry(param.clone()).or_insert_with(|| default.clone());
179 }
180 for param in ¯o_.params {
181 if !args.contains_key(param) {
182 diags.push(at(Diag::error(
183 "proef::bind::missing_param",
184 format!(
185 "macro `{}` needs `{param}` — add a `{{{param}}}` capture, a data-table row, or a default",
186 macro_.name
187 ),
188 )));
189 }
190 }
191
192 steps.push(BoundStep {
193 defn: step.clone(),
194 macro_name: macro_name.to_owned(),
195 args,
196 });
197 }
198
199 BoundScenario {
200 name: scenario.name.clone(),
201 tags: scenario.tags.clone(),
202 line: scenario.line,
203 steps,
204 }
205}
206
207fn closest_pattern<'a>(step_text: &str, defs: &[(&'a str, &str)]) -> Option<&'a str> {
215 defs.iter()
216 .map(|(pattern, _)| {
217 let skeleton = matcher::literal_skeleton(pattern);
218 let skeleton = skeleton.trim();
219 let clipped: String = step_text.chars().take(skeleton.chars().count()).collect();
220 let distance = matcher::levenshtein(step_text, skeleton)
221 .min(matcher::levenshtein(&clipped, skeleton));
222 (distance, *pattern)
223 })
224 .filter(|(distance, _)| *distance <= 3)
225 .min_by_key(|(distance, _)| *distance)
226 .map(|(_, pattern)| pattern)
227}
228
229#[cfg(test)]
230mod tests {
231 #![allow(clippy::unwrap_used)]
232
233 use super::*;
234 use crate::engine::StepKindSpec;
235 use crate::pack::{self, PackSource};
236
237 const KINDS: &[StepKindSpec] = &[StepKindSpec {
238 prefix: "hurl",
239 schema: "true",
240 validate: None,
241 }];
242
243 fn packs() -> PackSet {
244 let sources = vec![PackSource {
245 name: "test.yaml".into(),
246 text: Arc::from(
247 "templates:\n search:\n params: [term, index]\n defaults: { index: clients }\n match: \"I search for {term}\"\n steps:\n - hurl: |\n GET http://x/${index}?q=${term}\n HTTP 200\n",
248 ),
249 }];
250 pack::load(&sources, KINDS).unwrap()
251 }
252
253 fn make_feature(body: &str) -> FeatureFile {
254 crate::feature::parse("t.feature", &format!("Feature: F\n Scenario: S\n{body}")).unwrap()
255 }
256
257 #[test]
258 fn captures_tables_and_defaults_assemble_args() {
259 let feature = make_feature(" When I search for \"Jansen\"\n");
260 let bound = bind(&feature, &packs()).unwrap();
261 let step = &bound[0].steps[0];
262 assert_eq!(step.macro_name, "search");
263 assert_eq!(step.args["term"], "Jansen");
264 assert_eq!(step.args["index"], "clients", "default filled");
265 }
266
267 #[test]
268 fn table_overrides_defaults_but_not_captures() {
269 let feature = make_feature(" When I search for Jansen\n | index | users |\n");
270 let bound = bind(&feature, &packs()).unwrap();
271 assert_eq!(bound[0].steps[0].args["index"], "users");
272
273 let feature = make_feature(" When I search for Jansen\n | term | other |\n");
274 let errs = bind(&feature, &packs()).unwrap_err();
275 assert_eq!(errs[0].code, "proef::bind::table_conflict");
276 }
277
278 #[test]
279 fn unbound_step_suggests_the_closest_pattern() {
280 let feature = make_feature(" When I serch for Jansen\n");
281 let errs = bind(&feature, &packs()).unwrap_err();
282 assert_eq!(errs[0].code, "proef::bind::unbound_step");
283 assert!(
284 errs[0].message.contains("I search for {term}"),
285 "{}",
286 errs[0].message
287 );
288 }
289
290 #[test]
291 fn unknown_table_key_and_bad_table_shape_error() {
292 let feature = make_feature(" When I search for Jansen\n | indx | users |\n");
293 let errs = bind(&feature, &packs()).unwrap_err();
294 assert_eq!(errs[0].code, "proef::bind::unknown_table_key");
295 assert!(errs[0].message.contains("did you mean `index`?"));
296
297 let feature = make_feature(" When I search for Jansen\n | a | b | c |\n");
298 let errs = bind(&feature, &packs()).unwrap_err();
299 assert_eq!(errs[0].code, "proef::bind::bad_table");
300 }
301
302 #[test]
303 fn ambiguity_lists_all_candidates() {
304 let sources = vec![PackSource {
305 name: "test.yaml".into(),
306 text: Arc::from(
307 "templates:\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",
308 ),
309 }];
310 let packs = pack::load(&sources, KINDS).unwrap();
311 let feature = make_feature(" When do it now\n");
312 let errs = bind(&feature, &packs).unwrap_err();
313 assert_eq!(errs[0].code, "proef::bind::ambiguous_step");
314 assert!(errs[0].message.contains("`a`") && errs[0].message.contains("`b`"));
315 }
316
317 #[test]
318 fn missing_required_param_is_reported() {
319 let sources = vec![PackSource {
320 name: "test.yaml".into(),
321 text: Arc::from(
322 "templates:\n create:\n params: [firstName, lastName]\n match: I create a client\n steps:\n - hurl: |\n POST http://x/${firstName}/${lastName}\n",
323 ),
324 }];
325 let packs = pack::load(&sources, KINDS).unwrap();
326 let feature = make_feature(" When I create a client\n");
327 let errs = bind(&feature, &packs).unwrap_err();
328 assert_eq!(errs.len(), 2);
329 assert!(errs.iter().all(|d| d.code == "proef::bind::missing_param"));
330 }
331}