Skip to main content

seq_runtime/
test.rs

1//! Test framework support for Seq
2//!
3//! Provides assertion primitives and test context management for the `seqc test` runner.
4//! Assertions collect failures instead of panicking, allowing all tests to run and
5//! report comprehensive results.
6//!
7//! These functions are exported with C ABI for LLVM codegen to call.
8
9use crate::stack::{Stack, pop, push};
10use crate::value::Value;
11use std::sync::Mutex;
12
13/// Render a stack `Value` for a failure message — prefers the natural
14/// form for `Int` / `Bool`, falls back to debug for anything else.
15fn display_value(val: &Value) -> String {
16    match val {
17        Value::Bool(b) => b.to_string(),
18        Value::Int(n) => n.to_string(),
19        other => format!("{:?}", other),
20    }
21}
22
23/// Escape a string for inclusion in a single-line failure detail.
24///
25/// `\n`, `\r`, `\t`, `\\`, `\"` become their two-character source forms;
26/// other control bytes (< 0x20 or 0x7F) become `\xNN`. Non-ASCII bytes
27/// pass through unchanged so legible UTF-8 stays legible.
28///
29/// Critical for the `assert-eq-str` failure path: the test runner's
30/// `collect_failure_block` only attaches continuation lines that start
31/// with whitespace, so an embedded raw newline in a value would break
32/// the failure block in two and silently truncate the report.
33fn escape_for_display(s: &str) -> String {
34    let mut out = String::with_capacity(s.len());
35    for b in s.bytes() {
36        match b {
37            b'\\' => out.push_str("\\\\"),
38            b'"' => out.push_str("\\\""),
39            b'\n' => out.push_str("\\n"),
40            b'\r' => out.push_str("\\r"),
41            b'\t' => out.push_str("\\t"),
42            0x20..=0x7E => out.push(b as char),
43            0x00..=0x1F | 0x7F => {
44                use std::fmt::Write;
45                let _ = write!(out, "\\x{:02X}", b);
46            }
47            _ => out.push(b as char),
48        }
49    }
50    out
51}
52
53/// Discriminator for how a failure should be rendered. The string-eq
54/// path needs a multi-line block (escaped values, byte counts, first
55/// differing offset); other failures keep the historical single-line
56/// `expected E, got A` shape.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum FailureKind {
59    #[default]
60    Other,
61    StringEq,
62}
63
64/// Maximum number of per-test assertion failures to print in the run
65/// summary. Additional failures are rolled up into a `+N more failure(s)`
66/// footer so noisy tests (loop-like assertions over lists) don't drown
67/// the overall report. Tune here if feedback suggests a different value.
68const MAX_PRINTED_FAILURES_PER_TEST: usize = 5;
69
70/// A single test failure with context
71#[derive(Debug, Clone)]
72pub struct TestFailure {
73    /// Source line of the assertion (1-indexed), if codegen set one.
74    pub line: Option<u32>,
75    pub message: String,
76    /// For `kind == StringEq` these hold the *raw* values; the printer
77    /// escapes them. For other kinds they hold pre-formatted strings.
78    pub expected: Option<String>,
79    pub actual: Option<String>,
80    pub kind: FailureKind,
81}
82
83/// Test context that tracks assertion results
84#[derive(Debug, Default)]
85pub struct TestContext {
86    /// Current test name being executed
87    pub current_test: Option<String>,
88    /// Source line of the assertion most recently announced by codegen.
89    /// Set by `patch_seq_test_set_line` just before each `test.assert*`
90    /// call; captured into a `TestFailure` if the assertion fails.
91    pub current_line: Option<u32>,
92    /// Number of passed assertions
93    pub passes: usize,
94    /// Collected failures
95    pub failures: Vec<TestFailure>,
96}
97
98impl TestContext {
99    pub fn new() -> Self {
100        Self::default()
101    }
102
103    pub fn reset(&mut self, test_name: Option<String>) {
104        self.current_test = test_name;
105        self.current_line = None;
106        self.passes = 0;
107        self.failures.clear();
108    }
109
110    pub fn record_pass(&mut self) {
111        self.passes += 1;
112        // Consume the line so a following assertion without a `set_line`
113        // hook (defensive — span-less `WordCall`s don't emit one) can't
114        // inherit this one's attribution.
115        self.current_line = None;
116    }
117
118    pub fn record_failure(
119        &mut self,
120        message: String,
121        expected: Option<String>,
122        actual: Option<String>,
123    ) {
124        self.failures.push(TestFailure {
125            line: self.current_line,
126            message,
127            expected,
128            actual,
129            kind: FailureKind::Other,
130        });
131        // Same rationale as `record_pass`: don't let this line bleed into
132        // the next assertion's record.
133        self.current_line = None;
134    }
135
136    /// Record a string-equality failure. Stores the raw (unescaped)
137    /// values; rendering happens at print time so we keep the byte
138    /// counts and first-differing offset accurate.
139    pub fn record_string_eq_failure(&mut self, expected: String, actual: String) {
140        self.failures.push(TestFailure {
141            line: self.current_line,
142            message: "assertion failed: strings not equal".to_string(),
143            expected: Some(expected),
144            actual: Some(actual),
145            kind: FailureKind::StringEq,
146        });
147        self.current_line = None;
148    }
149
150    pub fn has_failures(&self) -> bool {
151        !self.failures.is_empty()
152    }
153}
154
155/// Global test context protected by mutex
156static TEST_CONTEXT: Mutex<TestContext> = Mutex::new(TestContext {
157    current_test: None,
158    current_line: None,
159    passes: 0,
160    failures: Vec::new(),
161});
162
163/// Announce the source line of the next `test.assert*` call.
164///
165/// Called by generated code immediately before each assertion so the
166/// runtime can attribute a failure to its source position. `line` is
167/// 1-indexed; pass 0 to clear.
168///
169/// This helper takes a raw `i64` rather than a stack argument because it
170/// is a compiler-emitted diagnostic, not a user-callable Seq builtin.
171///
172/// # Safety
173///
174/// Safe to call from any thread. Acquires the global test-context
175/// mutex; no other preconditions.
176#[unsafe(no_mangle)]
177pub unsafe extern "C" fn patch_seq_test_set_line(line: i64) {
178    let mut ctx = TEST_CONTEXT.lock().unwrap();
179    // Reject 0 (the agreed "clear" sentinel) and any value that can't
180    // fit in a u32 (no real source file has 4B lines, but be explicit
181    // about truncation intent rather than silently wrapping).
182    ctx.current_line = if line > 0 {
183        u32::try_from(line).ok()
184    } else {
185        None
186    };
187}
188
189/// Set the current test's display name without touching any other state.
190///
191/// Used by the `seqc test` runner to reassert the word-level test name
192/// after the user's test word has run, in case the user called
193/// `test.init "friendly name"` internally and overwrote the header.
194/// Unlike `test.init`, this does NOT clear `failures`, `passes`, or
195/// `current_line`.
196///
197/// Stack effect: ( ..a String -- ..a )
198///
199/// # Safety
200/// Stack must have a String (test name) on top.
201#[unsafe(no_mangle)]
202pub unsafe extern "C" fn patch_seq_test_set_name(stack: Stack) -> Stack {
203    unsafe {
204        let (stack, name_val) = pop(stack);
205        let name = match name_val {
206            Value::String(s) => s.as_str_or_empty().to_string(),
207            _ => panic!("test.set-name: expected String (test name) on stack"),
208        };
209        let mut ctx = TEST_CONTEXT.lock().unwrap();
210        ctx.current_test = Some(name);
211        stack
212    }
213}
214
215/// Initialize test context for a new test
216///
217/// Stack effect: ( name -- )
218///
219/// # Safety
220/// Stack must have a String (test name) on top
221#[unsafe(no_mangle)]
222pub unsafe extern "C" fn patch_seq_test_init(stack: Stack) -> Stack {
223    unsafe {
224        let (stack, name_val) = pop(stack);
225        let name = match name_val {
226            Value::String(s) => s.as_str_or_empty().to_string(),
227            _ => panic!("test.init: expected String (test name) on stack"),
228        };
229
230        let mut ctx = TEST_CONTEXT.lock().unwrap();
231        ctx.reset(Some(name));
232        stack
233    }
234}
235
236/// Render a single failure as one or more indented detail lines,
237/// terminated by a final newline. The result is always whitespace-prefixed
238/// on every line so the runner's `collect_failure_block` attaches it to
239/// the preceding `... FAILED` header.
240///
241/// Pure (no I/O, no globals) so the output is unit-testable.
242pub fn format_failure_detail(failure: &TestFailure) -> String {
243    use std::fmt::Write;
244    let mut out = String::new();
245
246    match (failure.kind, &failure.expected, &failure.actual) {
247        (FailureKind::StringEq, Some(e), Some(a)) => {
248            match failure.line {
249                Some(line) => writeln!(out, "  at line {}: {}", line, failure.message).unwrap(),
250                None => writeln!(out, "  {}", failure.message).unwrap(),
251            }
252            writeln!(
253                out,
254                "    expected ({} bytes): \"{}\"",
255                e.len(),
256                escape_for_display(e)
257            )
258            .unwrap();
259            writeln!(
260                out,
261                "    actual   ({} bytes): \"{}\"",
262                a.len(),
263                escape_for_display(a)
264            )
265            .unwrap();
266            if e != a {
267                let common = e
268                    .as_bytes()
269                    .iter()
270                    .zip(a.as_bytes().iter())
271                    .take_while(|(x, y)| x == y)
272                    .count();
273                writeln!(out, "    first differ at byte {}", common).unwrap();
274            }
275        }
276        (_, Some(e), Some(a)) => {
277            let detail = format!("expected {}, got {}", e, a);
278            match failure.line {
279                Some(line) => writeln!(out, "  at line {}: {}", line, detail).unwrap(),
280                None => writeln!(out, "  {}", detail).unwrap(),
281            }
282        }
283        _ => match failure.line {
284            Some(line) => writeln!(out, "  at line {}: {}", line, failure.message).unwrap(),
285            None => writeln!(out, "  {}", failure.message).unwrap(),
286        },
287    }
288
289    out
290}
291
292/// Finalize test and print results
293///
294/// Stack effect: ( -- )
295///
296/// Prints pass/fail summary for the current test in a format parseable by the test runner.
297/// Output format: "test-name ... ok" or "test-name ... FAILED"
298///
299/// # Safety
300/// Stack pointer must be valid
301#[unsafe(no_mangle)]
302pub unsafe extern "C" fn patch_seq_test_finish(stack: Stack) -> Stack {
303    let ctx = TEST_CONTEXT.lock().unwrap();
304    let test_name = ctx.current_test.as_deref().unwrap_or("unknown");
305
306    if ctx.failures.is_empty() {
307        // Output pass in parseable format
308        println!("{} ... ok", test_name);
309    } else {
310        // Output failure in parseable format. Detail lines are emitted on
311        // STDOUT, indented, so the test runner can associate them with the
312        // preceding FAILED header on the same stream.
313        // Cap the per-test output so a flood of failures (e.g. a loop-like
314        // test walking a list) doesn't drown the summary. The first
315        // `MAX_PRINTED_FAILURES_PER_TEST` are printed in full; a footer
316        // counts anything suppressed.
317        println!("{} ... FAILED", test_name);
318        for failure in ctx.failures.iter().take(MAX_PRINTED_FAILURES_PER_TEST) {
319            print!("{}", format_failure_detail(failure));
320        }
321        if ctx.failures.len() > MAX_PRINTED_FAILURES_PER_TEST {
322            let remaining = ctx.failures.len() - MAX_PRINTED_FAILURES_PER_TEST;
323            let s = if remaining == 1 { "" } else { "s" };
324            println!("  +{} more failure{}", remaining, s);
325        }
326    }
327
328    stack
329}
330
331/// Check if any assertions failed
332///
333/// Stack effect: ( -- Int )
334///
335/// Returns 1 if there are failures, 0 if all passed.
336///
337/// # Safety
338/// Stack pointer must be valid
339#[unsafe(no_mangle)]
340pub unsafe extern "C" fn patch_seq_test_has_failures(stack: Stack) -> Stack {
341    let ctx = TEST_CONTEXT.lock().unwrap();
342    let has_failures = ctx.has_failures();
343    unsafe { push(stack, Value::Bool(has_failures)) }
344}
345
346/// Assert that a value is truthy (non-zero)
347///
348/// Stack effect: ( Int -- )
349///
350/// Records failure if value is 0, records pass otherwise.
351///
352/// # Safety
353/// Stack must have an Int on top
354#[unsafe(no_mangle)]
355pub unsafe extern "C" fn patch_seq_test_assert(stack: Stack) -> Stack {
356    unsafe {
357        let (stack, val) = pop(stack);
358        let condition = match val {
359            Value::Int(n) => n != 0,
360            Value::Bool(b) => b,
361            _ => panic!("test.assert: expected Int or Bool on stack, got {:?}", val),
362        };
363
364        let mut ctx = TEST_CONTEXT.lock().unwrap();
365        if condition {
366            ctx.record_pass();
367        } else {
368            ctx.record_failure(
369                "assertion failed".to_string(),
370                Some("true".to_string()),
371                Some(display_value(&val)),
372            );
373        }
374
375        stack
376    }
377}
378
379/// Assert that a value is falsy (zero)
380///
381/// Stack effect: ( Int -- )
382///
383/// Records failure if value is non-zero, records pass otherwise.
384///
385/// # Safety
386/// Stack must have an Int on top
387#[unsafe(no_mangle)]
388pub unsafe extern "C" fn patch_seq_test_assert_not(stack: Stack) -> Stack {
389    unsafe {
390        let (stack, val) = pop(stack);
391        let is_falsy = match val {
392            Value::Int(n) => n == 0,
393            Value::Bool(b) => !b,
394            _ => panic!(
395                "test.assert-not: expected Int or Bool on stack, got {:?}",
396                val
397            ),
398        };
399
400        let mut ctx = TEST_CONTEXT.lock().unwrap();
401        if is_falsy {
402            ctx.record_pass();
403        } else {
404            ctx.record_failure(
405                "assertion failed".to_string(),
406                Some("false".to_string()),
407                Some(display_value(&val)),
408            );
409        }
410
411        stack
412    }
413}
414
415/// Assert that two integers are equal
416///
417/// Stack effect: ( actual expected -- )
418///
419/// Records failure if values differ, records pass otherwise.
420///
421/// # Safety
422/// Stack must have two Ints on top
423#[unsafe(no_mangle)]
424pub unsafe extern "C" fn patch_seq_test_assert_eq(stack: Stack) -> Stack {
425    unsafe {
426        let (stack, expected_val) = pop(stack);
427        let (stack, actual_val) = pop(stack);
428
429        let (expected, actual) = match (&expected_val, &actual_val) {
430            (Value::Int(e), Value::Int(a)) => (*e, *a),
431            _ => panic!(
432                "test.assert-eq: expected two Ints on stack, got {:?} and {:?}",
433                expected_val, actual_val
434            ),
435        };
436
437        let mut ctx = TEST_CONTEXT.lock().unwrap();
438        if expected == actual {
439            ctx.record_pass();
440        } else {
441            ctx.record_failure(
442                "assertion failed: values not equal".to_string(),
443                Some(expected.to_string()),
444                Some(actual.to_string()),
445            );
446        }
447
448        stack
449    }
450}
451
452/// Assert that two strings are equal
453///
454/// Stack effect: ( actual expected -- )
455///
456/// Records failure if strings differ, records pass otherwise.
457///
458/// # Safety
459/// Stack must have two Strings on top
460#[unsafe(no_mangle)]
461pub unsafe extern "C" fn patch_seq_test_assert_eq_str(stack: Stack) -> Stack {
462    unsafe {
463        let (stack, expected_val) = pop(stack);
464        let (stack, actual_val) = pop(stack);
465
466        let (expected, actual) = match (&expected_val, &actual_val) {
467            (Value::String(e), Value::String(a)) => (
468                e.as_str_or_empty().to_string(),
469                a.as_str_or_empty().to_string(),
470            ),
471            _ => panic!(
472                "test.assert-eq-str: expected two Strings on stack, got {:?} and {:?}",
473                expected_val, actual_val
474            ),
475        };
476
477        let mut ctx = TEST_CONTEXT.lock().unwrap();
478        if expected == actual {
479            ctx.record_pass();
480        } else {
481            ctx.record_string_eq_failure(expected, actual);
482        }
483
484        stack
485    }
486}
487
488/// Explicitly fail a test with a message
489///
490/// Stack effect: ( message -- )
491///
492/// Always records a failure with the given message.
493///
494/// # Safety
495/// Stack must have a String on top
496#[unsafe(no_mangle)]
497pub unsafe extern "C" fn patch_seq_test_fail(stack: Stack) -> Stack {
498    unsafe {
499        let (stack, msg_val) = pop(stack);
500        let message = match msg_val {
501            Value::String(s) => s.as_str_or_empty().to_string(),
502            _ => panic!("test.fail: expected String (message) on stack"),
503        };
504
505        let mut ctx = TEST_CONTEXT.lock().unwrap();
506        ctx.record_failure(message, None, None);
507
508        stack
509    }
510}
511
512/// Get the number of passed assertions
513///
514/// Stack effect: ( -- Int )
515///
516/// # Safety
517/// Stack pointer must be valid
518#[unsafe(no_mangle)]
519pub unsafe extern "C" fn patch_seq_test_pass_count(stack: Stack) -> Stack {
520    let ctx = TEST_CONTEXT.lock().unwrap();
521    unsafe { push(stack, Value::Int(ctx.passes as i64)) }
522}
523
524/// Get the number of failed assertions
525///
526/// Stack effect: ( -- Int )
527///
528/// # Safety
529/// Stack pointer must be valid
530#[unsafe(no_mangle)]
531pub unsafe extern "C" fn patch_seq_test_fail_count(stack: Stack) -> Stack {
532    let ctx = TEST_CONTEXT.lock().unwrap();
533    unsafe { push(stack, Value::Int(ctx.failures.len() as i64)) }
534}
535
536#[cfg(test)]
537mod tests;