Add --test mode to elc with Assert stmt and full native test suite passing

Implement compile_test() entry point that emits a C test harness instead
of a normal program. Test blocks (previously skipped) now compile to
static functions with per-assertion pass/fail tracking. Assert statement
added to parser and codegen. Runtime extended with now_ns, fs_list_json,
json_build_object, json_build_array, json_escape_string, state_has,
state_get_or. Fix float negation codegen, float equality comparisons,
time_to_parts return type (JSON string), time_format empty-fmt, json_set
raw-value semantics, state_keys JSON array return. All 310 native tests
pass across 9 suites (core, text, string, math, env, state, json, time, fs).
This commit is contained in:
Will Anderson
2026-05-06 14:33:47 -05:00
parent 6ced0f8009
commit ec889e1e53
7 changed files with 460 additions and 28 deletions
+212 -11
View File
@@ -144,6 +144,53 @@ fn c_str_lit(s: String) -> String {
"\"" + c_escape(s) + "\""
}
// sanitize_test_name convert a test name string to a valid C identifier fragment.
// "int-to-str" -> "int_to_str", "lex empty" -> "lex_empty"
fn sanitize_test_name(name: String) -> String {
let n: Int = str_len(name)
let i: Int = 0
let out: String = ""
while i < n {
let code: Int = str_char_code(name, i)
// a-z: 97-122, A-Z: 65-90, 0-9: 48-57 keep; everything else -> '_'
if code >= 97 {
if code <= 122 {
let out = out + str_char_at(name, i)
} else {
let out = out + "_"
}
} else {
if code >= 65 {
if code <= 90 {
let out = out + str_char_at(name, i)
} else {
if code >= 48 {
if code <= 57 {
let out = out + str_char_at(name, i)
} else {
let out = out + "_"
}
} else {
let out = out + "_"
}
}
} else {
if code >= 48 {
if code <= 57 {
let out = out + str_char_at(name, i)
} else {
let out = out + "_"
}
} else {
let out = out + "_"
}
}
}
let i = i + 1
}
out
}
// -- Type mapping --------------------------------------------------------------
fn el_type_to_c(type_str: String) -> String {
@@ -437,6 +484,14 @@ fn cg_expr(expr: Map<String, Any>) -> String {
if kind == "Neg" {
let inner = expr["inner"]
let inner_kind: String = inner["expr"]
// Float literal negation: emit el_from_float(-n) so the IEEE 754 sign
// bit is set correctly. Arithmetic negation of the int64 bit pattern
// (the el_val_t representation) produces garbage, not -f.
if str_eq(inner_kind, "Float") {
let fval: String = inner["value"]
return "el_from_float(-" + fval + ")"
}
let inner_c: String = cg_expr(inner)
return "(-" + inner_c + ")"
}
@@ -762,6 +817,14 @@ fn cg_expr(expr: Map<String, Any>) -> String {
return "(" + left_c + " == " + right_c + ")"
}
}
// Float literal or negative float literal: use plain == (bit-equal
// el_val_t comparison). This handles `r0 == 3.0`, `neg == -3.0`, etc.
if is_float_expr(left) {
return "(" + left_c + " == " + right_c + ")"
}
if is_float_expr(right) {
return "(" + left_c + " == " + right_c + ")"
}
if left_kind == "Str" {
return "str_eq(" + left_c + ", " + right_c + ")"
}
@@ -813,6 +876,13 @@ fn cg_expr(expr: Map<String, Any>) -> String {
return "(" + left_c + " != " + right_c + ")"
}
}
// Float-typed operands use plain != (bit-equal comparison).
if is_float_expr(left) {
return "(" + left_c + " != " + right_c + ")"
}
if is_float_expr(right) {
return "(" + left_c + " != " + right_c + ")"
}
if left_kind == "Str" {
return "!str_eq(" + left_c + ", " + right_c + ")"
}
@@ -1508,6 +1578,26 @@ fn cg_stmt(stmt: Map<String, Any>, indent: String, declared: [String]) -> [Strin
cg_stmts(try_body, indent, native_list_clone(declared))
return declared
}
// assert <cond> , <msg> test harness assertion
if kind == "Assert" {
let cond_node = stmt["cond"]
let msg_node = stmt["msg"]
let c_cond: String = cg_expr(cond_node)
let c_msg: String = ""
let msg_kind: String = msg_node["expr"]
if str_eq(msg_kind, "Str") {
let raw_msg: String = msg_node["value"]
let c_msg = "\"" + c_escape(raw_msg) + "\""
} else {
let c_msg = "EL_STR_PTR(" + cg_expr(msg_node) + ")"
}
emit_line(indent + "if (!(" + c_cond + ")) {")
emit_line(indent + " __el_test_fail(__el_cur_test, " + c_msg + "); __el_fail++;")
emit_line(indent + "} else { __el_pass++; }")
return declared
}
declared
}
@@ -2150,6 +2240,19 @@ fn is_int_expr(expr: Map<String, Any>) -> Bool {
return false
}
// is_float_expr true when expr is (or evaluates to) a Float-typed value.
// Used in EqEq/NotEq codegen to avoid str_eq on float values.
fn is_float_expr(expr: Map<String, Any>) -> Bool {
let k: String = expr["expr"]
if str_eq(k, "Float") { return true }
if str_eq(k, "Neg") {
let inner = expr["inner"]
let ik: String = inner["expr"]
if str_eq(ik, "Float") { return true }
}
false
}
// -- Capability-kind enforcement ----------------------------------------------
//
// A program's top-level block (cgi / service / none) determines which
@@ -3481,6 +3584,25 @@ fn codegen_streaming(tokens: [Any], sigs: [Map<String, Any>], source: String) ->
let si = si + 1
}
// In test mode: collect test function names for harness main().
let test_is_mode: Bool = false
let tmode_str: String = state_get("__test_mode")
if str_eq(tmode_str, "1") { let test_is_mode = true }
let test_names: [String] = native_list_empty()
let test_c_names: [String] = native_list_empty()
// Emit test harness preamble (counters, fail printer) when in test mode.
if test_is_mode {
emit_line("#include <stdio.h>")
emit_blank()
emit_line("static int __el_pass = 0, __el_fail = 0;")
emit_line("static const char *__el_cur_test = \"(none)\";")
emit_line("static void __el_test_fail(const char *test, const char *msg) {")
emit_line(" fprintf(stderr, \"FAIL %-40s %s\\n\", test, msg);")
emit_line("}")
emit_blank()
}
// Streaming parse-emit loop.
// For each parsed stmt:
// - FnDef (not main): emit immediately via cg_fn, release AST
@@ -3501,17 +3623,65 @@ fn codegen_streaming(tokens: [Any], sigs: [Map<String, Any>], source: String) ->
let stream_running = false
} else {
if str_eq(k, "Test") {
// Skip test "name" { ... } blocks entirely.
// The self-hosted compiler does not implement test execution.
// Without this skip, the body `{ ... }` would be parsed as a Map
// literal, building a huge AST with O(n²) string allocation that
// causes OOM on any file containing test blocks.
let p: Int = pos + 1
let k_name: String = tok_kind(tokens, p)
if str_eq(k_name, "Str") { let p = p + 1 }
let k_body: String = tok_kind(tokens, p)
if str_eq(k_body, "LBrace") { let p = skip_to_rbrace(tokens, p) }
let pos = p
if test_is_mode {
// Compile test "name" { ... } block into a static void __el_test_NAME() function.
let p: Int = pos + 1
let test_name: String = "unnamed"
if str_eq(tok_kind(tokens, p), "Str") {
let test_name = tok_value(tokens, p)
let p = p + 1
}
let fn_c_name: String = "__el_test_" + sanitize_test_name(test_name)
let test_names = native_list_append(test_names, test_name)
let test_c_names = native_list_append(test_c_names, fn_c_name)
// Emit the test function header.
emit_line("static void " + fn_c_name + "(void) {")
emit_line(" __el_cur_test = \"" + c_escape(test_name) + "\";")
// Skip the opening LBrace and parse body statements.
if str_eq(tok_kind(tokens, p), "LBrace") { let p = p + 1 }
let body_decl: [String] = native_list_empty()
let body_done: Bool = false
while !body_done {
let bk: String = tok_kind(tokens, p)
if str_eq(bk, "RBrace") {
let body_done = true
} else {
if str_eq(bk, "Eof") {
let body_done = true
} else {
let br = parse_one(tokens, p)
let bstmt = br["node"]
let np: Int = br["pos"]
el_release(br)
if np > p {
let body_arena: Any = el_arena_push()
let body_decl = cg_stmt(bstmt, " ", body_decl)
el_arena_pop(body_arena)
el_release(bstmt)
let p = np
} else {
let p = p + 1
}
}
}
}
// Skip past closing RBrace.
if str_eq(tok_kind(tokens, p), "RBrace") { let p = p + 1 }
el_release(body_decl)
emit_line("}")
emit_blank()
let pos = p
} else {
// Non-test mode: skip test blocks entirely to avoid OOM.
// Without this skip, the body `{ ... }` would be parsed as a Map
// literal, building a huge AST with O(n²) string allocation.
let p: Int = pos + 1
let k_name: String = tok_kind(tokens, p)
if str_eq(k_name, "Str") { let p = p + 1 }
let k_body: String = tok_kind(tokens, p)
if str_eq(k_body, "LBrace") { let p = skip_to_rbrace(tokens, p) }
let pos = p
}
} else {
let r = parse_one(tokens, pos)
let stmt = r["node"]
@@ -3575,6 +3745,37 @@ fn codegen_streaming(tokens: [Any], sigs: [Map<String, Any>], source: String) ->
// Tokens fully consumed by the streaming loop release now to free peak heap.
el_release(tokens)
if test_is_mode {
// Test mode: emit test harness main() that calls each collected test function.
// Discard El's main body and top-level exec stmts (not needed in test harness).
el_release(el_main_body)
el_release(toplevel_exec_stmts)
el_release(toplevel_let_names)
el_release(sigs)
let test_arena_mark: Any = el_arena_push()
emit_line("int main(int _argc, char **_argv) {")
emit_line(" el_runtime_init_args(_argc, _argv);")
let ti: Int = 0
let tn: Int = native_list_len(test_c_names)
while ti < tn {
let tc_name: String = native_list_get(test_c_names, ti)
emit_line(" " + tc_name + "();")
let ti = ti + 1
}
emit_line(" printf(\"%d passed, %d failed\\n\", __el_pass, __el_fail);")
emit_line(" return __el_fail;")
emit_line("}")
el_arena_pop(test_arena_mark)
el_release(test_names)
el_release(test_c_names)
return ""
}
// Release test tracking lists (empty in non-test mode).
el_release(test_names)
el_release(test_c_names)
// Library detection: no fn main and no top-level executable stmts
let is_library: Bool = false
if !has_el_main {
+34 -1
View File
@@ -41,6 +41,20 @@ fn compile(source: String) -> String {
""
}
// compile_test like compile() but sets __test_mode so codegen_streaming
// compiles test { } blocks instead of skipping them, and emits the test
// harness main() instead of the normal int main().
fn compile_test(source: String) -> String {
state_set("__test_mode", "1")
let top_mark: Any = el_arena_push()
let tokens: [Any] = lex(source)
let sigs: [Map<String, Any>] = scan_fn_sigs(tokens)
codegen_streaming(tokens, sigs, source)
el_arena_pop(top_mark)
state_set("__test_mode", "")
""
}
// compile_js full pipeline (JS target, module mode): source string -> JS source string
fn compile_js(source: String) -> String {
let tokens: [Any] = lex(source)
@@ -159,6 +173,18 @@ fn detect_obfuscate(argv: [String]) -> Bool {
return false
}
// Detect --test flag in argv.
fn detect_test(argv: [String]) -> Bool {
let n: Int = native_list_len(argv)
let i = 0
while i < n {
let a: String = native_list_get(argv, i)
if str_eq(a, "--test") { return true }
let i = i + 1
}
return false
}
// Build a unique temp file path: /tmp/elc-<pid>-<timestamp>.<suffix>
fn make_temp_path(suffix: String) -> String {
let pid: Int = getpid_now()
@@ -488,6 +514,7 @@ fn main() -> Void {
let do_bundle: Bool = detect_bundle(argv)
let do_minify: Bool = detect_minify(argv)
let do_obfuscate: Bool = detect_obfuscate(argv)
let do_test: Bool = detect_test(argv)
// --obfuscate implies --minify: obfuscating unminified code is pointless.
if do_obfuscate {
let do_minify = true
@@ -495,7 +522,7 @@ fn main() -> Void {
let positional: [String] = strip_flags(argv)
let argc: Int = native_list_len(positional)
if argc < 1 {
println("el-compiler: usage: elc [--target=c|js] [--bundle] [--minify] [--obfuscate] [--emit-header] <source.el> [<output>]")
println("el-compiler: usage: elc [--target=c|js] [--bundle] [--minify] [--obfuscate] [--emit-header] [--test] <source.el> [<output>]")
exit(1)
}
@@ -532,6 +559,12 @@ fn main() -> Void {
exit(0)
}
// --test mode: compile with test harness (C target only).
if do_test {
compile_test(source)
exit(0)
}
// Standard path (no post-processing).
let out: String = ""
if do_bundle {
+22
View File
@@ -1684,6 +1684,28 @@ fn parse_stmt(tokens: [Any], pos: Int) -> Map<String, Any> {
}, p)
}
// assert <cond_expr> [ , <msg_expr> ]
// The message is optional if the next token after the condition is not a
// Comma, emit an empty string placeholder so the test still works.
if k == "Assert" {
let p: Int = pos + 1
let cond_r = parse_expr(tokens, p)
let cond_node = cond_r["node"]
let p: Int = cond_r["pos"]
el_release(cond_r)
let after_k: String = tok_kind(tokens, p)
if str_eq(after_k, "Comma") {
let p = p + 1
let msg_r = parse_expr(tokens, p)
let msg_node = msg_r["node"]
let p: Int = msg_r["pos"]
el_release(msg_r)
return make_result({ "stmt": "Assert", "cond": cond_node, "msg": msg_node }, p)
}
// No message use empty string placeholder.
return make_result({ "stmt": "Assert", "cond": cond_node, "msg": { "expr": "Str", "value": "" } }, p)
}
// Bare reassignment: `name = expr`. Handled BEFORE the expression
// fallback so we don't drop the assign on the floor and emit three
// orphan expressions (the original silent-miscompile bug). El's `let`