proef-core 0.11.1

Engine-agnostic core of proef: parsing, binding, lowering, IR, emit, dispatch, World, events, errors
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
//! Step binding (TECH-SPEC ยง4.3): match each authored step against the loaded
//! macros' `match:` patterns.
//!
//! Exactly one pattern must match: zero is an unbound step (with a
//! closest-pattern suggestion), two or more is ambiguity (listing candidates).
//! Captured `{name}` values, `| key | value |` data-table rows, and macro
//! defaults fill the macro's params; conflicts and missing required params are
//! bind-time errors anchored to the step's line.

use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::sync::Arc;

use crate::diag::{Diag, Severity};
use crate::feature::{FeatureFile, ScenarioDef, StepDefn};
use crate::matcher;
use crate::pack::PackSet;

/// One step bound to a macro with fully-assembled args.
#[derive(Debug, Clone)]
pub struct BoundStep {
    /// The authored step (anchor, span, keyword, table).
    pub defn: StepDefn,
    /// The macro this step invokes.
    pub macro_name: String,
    /// Assembled args: captures + data table + defaults.
    pub args: BTreeMap<String, String>,
}

/// One scenario with every step bound.
#[derive(Debug, Clone)]
pub struct BoundScenario {
    /// Scenario name (post-expansion).
    pub name: String,
    /// Accumulated tags (without `@`).
    pub tags: Vec<String>,
    /// 1-based header line.
    pub line: usize,
    /// Bound steps in authored order.
    pub steps: Vec<BoundStep>,
}

/// Bind every scenario, always returning both the bound scenarios (bound steps
/// only) and every diagnostic. This is the collect-all substrate the LSP reads;
/// `bind` is its fail-fast wrapper. One binder, two error policies.
pub fn bind_collect(feature: &FeatureFile, packs: &PackSet) -> (Vec<BoundScenario>, Vec<Diag>) {
    let mut diags: Vec<Diag> = Vec::new();
    let mut scenarios = Vec::new();
    let defs = packs.step_defs();
    for scenario in &feature.scenarios {
        scenarios.push(bind_scenario(scenario, feature, packs, &defs, &mut diags));
    }
    (scenarios, diags)
}

/// Bind every scenario of a feature. Diagnostics accumulate across steps and
/// scenarios so authors see all problems in one run.
pub fn bind(feature: &FeatureFile, packs: &PackSet) -> Result<Vec<BoundScenario>, Vec<Diag>> {
    let (scenarios, diags) = bind_collect(feature, packs);
    if diags.iter().any(|d| d.severity == Severity::Error) {
        Err(diags)
    } else {
        Ok(scenarios)
    }
}

