Skip to main content

proef_core/
bind.rs

1//! Step binding (TECH-SPEC §4.3): match each authored step against the loaded
2//! macros' `match:` patterns.
3//!
4//! Exactly one pattern must match: zero is an unbound step (with a
5//! closest-pattern suggestion), two or more is ambiguity (listing candidates).
6//! Captured `{name}` values, `| key | value |` data-table rows, and macro
7//! defaults fill the macro's params; conflicts and missing required params are
8//! bind-time errors anchored to the step's line.
9
10use 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/// One step bound to a macro with fully-assembled args.
19#[derive(Debug, Clone)]
20pub struct BoundStep {
21    /// The authored step (anchor, span, keyword, table).
22    pub defn: StepDefn,
23    /// The macro this step invokes.
24    pub macro_name: String,
25    /// Assembled args: captures + data table + defaults.
26    pub args: BTreeMap<String, String>,
27}
28
29/// One scenario with every step bound.
30#[derive(Debug, Clone)]
31pub struct BoundScenario {
32    /// Scenario name (post-expansion).
33    pub name: String,
34    /// Accumulated tags (without `@`).
35    pub tags: Vec<String>,
36    /// 1-based header line.
37    pub line: usize,
38    /// Bound steps in authored order.
39    pub steps: Vec<BoundStep>,
40}
41
42/// Bind every scenario of a feature. Diagnostics accumulate across steps and
43/// scenarios so authors see all problems in one run.
44pub 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// One cohesive listing of the binding rules; splitting hides the order.
61#[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; // unreachable: step_defs derive from the same map
120        };
121
122        // Data-table rows merge into args (`| key | value |`).
123        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        // A docstring feeds the macro's `docstring` param (raw request bodies,
161        // TECH-SPEC §7); a macro that doesn't declare it gets a warning.
162        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        // Defaults fill the gaps; whatever is still missing is required.
177        for (param, default) in &macro_.defaults {
178            args.entry(param.clone()).or_insert_with(|| default.clone());
179        }
180        for param in &macro_.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
207/// The closest `match:` pattern to an unbound step, comparing against each
208/// pattern's literal skeleton within the shared suggestion threshold.
209///
210/// Capture *values* in the step would inflate a whole-text distance (`I serch
211/// for Jansen` is far from the skeleton `I search for`), so the distance is
212/// also taken over the step's prefix clipped to the skeleton's char length —
213/// the minimum of both comparisons decides.
214fn 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}