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                // Like a slotted sensor, a header-bound row step must answer the
243                // row it is bound to: no return means nothing was compared.
244                if last_return.is_none() || !bad.is_empty() {
245                    let last_step = steps.last().unwrap();
246                    let err = if last_return.is_none() {
247                        StepError::ReturnShape(
248                            "a header-bound row step must return a row object with one value per bound cell, got nothing".to_string(),
249                        )
250                    } else {
251                        StepError::CellMismatch(bad)
252                    };
253                    let failure = attach_location(err, last_step, path);
254                    observe(
255                        ports,
256                        StepObservation {
257                            example_index,
258                            ordinal: steps.len(),
259                            outcome: StepOutcome::Fail,
260                            error: Some(failure.clone()),
261                        },
262                    );
263                    thrown = Some(failure);
264                }
265            }
266        }
267    }
268
269    // Error-fence inversion.
270    if ex.expected_outcome.as_deref() == Some("fail") {
271        match thrown {
272            None => {
273                return Err(match steps.last() {
274                    Some(last) => attach_location(StepError::UnexpectedPass, last, path),
275                    None => StepFailure::bare(StepError::UnexpectedPass),
276                });
277            }
278            Some(failure) => {
279                if let Some(expected_msg) = &ex.expected_error_message {
280                    if !failure.error.message().contains(expected_msg) {
281                        return Err(failure);
282                    }
283                }
284                return Ok(());
285            }
286        }
287    }
288
289    match thrown {
290        Some(failure) => Err(failure),
291        None => Ok(()),
292    }
293}
294
295fn create_context(ports: &ExecutePorts, file: &str) -> Rc<dyn Any> {
296    match &ports.create_context {
297        Some(cc) => cc(file),
298        None => Rc::new(()) as Rc<dyn Any>,
299    }
300}
301
302fn observe(ports: &ExecutePorts, observation: StepObservation) {
303    if let Some(observer) = &ports.observer {
304        observer(observation);
305    }
306}
307
308fn table_rows(table: &crate::ast::Table) -> Value {
309    let row =
310        |cells: &[String]| Value::List(cells.iter().map(|c| Value::from(c.as_str())).collect());
311    let mut rows = vec![row(&table.header.cells)];
312    for r in &table.rows {
313        rows.push(row(&r.cells));
314    }
315    Value::List(rows)
316}
317
318fn attach_location(error: StepError, step: &PlannedStep, var_path: &str) -> StepFailure {
319    let anchor = failure_anchor::anchor(&error, step.match_span);
320    let label = truncate_label(&step.text);
321    StepFailure {
322        error,
323        location: Some(FailureLocation {
324            label,
325            path: var_path.to_string(),
326            line: anchor.start_line,
327        }),
328    }
329}
330
331fn truncate_label(text: &str) -> String {
332    if utf16_len(text) > 60 {
333        let truncated: String = text.chars().take(60).collect();
334        format!("{truncated}…")
335    } else {
336        text.to_string()
337    }
338}
339
340// -----------------------------------------------------------------------------
341// Sensor return comparison
342// -----------------------------------------------------------------------------
343
344fn check_sensor_return(
345    source: &str,
346    step: &PlannedStep,
347    returned: Option<Value>,
348) -> Result<(), StepError> {
349    let extra_count = usize::from(step.data_table.is_some() || step.doc_string.is_some());
350    let slot_count = step.args.len() + extra_count;
351    // With one or more slots the return is REQUIRED: returning nothing used to
352    // skip the comparison silently, so a typo turned an assertion into a no-op.
353    let returned = match returned {
354        // Nothing to compare against: returning nothing is the pass.
355        None if slot_count == 0 => return Ok(()),
356        None => {
357            return Err(StepError::ReturnShape(format!(
358                "a sensor with {slot_count} slot(s) must return one value per slot, got nothing"
359            )));
360        }
361        Some(v) => v,
362    };
363    if slot_count == 0 {
364        return Err(StepError::ReturnShape(
365            "this sensor has no parameters, data table or doc string — nothing to compare a return value against \
366             (throw to fail, return nothing to pass)"
367                .to_string(),
368        ));
369    }
370    let slots: Vec<Value> = if slot_count == 1 {
371        // The return IS the single slot's value, never read as a positional list.
372        vec![returned]
373    } else {
374        match returned {
375            Value::List(list) => {
376                if list.len() != slot_count {
377                    return Err(StepError::ReturnShape(format!(
378                        "sensor return must have {} element(s), got {}",
379                        slot_count,
380                        list.len()
381                    )));
382                }
383                list
384            }
385            other => {
386                return Err(StepError::ReturnShape(format!(
387                    "a sensor with {} slots must return a List of {} values, got {}",
388                    slot_count,
389                    slot_count,
390                    other.type_name()
391                )));
392            }
393        }
394    };
395
396    let arg_count = step.args.len();
397    if arg_count > 0 {
398        let source_texts: Vec<String> = step
399            .param_spans
400            .iter()
401            .map(|s| utf16_slice(source, s.start_offset, s.end_offset).to_string())
402            .collect();
403        let bad: Vec<CellDiff> = compare_params_with_formats(
404            &slots[0..arg_count],
405            &step.args,
406            &step.param_spans,
407            &source_texts,
408            Some(&step.formats),
409        )
410        .into_iter()
411        .filter(|d| !d.ok)
412        .collect();
413        if !bad.is_empty() {
414            return Err(StepError::CellMismatch(bad));
415        }
416    }
417
418    if let Some(table) = &step.data_table {
419        let bad: Vec<CellDiff> = compare_table(Some(&slots[arg_count]), table)?
420            .into_iter()
421            .filter(|d| !d.ok)
422            .collect();
423        if !bad.is_empty() {
424            return Err(StepError::CellMismatch(bad));
425        }
426    } else if let Some(fence) = &step.doc_string {
427        if let Some(diff) =
428            compare_doc_string(Some(&slots[arg_count]), &fence.body, fence.body_span)?
429        {
430            return Err(StepError::CellMismatch(vec![diff]));
431        }
432    }
433    Ok(())
434}
435
436// -----------------------------------------------------------------------------
437// Handler invocation (panic-catching + async resolution)
438// -----------------------------------------------------------------------------
439
440thread_local! {
441    static SUPPRESS_PANIC: Cell<bool> = const { Cell::new(false) };
442}
443
444static HOOK: Once = Once::new();
445
446/// Installs a panic hook (once) that suppresses the default stderr print for
447/// panics the executor deliberately catches (a handler's assertion-style
448/// failure), while leaving genuine test panics untouched on other threads.
449///
450/// DECLARED EXCEPTION to the "no globals in the core" rule (see `lib.rs`):
451/// `catch_unwind` is the executor's assertion channel — the AssertionError
452/// parity with Java — and the process-wide hook is the only way Rust offers to
453/// keep a *caught* panic from spewing to stderr. It is `Once`-guarded, chains
454/// the previous hook, and gates on a thread-local so it is inert outside
455/// [`invoke_resolve`]; observable behaviour is otherwise unchanged.
456fn install_hook() {
457    HOOK.call_once(|| {
458        let previous = std::panic::take_hook();
459        std::panic::set_hook(Box::new(move |info| {
460            if SUPPRESS_PANIC.with(Cell::get) {
461                return;
462            }
463            previous(info);
464        }));
465    });
466}
467
468/// Invokes the handler and resolves any `Future`, catching a panic (the
469/// assertion-style failure channel) into a [`HandlerError`].
470fn invoke_resolve(
471    handler: &Handler,
472    state: Rc<dyn Any>,
473    args: Vec<Value>,
474) -> Result<StepOutput, HandlerError> {
475    install_hook();
476    let caught = SUPPRESS_PANIC.with(|s| {
477        s.set(true);
478        let r = std::panic::catch_unwind(AssertUnwindSafe(|| match handler.call(state, args) {
479            StepReturn::Ready(r) => r,
480            StepReturn::Pending(fut) => block_on(fut),
481        }));
482        s.set(false);
483        r
484    });
485    match caught {
486        Ok(r) => r,
487        Err(payload) => Err(HandlerError::from_panic(payload)),
488    }
489}
490
491/// A minimal `block_on`: polls the future, parking the thread until its waker
492/// unparks it. No dependencies, no unsafe.
493fn block_on<T>(mut fut: Pin<Box<dyn Future<Output = T>>>) -> T {
494    struct ThreadWaker(std::thread::Thread);
495    impl Wake for ThreadWaker {
496        fn wake(self: std::sync::Arc<Self>) {
497            self.0.unpark();
498        }
499        fn wake_by_ref(self: &std::sync::Arc<Self>) {
500            self.0.unpark();
501        }
502    }
503    let waker = Waker::from(std::sync::Arc::new(ThreadWaker(std::thread::current())));
504    let mut cx = Context::from_waker(&waker);
505    loop {
506        match fut.as_mut().poll(&mut cx) {
507            Poll::Ready(v) => return v,
508            Poll::Pending => std::thread::park(),
509        }
510    }
511}