Skip to main content

varar_core/
execute.rs

1//! The executor — port of `execute.ts` / `Execute.java`, on the full-replacement
2//! state model. Handlers are invoked via boxed closures (no reflection); panics
3//! are caught (the `AssertionError`/`Throwable` parity channel); `Future`
4//! returns are driven by a small std `block_on`. State is a [`Value`], replaced
5//! wholesale by each stimulus.
6
7use crate::cell_diff::{CellDiff, compare_row, compare_table};
8use crate::diagnostics::Diagnostic;
9use crate::doc_string_diff::compare_doc_string;
10use crate::error::{FailureLocation, HandlerError, StepError, StepFailure};
11use crate::failure_anchor;
12use crate::handler::{Handler, StepOutput, StepReturn};
13use crate::offsets::{utf16_len, utf16_slice};
14use crate::param_diff::compare_params_with_formats;
15use crate::plan::{ExecutionPlan, PlannedExample, PlannedStep};
16use crate::result::AnchorRange;
17use crate::step_kind::StepKind;
18use crate::value::Value;
19use std::any::Any;
20use std::cell::Cell;
21use std::collections::HashMap;
22use std::future::Future;
23use std::panic::AssertUnwindSafe;
24use std::pin::Pin;
25use std::rc::Rc;
26use std::sync::Once;
27use std::task::{Context, Poll, Wake, Waker};
28
29/// A step's outcome in the conformance trace.
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum StepOutcome {
32    Pass,
33    Fail,
34    Skipped,
35}
36
37impl StepOutcome {
38    /// The wire string (`"pass"`/`"fail"`/`"skipped"`).
39    pub fn as_str(self) -> &'static str {
40        match self {
41            StepOutcome::Pass => "pass",
42            StepOutcome::Fail => "fail",
43            StepOutcome::Skipped => "skipped",
44        }
45    }
46}
47
48/// One executed step's outcome. `example_index` is 0-based; `ordinal` is 1-based.
49#[derive(Clone, Debug, PartialEq)]
50pub struct StepObservation {
51    pub example_index: usize,
52    pub ordinal: usize,
53    pub outcome: StepOutcome,
54    pub error: Option<StepFailure>,
55}
56
57/// The ports [`collect_examples`]/[`execute_plan`] need. `create_context` maps a
58/// step-file to its fresh initial state (`None` → a unit state per file);
59/// `observer` is optional per-step instrumentation. The lifetime lets the port
60/// closures borrow caller locals (e.g. a conformance observer's accumulator).
61pub struct ExecutePorts<'a> {
62    pub reporter: Reporter<'a>,
63    pub create_context: Option<ContextFactory<'a>>,
64    pub observer: Option<Observer<'a>>,
65}
66
67/// Receives every diagnostic collected during planning.
68pub type Reporter<'a> = Box<dyn Fn(&Diagnostic) + 'a>;
69/// Maps a step-file to its fresh initial state.
70pub type ContextFactory<'a> = Box<dyn Fn(&str) -> Rc<dyn Any> + 'a>;
71/// Per-step instrumentation (conformance trace mode).
72pub type Observer<'a> = Box<dyn Fn(StepObservation) + 'a>;
73
74impl<'a> ExecutePorts<'a> {
75    /// Ports with just a reporter (no context factory, no observer).
76    pub fn new(reporter: Box<dyn Fn(&Diagnostic) + 'a>) -> ExecutePorts<'a> {
77        ExecutePorts {
78            reporter,
79            create_context: None,
80            observer: None,
81        }
82    }
83}
84
85impl ExecutePorts<'static> {
86    /// Ports that discard diagnostics and observe nothing.
87    pub fn silent() -> ExecutePorts<'static> {
88        ExecutePorts::new(Box::new(|_| {}))
89    }
90}
91
92/// One runnable example: its name and a callback that runs its steps.
93pub struct QueuedExample<'a> {
94    pub name: String,
95    run: Box<dyn Fn() -> Result<(), StepFailure> + 'a>,
96}
97
98impl QueuedExample<'_> {
99    /// Runs the example's steps; `Err` on the first failure.
100    pub fn run(&self) -> Result<(), StepFailure> {
101        (self.run)()
102    }
103}
104
105/// Reports every diagnostic in `plan`, then returns one [`QueuedExample`] per
106/// planned example, in document order (each `run` is lazy). Port of `collectExamples`.
107pub fn collect_examples<'a>(
108    plan: &'a ExecutionPlan,
109    ports: &'a ExecutePorts<'a>,
110) -> Vec<QueuedExample<'a>> {
111    for d in &plan.diagnostics {
112        (ports.reporter)(d);
113    }
114    plan.examples
115        .iter()
116        .enumerate()
117        .map(|(i, ex)| QueuedExample {
118            name: ex.name.clone(),
119            run: Box::new(move || run_example(plan, ex, i, ports)),
120        })
121        .collect()
122}
123
124/// Runs every example in `plan`, in order, stopping at the first failure. Port
125/// of `executePlan`.
126pub fn execute_plan<'a>(
127    plan: &'a ExecutionPlan,
128    ports: &'a ExecutePorts<'a>,
129) -> Result<(), StepFailure> {
130    for q in collect_examples(plan, ports) {
131        q.run()?;
132    }
133    Ok(())
134}
135
136// -----------------------------------------------------------------------------
137// One example
138// -----------------------------------------------------------------------------
139
140fn run_example(
141    plan: &ExecutionPlan,
142    ex: &PlannedExample,
143    example_index: usize,
144    ports: &ExecutePorts,
145) -> Result<(), StepFailure> {
146    let path = &plan.doc.path;
147    let source = &plan.doc.source;
148    let steps = &ex.steps;
149
150    let mut state_by_file: HashMap<String, Rc<dyn Any>> = HashMap::new();
151    let mut last_return: Option<Value> = None;
152    let mut thrown: Option<StepFailure> = None;
153
154    for (i, step) in steps.iter().enumerate() {
155        let file = &step.step_def.expression_source_file;
156        let state = match state_by_file.get(file) {
157            Some(s) => s.clone(),
158            None => {
159                let created = create_context(ports, file);
160                state_by_file.insert(file.clone(), created.clone());
161                created
162            }
163        };
164
165        // A trailing data table / doc string is the last handler argument.
166        let mut call_args = step.args.clone();
167        if let Some(table) = &step.data_table {
168            call_args.push(table_rows(table));
169        } else if let Some(fence) = &step.doc_string {
170            call_args.push(Value::from(fence.body.as_str()));
171        }
172
173        let step_error: Option<StepError> =
174            match invoke_resolve(&step.step_def.handler, state, call_args) {
175                Err(he) => Some(StepError::Handler(he)),
176                Ok(output) => {
177                    last_return = output.compared().cloned();
178                    match step.step_def.kind {
179                        Some(StepKind::Stimulus) => {
180                            // A stimulus's output IS the next state (full
181                            // replacement). The facade yields `State`; the core's
182                            // Value-state conveniences yield `Compared`, which for
183                            // a stimulus means the same thing. Returning nothing
184                            // (`Compared(None)`) leaves state unchanged — it is not
185                            // the same as returning `Value::Null`.
186                            let next: Option<Rc<dyn Any>> = match output {
187                                StepOutput::State(next) => Some(next),
188                                StepOutput::Compared(v) => v.map(|v| Rc::new(v) as Rc<dyn Any>),
189                            };
190                            if let Some(next) = next {
191                                state_by_file.insert(file.clone(), next);
192                            }
193                            None
194                        }
195                        Some(StepKind::Sensor) => {
196                            // Header-bound rows are checked after the loop via row_checks.
197                            if ex.row_checks.is_none() {
198                                check_sensor_return(source, step, output.compared().cloned()).err()
199                            } else {
200                                None
201                            }
202                        }
203                        None => Some(StepError::ReturnShape("unknown step kind: null".to_string())),
204                    }
205                }
206            };
207
208        match step_error {
209            None => observe(
210                ports,
211                StepObservation {
212                    example_index,
213                    ordinal: i + 1,
214                    outcome: StepOutcome::Pass,
215                    error: None,
216                },
217            ),
218            Some(err) => {
219                let failure = attach_location(err, step, path);
220                observe(
221                    ports,
222                    StepObservation {
223                        example_index,
224                        ordinal: i + 1,
225                        outcome: StepOutcome::Fail,
226                        error: Some(failure.clone()),
227                    },
228                );
229                thrown = Some(failure);
230                break;
231            }
232        }
233    }
234
235    // Header-bound row checks (deferred to after the loop).
236    if thrown.is_none() {
237        if let Some(checks) = &ex.row_checks {
238            if !checks.is_empty() {
239                let bad: Vec<CellDiff> = compare_row(last_return.as_ref(), checks)
240                    .into_iter()
241                    .filter(|d| !d.ok)
242                    .collect();
243                // Like a slotted sensor, a header-bound row step must answer the
244                // row it is bound to: no return means nothing was compared.
245                if last_return.is_none() || !bad.is_empty() {
246                    let last_step = steps.last().unwrap();
247                    let err = if last_return.is_none() {
248                        StepError::ReturnShape(
249                            "a header-bound row step must return a row object with one value per bound cell, got nothing".to_string(),
250                        )
251                    } else {
252                        StepError::CellMismatch(bad)
253                    };
254                    let failure = attach_location(err, last_step, path);
255                    observe(
256                        ports,
257                        StepObservation {
258                            example_index,
259                            ordinal: steps.len(),
260                            outcome: StepOutcome::Fail,
261                            error: Some(failure.clone()),
262                        },
263                    );
264                    thrown = Some(failure);
265                }
266            }
267        }
268    }
269
270    // Error-fence inversion.
271    if ex.expected_outcome.as_deref() == Some("fail") {
272        match thrown {
273            None => {
274                return Err(match steps.last() {
275                    Some(last) => attach_location(StepError::UnexpectedPass, last, path),
276                    None => StepFailure::bare(StepError::UnexpectedPass),
277                });
278            }
279            Some(failure) => {
280                if let Some(expected_msg) = &ex.expected_error_message {
281                    if !failure.error.message().contains(expected_msg) {
282                        return Err(failure);
283                    }
284                }
285                return Ok(());
286            }
287        }
288    }
289
290    match thrown {
291        Some(failure) => Err(failure),
292        None => Ok(()),
293    }
294}
295
296fn create_context(ports: &ExecutePorts, file: &str) -> Rc<dyn Any> {
297    match &ports.create_context {
298        Some(cc) => cc(file),
299        None => Rc::new(()) as Rc<dyn Any>,
300    }
301}
302
303fn observe(ports: &ExecutePorts, observation: StepObservation) {
304    if let Some(observer) = &ports.observer {
305        observer(observation);
306    }
307}
308
309fn table_rows(table: &crate::ast::Table) -> Value {
310    let row =
311        |cells: &[String]| Value::List(cells.iter().map(|c| Value::from(c.as_str())).collect());
312    let mut rows = vec![row(&table.header.cells)];
313    for r in &table.rows {
314        rows.push(row(&r.cells));
315    }
316    Value::List(rows)
317}
318
319fn attach_location(error: StepError, step: &PlannedStep, oath_path: &str) -> StepFailure {
320    let anchor = failure_anchor::anchor(&error, step.match_span);
321    let label = truncate_label(&step.text);
322    StepFailure {
323        error,
324        // A step a reference block spliced in (ADR 0016) has spans in the
325        // document it was WRITTEN in, so the location must name that file —
326        // otherwise a renderer points at the running oath's line N, which is
327        // some other sentence entirely.
328        location: Some(FailureLocation {
329            label,
330            path: step
331                .doc_path
332                .clone()
333                .unwrap_or_else(|| oath_path.to_string()),
334            line: anchor.start_line,
335            anchor: AnchorRange {
336                from: anchor.start_offset,
337                to: anchor.end_offset,
338            },
339        }),
340    }
341}
342
343fn truncate_label(text: &str) -> String {
344    if utf16_len(text) > 60 {
345        let truncated: String = text.chars().take(60).collect();
346        format!("{truncated}…")
347    } else {
348        text.to_string()
349    }
350}
351
352// -----------------------------------------------------------------------------
353// Sensor return comparison
354// -----------------------------------------------------------------------------
355
356fn check_sensor_return(
357    source: &str,
358    step: &PlannedStep,
359    returned: Option<Value>,
360) -> Result<(), StepError> {
361    let extra_count = usize::from(step.data_table.is_some() || step.doc_string.is_some());
362    let slot_count = step.args.len() + extra_count;
363    // With one or more slots the return is REQUIRED: returning nothing used to
364    // skip the comparison silently, so a typo turned an assertion into a no-op.
365    let returned = match returned {
366        // Nothing to compare against: returning nothing is the pass.
367        None if slot_count == 0 => return Ok(()),
368        None => {
369            return Err(StepError::ReturnShape(format!(
370                "a sensor with {slot_count} slot(s) must return one value per slot, got nothing"
371            )));
372        }
373        Some(v) => v,
374    };
375    if slot_count == 0 {
376        return Err(StepError::ReturnShape(
377            "this sensor has no parameters, data table or doc string — nothing to compare a return value against \
378             (throw to fail, return nothing to pass)"
379                .to_string(),
380        ));
381    }
382    let slots: Vec<Value> = if slot_count == 1 {
383        // The return IS the single slot's value, never read as a positional list.
384        vec![returned]
385    } else {
386        match returned {
387            Value::List(list) => {
388                if list.len() != slot_count {
389                    return Err(StepError::ReturnShape(format!(
390                        "sensor return must have {} element(s), got {}",
391                        slot_count,
392                        list.len()
393                    )));
394                }
395                list
396            }
397            other => {
398                return Err(StepError::ReturnShape(format!(
399                    "a sensor with {} slots must return a List of {} values, got {}",
400                    slot_count,
401                    slot_count,
402                    other.type_name()
403                )));
404            }
405        }
406    };
407
408    let arg_count = step.args.len();
409    if arg_count > 0 {
410        let source_texts: Vec<String> = step
411            .param_spans
412            .iter()
413            .map(|s| utf16_slice(source, s.start_offset, s.end_offset).to_string())
414            .collect();
415        let bad: Vec<CellDiff> = compare_params_with_formats(
416            &slots[0..arg_count],
417            &step.args,
418            &step.param_spans,
419            &source_texts,
420            Some(&step.formats),
421        )
422        .into_iter()
423        .filter(|d| !d.ok)
424        .collect();
425        if !bad.is_empty() {
426            return Err(StepError::CellMismatch(bad));
427        }
428    }
429
430    if let Some(table) = &step.data_table {
431        let bad: Vec<CellDiff> = compare_table(Some(&slots[arg_count]), table)?
432            .into_iter()
433            .filter(|d| !d.ok)
434            .collect();
435        if !bad.is_empty() {
436            return Err(StepError::CellMismatch(bad));
437        }
438    } else if let Some(fence) = &step.doc_string {
439        if let Some(diff) =
440            compare_doc_string(Some(&slots[arg_count]), &fence.body, fence.body_span)?
441        {
442            return Err(StepError::CellMismatch(vec![diff]));
443        }
444    }
445    Ok(())
446}
447
448// -----------------------------------------------------------------------------
449// Handler invocation (panic-catching + async resolution)
450// -----------------------------------------------------------------------------
451
452thread_local! {
453    static SUPPRESS_PANIC: Cell<bool> = const { Cell::new(false) };
454}
455
456static HOOK: Once = Once::new();
457
458/// Installs a panic hook (once) that suppresses the default stderr print for
459/// panics the executor deliberately catches (a handler's assertion-style
460/// failure), while leaving genuine test panics untouched on other threads.
461///
462/// DECLARED EXCEPTION to the "no globals in the core" rule (see `lib.rs`):
463/// `catch_unwind` is the executor's assertion channel — the AssertionError
464/// parity with Java — and the process-wide hook is the only way Rust offers to
465/// keep a *caught* panic from spewing to stderr. It is `Once`-guarded, chains
466/// the previous hook, and gates on a thread-local so it is inert outside
467/// [`invoke_resolve`]; observable behaviour is otherwise unchanged.
468fn install_hook() {
469    HOOK.call_once(|| {
470        let previous = std::panic::take_hook();
471        std::panic::set_hook(Box::new(move |info| {
472            if SUPPRESS_PANIC.with(Cell::get) {
473                return;
474            }
475            previous(info);
476        }));
477    });
478}
479
480/// Invokes the handler and resolves any `Future`, catching a panic (the
481/// assertion-style failure channel) into a [`HandlerError`].
482fn invoke_resolve(
483    handler: &Handler,
484    state: Rc<dyn Any>,
485    args: Vec<Value>,
486) -> Result<StepOutput, HandlerError> {
487    install_hook();
488    let caught = SUPPRESS_PANIC.with(|s| {
489        s.set(true);
490        let r = std::panic::catch_unwind(AssertUnwindSafe(|| match handler.call(state, args) {
491            StepReturn::Ready(r) => r,
492            StepReturn::Pending(fut) => block_on(fut),
493        }));
494        s.set(false);
495        r
496    });
497    match caught {
498        Ok(r) => r,
499        Err(payload) => Err(HandlerError::from_panic(payload)),
500    }
501}
502
503/// A minimal `block_on`: polls the future, parking the thread until its waker
504/// unparks it. No dependencies, no unsafe.
505fn block_on<T>(mut fut: Pin<Box<dyn Future<Output = T>>>) -> T {
506    struct ThreadWaker(std::thread::Thread);
507    impl Wake for ThreadWaker {
508        fn wake(self: std::sync::Arc<Self>) {
509            self.0.unpark();
510        }
511        fn wake_by_ref(self: &std::sync::Arc<Self>) {
512            self.0.unpark();
513        }
514    }
515    let waker = Waker::from(std::sync::Arc::new(ThreadWaker(std::thread::current())));
516    let mut cx = Context::from_waker(&waker);
517    loop {
518        match fut.as_mut().poll(&mut cx) {
519            Poll::Ready(v) => return v,
520            Poll::Pending => std::thread::park(),
521        }
522    }
523}