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