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::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/// One step bound to a macro with fully-assembled args.
20#[derive(Debug, Clone)]
21pub struct BoundStep {
22    /// The authored step (anchor, span, keyword, table).
23    pub defn: StepDefn,
24    /// The macro this step invokes.
25    pub macro_name: String,
26    /// Assembled args: captures + data table + defaults.
27    pub args: BTreeMap<String, String>,
28}
29
30/// One scenario with every step bound.
31#[derive(Debug, Clone)]
32pub struct BoundScenario {
33    /// Scenario name (post-expansion).
34    pub name: String,
35    /// Accumulated tags (without `@`).
36    pub tags: Vec<String>,
37    /// 1-based header line.
38    pub line: usize,
39    /// Bound steps in authored order.
40    pub steps: Vec<BoundStep>,
41}
42
43/// Bind every scenario, always returning both the bound scenarios (bound steps
44/// only) and every diagnostic. This is the collect-all substrate the LSP reads;
45/// `bind` is its fail-fast wrapper. One binder, two error policies.
46pub 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
56/// Bind every scenario of a feature. Diagnostics accumulate across steps and
57/// scenarios so authors see all problems in one run.
58pub 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// One cohesive listing of the binding rules; splitting hides the order.
68#[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; // unreachable: step_defs derive from the same map
127        };
128
129        // Data-table rows merge into args (`| key | value |`).
130        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        // A docstring feeds the macro's `docstring` param (raw request bodies,
168        // TECH-SPEC §7); a macro that doesn't declare it gets a warning.
169        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        // Defaults fill the gaps; whatever is still missing is required.
184        for (param, default) in &macro_.defaults {
185            args.entry(param.clone()).or_insert_with(|| default.clone());
186        }
187        for param in &macro_.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
214/// The closest `match:` pattern to an unbound step, comparing against each
215/// pattern's literal skeleton within the shared suggestion threshold.
216///
217/// Capture *values* in the step would inflate a whole-text distance (`I serch
218/// for Jansen` is far from the skeleton `I search for`), so the distance is
219/// also taken over the step's prefix clipped to the skeleton's char length —
220/// the minimum of both comparisons decides.
221fn 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
236/// A paste-ready pack-macro stub for an unbound step. Quoted tokens become
237/// `{argN}` captures (the matcher sheds those quotes when binding), so an author
238/// can drop the stub into a pack and fill in the request instead of hand-writing
239/// the `match:`/`hurl:` scaffold.
240fn 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            // Consume through the matching quote — the quoted run is one capture.
247            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    // Two readers, two different actions. A scenario author writes prose
259    // against a vocabulary somebody else maintains, so their move is to say
260    // something the packs already bind. A pack maintainer's move is the stub
261    // below. The author's action leads because they cannot perform the
262    // maintainer's; the stub stays because the maintainer needs it verbatim.
263    //
264    // Names no tool: this text reaches an editor's diagnostics pane verbatim
265    // through the LSP as well as the terminal, and each front end already has
266    // its own way to show the vocabulary (completion there, `macros` there).
267    // Core does not know which one is reading.
268    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        fragments: None,
289        options: None,
290    }];
291
292    fn packs() -> PackSet {
293        let sources = vec![PackSource {
294            name: "test.yaml".into(),
295            text: Arc::from(
296                "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",
297            ),
298        }];
299        pack::load(&sources, &crate::pack::FragmentCorpus::empty(), KINDS).unwrap()
300    }
301
302    fn make_feature(body: &str) -> FeatureFile {
303        crate::feature::parse("t.feature", &format!("Feature: F\n  Scenario: S\n{body}")).unwrap()
304    }
305
306    #[test]
307    fn macro_stub_parametrizes_quoted_tokens() {
308        // Quoted runs become sequential {argN} captures (double and single quotes).
309        let stub = macro_stub("the operator searches for \"Acme\" in 'people'");
310        assert!(
311            stub.contains("match: the operator searches for {arg1} in {arg2}"),
312            "{stub}"
313        );
314        // A quote-free step keeps its literal text as the pattern.
315        assert!(
316            macro_stub("all done").contains("match: all done"),
317            "no-quote stub"
318        );
319    }
320
321    #[test]
322    fn captures_tables_and_defaults_assemble_args() {
323        let feature = make_feature("    When I search for \"Jansen\"\n");
324        let bound = bind(&feature, &packs()).unwrap();
325        let step = &bound[0].steps[0];
326        assert_eq!(step.macro_name, "search");
327        assert_eq!(step.args["term"], "Jansen");
328        assert_eq!(step.args["index"], "records", "default filled");
329    }
330
331    #[test]
332    fn table_overrides_defaults_but_not_captures() {
333        let feature = make_feature("    When I search for Jansen\n      | index | people |\n");
334        let bound = bind(&feature, &packs()).unwrap();
335        assert_eq!(bound[0].steps[0].args["index"], "people");
336
337        let feature = make_feature("    When I search for Jansen\n      | term | other |\n");
338        let errs = bind(&feature, &packs()).unwrap_err();
339        assert_eq!(errs[0].code, "proef::bind::table_conflict");
340    }
341
342    #[test]
343    fn unbound_step_suggests_the_closest_pattern() {
344        let feature = make_feature("    When I serch for Jansen\n");
345        let errs = bind(&feature, &packs()).unwrap_err();
346        assert_eq!(errs[0].code, "proef::bind::unbound_step");
347        assert!(
348            errs[0].message.contains("I search for {term}"),
349            "{}",
350            errs[0].message
351        );
352    }
353
354    #[test]
355    fn unknown_table_key_and_bad_table_shape_error() {
356        let feature = make_feature("    When I search for Jansen\n      | indx | people |\n");
357        let errs = bind(&feature, &packs()).unwrap_err();
358        assert_eq!(errs[0].code, "proef::bind::unknown_table_key");
359        assert!(errs[0].message.contains("did you mean `index`?"));
360
361        let feature = make_feature("    When I search for Jansen\n      | a | b | c |\n");
362        let errs = bind(&feature, &packs()).unwrap_err();
363        assert_eq!(errs[0].code, "proef::bind::bad_table");
364    }
365
366    #[test]
367    fn ambiguity_lists_all_candidates() {
368        let sources = vec![PackSource {
369            name: "test.yaml".into(),
370            text: Arc::from(
371                "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",
372            ),
373        }];
374        let packs = pack::load(&sources, &crate::pack::FragmentCorpus::empty(), KINDS).unwrap();
375        let feature = make_feature("    When do it now\n");
376        let errs = bind(&feature, &packs).unwrap_err();
377        assert_eq!(errs[0].code, "proef::bind::ambiguous_step");
378        assert!(errs[0].message.contains("`a`") && errs[0].message.contains("`b`"));
379    }
380
381    #[test]
382    fn missing_required_param_is_reported() {
383        let sources = vec![PackSource {
384            name: "test.yaml".into(),
385            text: Arc::from(
386                "macros:\n  create:\n    params: [firstName, lastName]\n    match: I create a record\n    steps:\n      - hurl: |\n          POST http://x/${firstName}/${lastName}\n",
387            ),
388        }];
389        let packs = pack::load(&sources, &crate::pack::FragmentCorpus::empty(), KINDS).unwrap();
390        let feature = make_feature("    When I create a record\n");
391        let errs = bind(&feature, &packs).unwrap_err();
392        assert_eq!(errs.len(), 2);
393        assert!(errs.iter().all(|d| d.code == "proef::bind::missing_param"));
394    }
395
396    #[test]
397    fn bind_collect_returns_bindings_and_diags_without_early_return() {
398        // A feature with one bindable step and one unbound step: collect-all must
399        // return the bound step's binding AND the unbound diagnostic together.
400        let packs = crate::pack::load(
401            &[crate::pack::PackSource {
402                name: "packs/p.yaml".to_owned(),
403                text: std::sync::Arc::from(
404                    "macros:\n  greet:\n    params: [who]\n    match: \"I greet {who}\"\n    steps:\n      - hurl: |\n          GET http://x\n",
405                ),
406            }],
407            &crate::pack::FragmentCorpus::empty(),
408            KINDS,
409        )
410        .unwrap();
411        let file = crate::feature::parse(
412            "f.feature",
413            "Feature: F\n  Scenario: S\n    When I greet Sam\n    And I xyzzy\n",
414        )
415        .unwrap();
416
417        let (scenarios, diags) = bind_collect(&file, &packs);
418        // One scenario, with the bound step surviving.
419        let bound_step_count: usize = scenarios.iter().map(|s| s.steps.len()).sum();
420        assert_eq!(bound_step_count, 1, "the bindable step must survive");
421        assert_eq!(scenarios[0].steps[0].macro_name, "greet");
422        // The unbound step surfaces its diagnostic rather than aborting the feature.
423        assert!(diags.iter().any(|d| d.code == "proef::bind::unbound_step"));
424    }
425}