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        location: Some(FailureLocation {
325            label,
326            path: oath_path.to_string(),
327            line: anchor.start_line,
328            anchor: AnchorRange {
329                from: anchor.start_offset,
330                to: anchor.end_offset,
331            },
332        }),
333    }
334}
335
336fn truncate_label(text: &str) -> String {
337    if utf16_len(text) > 60 {
338        let truncated: String = text.chars().take(60).collect();
339        format!("{truncated}…")
340    } else {
341        text.to_string()
342    }
343}
344
345// -----------------------------------------------------------------------------
346// Sensor return comparison
347// -----------------------------------------------------------------------------
348
349fn check_sensor_return(
350    source: &str,
351    step: &PlannedStep,
352    returned: Option<Value>,
353) -> Result<(), StepError> {
354    let extra_count = usize::from(step.data_table.is_some() || step.doc_string.is_some());
355    let slot_count = step.args.len() + extra_count;
356    // With one or more slots the return is REQUIRED: returning nothing used to
357    // skip the comparison silently, so a typo turned an assertion into a no-op.
358    let returned = match returned {
359        // Nothing to compare against: returning nothing is the pass.
360        None if slot_count == 0 => return Ok(()),
361        None => {
362            return Err(StepError::ReturnShape(format!(
363                "a sensor with {slot_count} slot(s) must return one value per slot, got nothing"
364            )));
365        }
366        Some(v) => v,
367    };
368    if slot_count == 0 {
369        return Err(StepError::ReturnShape(
370            "this sensor has no parameters, data table or doc string — nothing to compare a return value against \
371             (throw to fail, return nothing to pass)"
372                .to_string(),
373        ));
374    }
375    let slots: Vec<Value> = if slot_count == 1 {
376        // The return IS the single slot's value, never read as a positional list.
377        vec![returned]
378    } else {
379        match returned {
380            Value::List(list) => {
381                if list.len() != slot_count {
382                    return Err(StepError::ReturnShape(format!(
383                        "sensor return must have {} element(s), got {}",
384                        slot_count,
385                        list.len()
386                    )));
387                }
388                list
389            }
390            other => {
391                return Err(StepError::ReturnShape(format!(
392                    "a sensor with {} slots must return a List of {} values, got {}",
393                    slot_count,
394                    slot_count,
395                    other.type_name()
396                )));
397            }
398        }
399    };
400
401    let arg_count = step.args.len();
402    if arg_count > 0 {
403        let source_texts: Vec<String> = step
404            .param_spans
405            .iter()
406            .map(|s| utf16_slice(source, s.start_offset, s.end_offset).to_string())
407            .collect();
408        let bad: Vec<CellDiff> = compare_params_with_formats(
409            &slots[0..arg_count],
410            &step.args,
411            &step.param_spans,
412            &source_texts,
413            Some(&step.formats),
414        )
415        .into_iter()
416        .filter(|d| !d.ok)
417        .collect();
418        if !bad.is_empty() {
419            return Err(StepError::CellMismatch(bad));
420        }
421    }
422
423    if let Some(table) = &step.data_table {
424        let bad: Vec<CellDiff> = compare_table(Some(&slots[arg_count]), table)?
425            .into_iter()
426            .filter(|d| !d.ok)
427            .collect();
428        if !bad.is_empty() {
429            return Err(StepError::CellMismatch(bad));
430        }
431    } else if let Some(fence) = &step.doc_string {
432        if let Some(diff) =
433            compare_doc_string(Some(&slots[arg_count]), &fence.body, fence.body_span)?
434        {
435            return Err(StepError::CellMismatch(vec![diff]));
436        }
437    }
438    Ok(())
439}
440
441// -----------------------------------------------------------------------------
442// Handler invocation (panic-catching + async resolution)
443// -----------------------------------------------------------------------------
444
445thread_local! {
446    static SUPPRESS_PANIC: Cell<bool> = const { Cell::new(false) };
447}
448
449static HOOK: Once = Once::new();
450
451/// Installs a panic hook (once) that suppresses the default stderr print for
452/// panics the executor deliberately catches (a handler's assertion-style
453/// failure), while leaving genuine test panics untouched on other threads.
454///
455/// DECLARED EXCEPTION to the "no globals in the core" rule (see `lib.rs`):
456/// `catch_unwind` is the executor's assertion channel — the AssertionError
457/// parity with Java — and the process-wide hook is the only way Rust offers to
458/// keep a *caught* panic from spewing to stderr. It is `Once`-guarded, chains
459/// the previous hook, and gates on a thread-local so it is inert outside
460/// [`invoke_resolve`]; observable behaviour is otherwise unchanged.
461fn install_hook() {
462    HOOK.call_once(|| {
463        let previous = std::panic::take_hook();
464        std::panic::set_hook(Box::new(move |info| {
465            if SUPPRESS_PANIC.with(Cell::get) {
466                return;
467            }
468            previous(info);
469        }));
470    });
471}
472
473/// Invokes the handler and resolves any `Future`, catching a panic (the
474/// assertion-style failure channel) into a [`HandlerError`].
475fn invoke_resolve(
476    handler: &Handler,
477    state: Rc<dyn Any>,
478    args: Vec<Value>,
479) -> Result<StepOutput, HandlerError> {
480    install_hook();
481    let caught = SUPPRESS_PANIC.with(|s| {
482        s.set(true);
483        let r = std::panic::catch_unwind(AssertUnwindSafe(|| match handler.call(state, args) {
484            StepReturn::Ready(r) => r,
485            StepReturn::Pending(fut) => block_on(fut),
486        }));
487        s.set(false);
488        r
489    });
490    match caught {
491        Ok(r) => r,
492        Err(payload) => Err(HandlerError::from_panic(payload)),
493    }
494}
495
496/// A minimal `block_on`: polls the future, parking the thread until its waker
497/// unparks it. No dependencies, no unsafe.
498fn block_on<T>(mut fut: Pin<Box<dyn Future<Output = T>>>) -> T {
499    struct ThreadWaker(std::thread::Thread);
500    impl Wake for ThreadWaker {
501        fn wake(self: std::sync::Arc<Self>) {
502            self.0.unpark();
503        }
504        fn wake_by_ref(self: &std::sync::Arc<Self>) {
505            self.0.unpark();
506        }
507    }
508    let waker = Waker::from(std::sync::Arc::new(ThreadWaker(std::thread::current())));
509    let mut cx = Context::from_waker(&waker);
510    loop {
511        match fut.as_mut().poll(&mut cx) {
512            Poll::Ready(v) => return v,
513            Poll::Pending => std::thread::park(),
514        }
515    }
516}