// One cohesive listing of the binding rules; splitting hides the order.
#[allow(clippy::too_many_lines)]
fn bind_scenario(
    scenario: &ScenarioDef,
    feature: &FeatureFile,
    packs: &PackSet,
    defs: &[(&str, &str)],
    diags: &mut Vec<Diag>,
) -> BoundScenario {
    let mut steps = Vec::new();
    for step in &scenario.steps {
        let at = |diag: Diag| {
            diag.with_source(feature.path.clone(), Arc::clone(&feature.source))
                .with_span(step.span)
        };

        let candidates: Vec<(&str, &str, BTreeMap<String, String>)> = defs
            .iter()
            .filter_map(|(pattern, macro_name)| {
                matcher::match_pattern(pattern, &step.text)
                    .map(|args| (*pattern, *macro_name, args))
            })
            .collect();

        match candidates.len() {
            0 => {
                let suggestion = closest_pattern(&step.text, defs)
                    .map(|p| format!(" โ€” did you mean `{p}`?"))
                    .unwrap_or_default();
                diags.push(
                    at(Diag::error(
                        "proef::bind::unbound_step",
                        format!("no macro matches `{}`{suggestion}", step.text),
                    ))
                    .with_help(macro_stub(&step.text)),
                );
                continue;
            }
            1 => {}
            _ => {
                let listing = candidates
                    .iter()
                    .map(|(pattern, macro_name, _)| format!("`{macro_name}` ({pattern})"))
                    .collect::<Vec<_>>()
                    .join(", ");
                diags.push(at(Diag::error(
                    "proef::bind::ambiguous_step",
                    format!(
                        "`{}` matches {} macros: {listing}",
                        step.text,
                        candidates.len()
                    ),
                )));
                continue;
            }
        }

        let (_, macro_name, mut args) = candidates.into_iter().next().unwrap_or_default();
        let Some(macro_) = packs.macros.get(macro_name) else {
            continue; // unreachable: step_defs derive from the same map
        };

        // Data-table rows merge into args (`| key | value |`).
        if let Some(rows) = &step.table {
            for row in rows {
                let [key, value] = row.as_slice() else {
                    diags.push(at(Diag::error(
                        "proef::bind::bad_table",
                        format!(
                            "data tables merge as `| key | value |` โ€” this row has {} cells",
                            row.len()
                        ),
                    )));
                    continue;
                };
                if args.contains_key(key) {
                    diags.push(at(Diag::error(
                        "proef::bind::table_conflict",
                        format!("`{key}` is set both by a `{{capture}}` and the data table"),
                    )));
                    continue;
                }
                if !macro_.params.contains(key) {
                    let suggestion =
                        matcher::closest(key, macro_.params.iter().map(String::as_str))
                            .map(|p| format!(" โ€” did you mean `{p}`?"))
                            .unwrap_or_default();
                    diags.push(at(Diag::error(
                        "proef::bind::unknown_table_key",
                        format!(
                            "`{key}` is not a param of macro `{}`{suggestion}",
                            macro_.name
                        ),
                    )));
                    continue;
                }
                args.insert(key.clone(), value.clone());
            }
        }

        // A docstring feeds the macro's `docstring` param (raw request bodies,
        // TECH-SPEC ยง7); a macro that doesn't declare it gets a warning.
        if let Some(docstring) = &step.docstring {
            if macro_.params.iter().any(|p| p == "docstring") {
                args.insert("docstring".to_owned(), docstring.clone());
            } else {
                diags.push(at(Diag::warning(
                    "proef::bind::docstring_unused",
                    format!(
                        "this step has a docstring but macro `{}` declares no `docstring` param โ€” ignored",
                        macro_.name
                    ),
                )));
            }
        }

        // Defaults fill the gaps; whatever is still missing is required.
        for (param, default) in &macro_.defaults {
            args.entry(param.clone()).or_insert_with(|| default.clone());
        }
        for param in &macro_.params {
            if !args.contains_key(param) {
                diags.push(at(Diag::error(
                    "proef::bind::missing_param",
                    format!(
                        "macro `{}` needs `{param}` โ€” add a `{{{param}}}` capture, a data-table row, or a default",
                        macro_.name
                    ),
                )));
            }
        }

        steps.push(BoundStep {
            defn: step.clone(),
            macro_name: macro_name.to_owned(),
            args,
        });
    }

    BoundScenario {
        name: scenario.name.clone(),
        tags: scenario.tags.clone(),
        line: scenario.line,
        steps,
    }
}

/// The closest `match:` pattern to an unbound step, comparing against each
/// pattern's literal skeleton within the shared suggestion threshold.
///
/// Capture *values* in the step would inflate a whole-text distance (`I serch
/// for Jansen` is far from the skeleton `I search for`), so the distance is
/// also taken over the step's prefix clipped to the skeleton's char length โ€”
/// the minimum of both comparisons decides.
fn closest_pattern<'a>(step_text: &str, defs: &[(&'a str, &str)]) -> Option<&'a str> {
    defs.iter()
        .map(|(pattern, _)| {
            let skeleton = matcher::literal_skeleton(pattern);
            let skeleton = skeleton.trim();
            let clipped: String = step_text.chars().take(skeleton.chars().count()).collect();
            let distance = matcher::levenshtein(step_text, skeleton)
                .min(matcher::levenshtein(&clipped, skeleton));
            (distance, *pattern)
        })
        .filter(|(distance, _)| *distance <= 3)
        .min_by_key(|(distance, _)| *distance)
        .map(|(_, pattern)| pattern)
}

/// A paste-ready pack-macro stub for an unbound step. Quoted tokens become
/// `{argN}` captures (the matcher sheds those quotes when binding), so an author
/// can drop the stub into a pack and fill in the request instead of hand-writing
/// the `match:`/`hurl:` scaffold.
fn macro_stub(step_text: &str) -> String {
    let mut pattern = String::new();
    let mut arg = 0u32;
    let mut chars = step_text.chars();
    while let Some(c) = chars.next() {
        if c == '"' || c == '\'' {
            // Consume through the matching quote โ€” the quoted run is one capture.
            for q in chars.by_ref() {
                if q == c {
                    break;
                }
            }
            arg += 1;
            let _ = write!(pattern, "{{arg{arg}}}");
        } else {
            pattern.push(c);
        }
    }
    // Two readers, two different actions. A scenario author writes prose
    // against a vocabulary somebody else maintains, so their move is to say
    // something the packs already bind. A pack maintainer's move is the stub
    // below. The author's action leads because they cannot perform the
    // maintainer's; the stub stays because the maintainer needs it verbatim.
    //
    // Names no tool: this text reaches an editor's diagnostics pane verbatim
    // through the LSP as well as the terminal, and each front end already has
    // its own way to show the vocabulary (completion there, `macros` there).
    // Core does not know which one is reading.
    format!(
        "match a sentence the suite's packs already bind, or \
         add a macro to a pack:\n\nmacros:\n  \
         newMacro:\n    match: {pattern}\n    steps:\n      - hurl: |\n          \
         GET ${{url:base}}/PATH\n          HTTP 200"
    )
}

#[cfg(test)]
mod tests {
    #![allow(clippy::unwrap_used)]

    use super::*;
    use crate::engine::StepKindSpec;
    use crate::pack::{self, PackSource};

    const KINDS: &[StepKindSpec] = &[StepKindSpec {
        prefix: "hurl",
        schema: "true",
        validate: None,
        fragments: None,
        options: None,
    }];

    fn packs() -> PackSet {
        let sources = vec![PackSource {
            name: "test.yaml".into(),
            text: Arc::from(
                "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",
            ),
        }];
        pack::load(&sources, &crate::pack::FragmentCorpus::empty(), KINDS).unwrap()
    }

    fn make_feature(body: &str) -> FeatureFile {
        crate::feature::parse("t.feature", &format!("Feature: F\n  Scenario: S\n{body}")).unwrap()
    }

    #[test]
    fn macro_stub_parametrizes_quoted_tokens() {
        // Quoted runs become sequential {argN} captures (double and single quotes).
        let stub = macro_stub("the operator searches for \"Acme\" in 'people'");
        assert!(
            stub.contains("match: the operator searches for {arg1} in {arg2}"),
            "{stub}"
        );
        // A quote-free step keeps its literal text as the pattern.
        assert!(
            macro_stub("all done").contains("match: all done"),
            "no-quote stub"
        );
    }

    #[test]
    fn captures_tables_and_defaults_assemble_args() {
        let feature = make_feature("    When I search for \"Jansen\"\n");
        let bound = bind(&feature, &packs()).unwrap();
        let step = &bound[0].steps[0];
        assert_eq!(step.macro_name, "search");
        assert_eq!(step.args["term"], "Jansen");
        assert_eq!(step.args["index"], "records", "default filled");
    }

    #[test]
    fn table_overrides_defaults_but_not_captures() {
        let feature = make_feature("    When I search for Jansen\n      | index | people |\n");
        let bound = bind(&feature, &packs()).unwrap();
        assert_eq!(bound[0].steps[0].args["index"], "people");

        let feature = make_feature("    When I search for Jansen\n      | term | other |\n");
        let errs = bind(&feature, &packs()).unwrap_err();
        assert_eq!(errs[0].code, "proef::bind::table_conflict");
    }

    #[test]
    fn unbound_step_suggests_the_closest_pattern() {
        let feature = make_feature("    When I serch for Jansen\n");
        let errs = bind(&feature, &packs()).unwrap_err();
        assert_eq!(errs[0].code, "proef::bind::unbound_step");
        assert!(
            errs[0].message.contains("I search for {term}"),
            "{}",
            errs[0].message
        );
    }

    #[test]
    fn unknown_table_key_and_bad_table_shape_error() {
        let feature = make_feature("    When I search for Jansen\n      | indx | people |\n");
        let errs = bind(&feature, &packs()).unwrap_err();
        assert_eq!(errs[0].code, "proef::bind::unknown_table_key");
        assert!(errs[0].message.contains("did you mean `index`?"));

        let feature = make_feature("    When I search for Jansen\n      | a | b | c |\n");
        let errs = bind(&feature, &packs()).unwrap_err();
        assert_eq!(errs[0].code, "proef::bind::bad_table");
    }

    #[test]
    fn ambiguity_lists_all_candidates() {
        let sources = vec![PackSource {
            name: "test.yaml".into(),
            text: Arc::from(
                "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",
            ),
        }];
        let packs = pack::load(&sources, &crate::pack::FragmentCorpus::empty(), KINDS).unwrap();
        let feature = make_feature("    When do it now\n");
        let errs = bind(&feature, &packs).unwrap_err();
        assert_eq!(errs[0].code, "proef::bind::ambiguous_step");
        assert!(errs[0].message.contains("`a`") && errs[0].message.contains("`b`"));
    }

    #[test]
    fn missing_required_param_is_reported() {
        let sources = vec![PackSource {
            name: "test.yaml".into(),
            text: Arc::from(
                "macros:\n  create:\n    params: [firstName, lastName]\n    match: I create a record\n    steps:\n      - hurl: |\n          POST http://x/${firstName}/${lastName}\n",
            ),
        }];
        let packs = pack::load(&sources, &crate::pack::FragmentCorpus::empty(), KINDS).unwrap();
        let feature = make_feature("    When I create a record\n");
        let errs = bind(&feature, &packs).unwrap_err();
        assert_eq!(errs.len(), 2);
        assert!(errs.iter().all(|d| d.code == "proef::bind::missing_param"));
    }

    #[test]
    fn bind_collect_returns_bindings_and_diags_without_early_return() {
        // A feature with one bindable step and one unbound step: collect-all must
        // return the bound step's binding AND the unbound diagnostic together.
        let packs = crate::pack::load(
            &[crate::pack::PackSource {
                name: "packs/p.yaml".to_owned(),
                text: std::sync::Arc::from(
                    "macros:\n  greet:\n    params: [who]\n    match: \"I greet {who}\"\n    steps:\n      - hurl: |\n          GET http://x\n",
                ),
            }],
            &crate::pack::FragmentCorpus::empty(),
            KINDS,
        )
        .unwrap();
        let file = crate::feature::parse(
            "f.feature",
            "Feature: F\n  Scenario: S\n    When I greet Sam\n    And I xyzzy\n",
        )
        .unwrap();

        let (scenarios, diags) = bind_collect(&file, &packs);
        // One scenario, with the bound step surviving.
        let bound_step_count: usize = scenarios.iter().map(|s| s.steps.len()).sum();
        assert_eq!(bound_step_count, 1, "the bindable step must survive");
        assert_eq!(scenarios[0].steps[0].macro_name, "greet");
        // The unbound step surfaces its diagnostic rather than aborting the feature.
        assert!(diags.iter().any(|d| d.code == "proef::bind::unbound_step"));
    }
}