diff --git a/dist/platform/elc b/dist/platform/elc index 417485d..9b68a40 100755 Binary files a/dist/platform/elc and b/dist/platform/elc differ diff --git a/dist/platform/elc.20260502-1249-self-host b/dist/platform/elc.20260502-1249-self-host new file mode 100755 index 0000000..9b68a40 Binary files /dev/null and b/dist/platform/elc.20260502-1249-self-host differ diff --git a/el-compiler/src/codegen.el b/el-compiler/src/codegen.el index c69f614..5d1d9dc 100644 --- a/el-compiler/src/codegen.el +++ b/el-compiler/src/codegen.el @@ -99,6 +99,29 @@ fn binop_to_c(op: String) -> String { // // cg_expr returns a C expression string (not a statement). +// duration_unit_nanos — multiplier from a postfix-literal unit name to +// nanoseconds. Singular and plural forms collapse to the same multiplier; +// the parser already restricted `unit` to the set is_duration_unit accepts. +// Returns the multiplier as a decimal string suitable for splicing into +// the generated C as a literal int64 expression. +fn duration_unit_nanos(unit: String) -> String { + if str_eq(unit, "nano") { return "1LL" } + if str_eq(unit, "nanos") { return "1LL" } + if str_eq(unit, "milli") { return "1000000LL" } + if str_eq(unit, "millis") { return "1000000LL" } + if str_eq(unit, "millisecond") { return "1000000LL" } + if str_eq(unit, "milliseconds") { return "1000000LL" } + if str_eq(unit, "second") { return "1000000000LL" } + if str_eq(unit, "seconds") { return "1000000000LL" } + if str_eq(unit, "minute") { return "60000000000LL" } + if str_eq(unit, "minutes") { return "60000000000LL" } + if str_eq(unit, "hour") { return "3600000000000LL" } + if str_eq(unit, "hours") { return "3600000000000LL" } + if str_eq(unit, "day") { return "86400000000000LL" } + if str_eq(unit, "days") { return "86400000000000LL" } + "1LL" +} + fn cg_expr(expr: Map) -> String { let kind: String = expr["expr"] @@ -107,6 +130,17 @@ fn cg_expr(expr: Map) -> String { return v } + // DurationLit — postfix-literal time value (e.g. 30.seconds, 1.hour). + // Lowered to a literal int64 nanosecond count, wrapped in the runtime + // entry point so the intent is explicit at the C level. The arithmetic + // is fully constant-folded by any optimising C compiler. + if kind == "DurationLit" { + let count: String = expr["count"] + let unit: String = expr["unit"] + let mult: String = duration_unit_nanos(unit) + return "el_duration_from_nanos((el_val_t)(" + count + "LL * " + mult + "))" + } + if kind == "Float" { // Wrap Float literals in el_from_float() so the bit pattern is // preserved through the el_val_t (int64) slot. Without this, @@ -157,6 +191,153 @@ fn cg_expr(expr: Map) -> String { let left_kind: String = left["expr"] let right_kind: String = right["expr"] + // ── Temporal-type dispatch (Instant + Duration first-class) ──────── + // Run BEFORE the int / string / generic paths so typed temporal + // operands route through the runtime wrappers and invalid combos + // become #error directives rather than silently falling through to + // raw int arithmetic. The wrappers are no-op casts at the C level + // but make the intent explicit and centralise future changes (e.g. + // saturating arithmetic, overflow guards). + let left_is_inst: Bool = is_instant_expr(left) + let right_is_inst: Bool = is_instant_expr(right) + let left_is_dur: Bool = is_duration_expr(left) + let right_is_dur: Bool = is_duration_expr(right) + let any_temporal: Bool = false + if left_is_inst { let any_temporal = true } + if right_is_inst { let any_temporal = true } + if left_is_dur { let any_temporal = true } + if right_is_dur { let any_temporal = true } + if any_temporal { + if op == "Plus" { + if left_is_inst { + if right_is_dur { + return "el_instant_add_dur(" + left_c + ", " + right_c + ")" + } + if right_is_inst { + time_record_violation("instant_plus_instant", "Instant + Instant is not allowed") + return "0 /* TIME_TYPE_ERROR: Instant + Instant */" + } + } + if left_is_dur { + if right_is_inst { + return "el_instant_add_dur(" + right_c + ", " + left_c + ")" + } + if right_is_dur { + return "el_duration_add(" + left_c + ", " + right_c + ")" + } + if is_int_expr(right) { + time_record_violation("duration_plus_int", "Duration + Int is not allowed (use duration_seconds(n) or N.seconds)") + return "0 /* TIME_TYPE_ERROR: Duration + Int */" + } + } + if right_is_dur { + if is_int_expr(left) { + time_record_violation("duration_plus_int", "Int + Duration is not allowed") + return "0 /* TIME_TYPE_ERROR: Int + Duration */" + } + } + } + if op == "Minus" { + if left_is_inst { + if right_is_dur { + return "el_instant_sub_dur(" + left_c + ", " + right_c + ")" + } + if right_is_inst { + return "el_instant_diff(" + left_c + ", " + right_c + ")" + } + } + if left_is_dur { + if right_is_dur { + return "el_duration_sub(" + left_c + ", " + right_c + ")" + } + if is_int_expr(right) { + time_record_violation("duration_minus_int", "Duration - Int is not allowed") + return "0 /* TIME_TYPE_ERROR: Duration - Int */" + } + } + } + if op == "Star" { + if left_is_dur { + if is_int_expr(right) { + return "el_duration_scale(" + left_c + ", " + right_c + ")" + } + } + if right_is_dur { + if is_int_expr(left) { + return "el_duration_scale(" + right_c + ", " + left_c + ")" + } + } + } + if op == "Slash" { + if left_is_dur { + if is_int_expr(right) { + return "el_duration_div(" + left_c + ", " + right_c + ")" + } + } + } + // Comparisons. Cross-type comparisons are forbidden. + if op == "Lt" { + if left_is_inst { + if right_is_inst { return "el_instant_lt(" + left_c + ", " + right_c + ")" } + if right_is_dur { + time_record_violation("instant_cmp_duration", "Instant < Duration is not allowed") + return "0 /* TIME_TYPE_ERROR: Instant < Duration */" + } + } + if left_is_dur { + if right_is_dur { return "el_duration_lt(" + left_c + ", " + right_c + ")" } + if right_is_inst { + time_record_violation("duration_cmp_instant", "Duration < Instant is not allowed") + return "0 /* TIME_TYPE_ERROR: Duration < Instant */" + } + } + } + if op == "LtEq" { + if left_is_inst { + if right_is_inst { return "el_instant_le(" + left_c + ", " + right_c + ")" } + } + if left_is_dur { + if right_is_dur { return "el_duration_le(" + left_c + ", " + right_c + ")" } + } + } + if op == "Gt" { + if left_is_inst { + if right_is_inst { return "el_instant_gt(" + left_c + ", " + right_c + ")" } + } + if left_is_dur { + if right_is_dur { return "el_duration_gt(" + left_c + ", " + right_c + ")" } + } + } + if op == "GtEq" { + if left_is_inst { + if right_is_inst { return "el_instant_ge(" + left_c + ", " + right_c + ")" } + } + if left_is_dur { + if right_is_dur { return "el_duration_ge(" + left_c + ", " + right_c + ")" } + } + } + if op == "EqEq" { + if left_is_inst { + if right_is_inst { return "el_instant_eq(" + left_c + ", " + right_c + ")" } + } + if left_is_dur { + if right_is_dur { return "el_duration_eq(" + left_c + ", " + right_c + ")" } + } + } + if op == "NotEq" { + if left_is_inst { + if right_is_inst { return "el_instant_ne(" + left_c + ", " + right_c + ")" } + } + if left_is_dur { + if right_is_dur { return "el_duration_ne(" + left_c + ", " + right_c + ")" } + } + } + // Fall through — let the existing path handle anything we + // didn't explicitly cover (typically string-concat with a + // typed temporal value, e.g. for debug prints, which works + // because both share the int64 slot). + } + if op == "Plus" { // If either side is a string literal, always concat if left_kind == "Str" { @@ -365,6 +546,21 @@ fn cg_expr(expr: Map) -> String { // handler)`). User-defined fns and variadic builtins pass // through (builtin_arity returns -1). arity_check_call(fn_name, arity) + // sleep(Duration) — Phase 1 of the typed-time work. When the + // single arg is provably a Duration we lower to el_sleep_duration + // so the runtime sees nanos directly. Existing sleep() callers + // that pass an Int still emit `sleep()`, which falls through + // to the no-such-symbol path — those call sites must migrate to + // a typed Duration. Acceptable: the spec marks them out for an + // audit pass during Phase 1. + if str_eq(fn_name, "sleep") { + if arity == 1 { + let only_arg = native_list_get(args, 0) + if is_duration_expr(only_arg) { + return "el_sleep_duration(" + args_c + ")" + } + } + } return fn_name + "(" + args_c + ")" } @@ -672,6 +868,23 @@ fn cg_stmt(stmt: Map, indent: String, declared: [String]) -> [Strin if str_eq(ltype, "Int") { add_int_name(name) } + // Temporal type annotations register the name with the matching + // typed-set so BinOp / comparison codegen routes through the + // typed wrappers and forbids cross-type ops. + if str_eq(ltype, "Instant") { + add_instant_name(name) + } + if str_eq(ltype, "Duration") { + add_duration_name(name) + } + // Inference from RHS — duration literals and known-typed calls + // propagate even when the let is unannotated. + if is_instant_expr(val) { + add_instant_name(name) + } + if is_duration_expr(val) { + add_duration_name(name) + } let vk: String = val["expr"] if str_eq(vk, "Int") { add_int_name(name) @@ -928,6 +1141,23 @@ fn is_int_name(name: String) -> Bool { return str_contains(csv, "," + name + ",") } +// Same shape as is_int_name, for Instant- and Duration-typed bindings. +// Used by the BinOp/comparison codegen to dispatch arithmetic through the +// typed runtime wrappers (el_instant_add_dur, el_duration_lt, …) and to +// surface mismatches (Instant + Instant, Duration + Int) as #error +// directives at the top of the generated C. +fn is_instant_name(name: String) -> Bool { + let csv: String = state_get("__instant_names") + if str_eq(csv, "") { return false } + return str_contains(csv, "," + name + ",") +} + +fn is_duration_name(name: String) -> Bool { + let csv: String = state_get("__duration_names") + if str_eq(csv, "") { return false } + return str_contains(csv, "," + name + ",") +} + // Known runtime builtins that return Int. Used to dispatch arithmetic vs // string-concat on `+` when one side is a Call. New builtins must be added // here when they return Int and may participate in arithmetic. @@ -956,9 +1186,161 @@ fn is_int_call(call_expr: Map) -> Bool { if str_eq(name, "el_max") { return true } if str_eq(name, "el_min") { return true } if str_eq(name, "float_to_int") { return true } + if str_eq(name, "unix_timestamp") { return true } + if str_eq(name, "instant_to_unix_seconds") { return true } + if str_eq(name, "instant_to_unix_millis") { return true } + if str_eq(name, "duration_to_seconds") { return true } + if str_eq(name, "duration_to_millis") { return true } + if str_eq(name, "duration_to_nanos") { return true } return false } +// Builtins that return an Instant. Used by is_instant_expr and the BinOp +// dispatch — `now() + 5.seconds` types as Instant only because we can see +// that now() is an Instant-returning Call. +fn is_instant_call(call_expr: Map) -> Bool { + let func = call_expr["func"] + let fk: String = func["expr"] + if !str_eq(fk, "Ident") { return false } + let name: String = func["name"] + if str_eq(name, "now") { return true } + if str_eq(name, "el_now_instant") { return true } + if str_eq(name, "unix_seconds") { return true } + if str_eq(name, "unix_millis") { return true } + if str_eq(name, "instant_from_iso8601") { return true } + if str_eq(name, "el_instant_add_dur") { return true } + if str_eq(name, "el_instant_sub_dur") { return true } + return false +} + +// Builtins that return a Duration. Same role as is_instant_call. +fn is_duration_call(call_expr: Map) -> Bool { + let func = call_expr["func"] + let fk: String = func["expr"] + if !str_eq(fk, "Ident") { return false } + let name: String = func["name"] + if str_eq(name, "el_duration_from_nanos") { return true } + if str_eq(name, "duration_seconds") { return true } + if str_eq(name, "duration_millis") { return true } + if str_eq(name, "duration_nanos") { return true } + if str_eq(name, "el_instant_diff") { return true } + if str_eq(name, "el_duration_add") { return true } + if str_eq(name, "el_duration_sub") { return true } + if str_eq(name, "el_duration_scale") { return true } + if str_eq(name, "el_duration_div") { return true } + if str_eq(name, "ttl_cache_age") { return true } + return false +} + +// Recursive type predicates for Instant / Duration. Mirror is_int_expr. +// is_instant_expr / is_duration_expr return true only when the expression +// is provably of that type at codegen time. Anything ambiguous returns +// false — the BinOp dispatcher then leaves the expression on the +// untyped-int path, which is the safest fallback because at the runtime +// level all three types share the int64 slot. +fn is_instant_expr(expr: Map) -> Bool { + let k: String = expr["expr"] + if str_eq(k, "Ident") { + let name: String = expr["name"] + return is_instant_name(name) + } + if str_eq(k, "Call") { + return is_instant_call(expr) + } + if str_eq(k, "BinOp") { + let op: String = expr["op"] + if str_eq(op, "Plus") { + // Instant + Duration → Instant + // Duration + Instant → Instant + if is_instant_expr(expr["left"]) { + if is_duration_expr(expr["right"]) { return true } + } + if is_duration_expr(expr["left"]) { + if is_instant_expr(expr["right"]) { return true } + } + return false + } + if str_eq(op, "Minus") { + // Instant - Duration → Instant + if is_instant_expr(expr["left"]) { + if is_duration_expr(expr["right"]) { return true } + } + return false + } + return false + } + return false +} + +fn is_duration_expr(expr: Map) -> Bool { + let k: String = expr["expr"] + if str_eq(k, "DurationLit") { return true } + if str_eq(k, "Ident") { + let name: String = expr["name"] + return is_duration_name(name) + } + if str_eq(k, "Call") { + return is_duration_call(expr) + } + if str_eq(k, "Neg") { + return is_duration_expr(expr["inner"]) + } + if str_eq(k, "BinOp") { + let op: String = expr["op"] + if str_eq(op, "Plus") { + // Duration + Duration → Duration + if is_duration_expr(expr["left"]) { + if is_duration_expr(expr["right"]) { return true } + } + return false + } + if str_eq(op, "Minus") { + // Duration - Duration → Duration + // Instant - Instant → Duration (caught here, not in is_instant_expr) + if is_duration_expr(expr["left"]) { + if is_duration_expr(expr["right"]) { return true } + } + if is_instant_expr(expr["left"]) { + if is_instant_expr(expr["right"]) { return true } + } + return false + } + if str_eq(op, "Star") { + // Duration * Int → Duration + // Int * Duration → Duration + if is_duration_expr(expr["left"]) { + if is_int_expr(expr["right"]) { return true } + } + if is_int_expr(expr["left"]) { + if is_duration_expr(expr["right"]) { return true } + } + return false + } + if str_eq(op, "Slash") { + // Duration / Int → Duration + if is_duration_expr(expr["left"]) { + if is_int_expr(expr["right"]) { return true } + } + return false + } + return false + } + return false +} + +// Record a temporal-type violation. Surfaced as `#error` directives at the +// top of the generated C, identical machinery to cap_record_violation. +// kinds: "instant_plus_instant", "duration_plus_int", etc. +fn time_record_violation(kind: String, detail: String) -> Bool { + let csv: String = state_get("__time_violations") + if str_eq(csv, "") { let csv = "," } + let entry: String = kind + ":" + detail + let key: String = "," + entry + "," + if str_contains(csv, key) { return true } + state_set("__time_violations", csv + entry + ",") + return true +} + // Recursive type-propagation: is `expr` known-Int at codegen time? // This unifies the BinOp(+) dispatch so chained arithmetic over Int // operands stays arithmetic. Without recursion, a wrapping `+` between @@ -1138,6 +1520,30 @@ fn emit_cap_violations() -> Void { } } +// Surface temporal-type violations as #error directives. The cg_expr BinOp +// dispatcher records each violation (Instant + Instant, Duration + Int, …) +// as a CSV entry "kind:detail" via time_record_violation. Each entry maps +// to a single #error so downstream cc fails the build with a clear El- +// source-level message before the bogus C even links. +fn emit_time_violations() -> Void { + let csv: String = state_get("__time_violations") + if str_eq(csv, "") { return } + if str_eq(csv, ",") { return } + let n: Int = str_len(csv) + let i: Int = 1 + while i < n { + let next_comma: Int = str_index_of(str_slice(csv, i, n), ",") + if next_comma < 0 { return } + let entry: String = str_slice(csv, i, i + next_comma) + let colon: Int = str_index_of(entry, ":") + if colon > 0 { + let detail: String = str_slice(entry, colon + 1, str_len(entry)) + emit_line("#error \"temporal type error: " + detail + "\"") + } + let i = i + next_comma + 1 + } +} + // ── Builtin arity table ─────────────────────────────────────────────────────── // // El programs sometimes call runtime builtins with the wrong number of @@ -1180,6 +1586,8 @@ fn builtin_arity(name: String) -> Int { if str_eq(name, "str_format") { return 2 } if str_eq(name, "str_lower") { return 1 } if str_eq(name, "str_upper") { return 1 } + // HTML sanitizer + if str_eq(name, "el_html_sanitize") { return 2 } // Math if str_eq(name, "el_abs") { return 1 } if str_eq(name, "el_max") { return 2 } @@ -1384,8 +1792,28 @@ fn add_int_name(name: String) -> Bool { return true } +fn add_instant_name(name: String) -> Bool { + let csv: String = state_get("__instant_names") + if str_eq(csv, "") { csv = "," } + let key: String = "," + name + "," + if str_contains(csv, key) { return true } + state_set("__instant_names", csv + name + ",") + return true +} + +fn add_duration_name(name: String) -> Bool { + let csv: String = state_get("__duration_names") + if str_eq(csv, "") { csv = "," } + let key: String = "," + name + "," + if str_contains(csv, key) { return true } + state_set("__duration_names", csv + name + ",") + return true +} + fn build_int_names_for_params(params: [Map]) -> Bool { state_set("__int_names", ",") + state_set("__instant_names", ",") + state_set("__duration_names", ",") let np: Int = native_list_len(params) let pi = 0 while pi < np { @@ -1395,6 +1823,12 @@ fn build_int_names_for_params(params: [Map]) -> Bool { if str_eq(ptype, "Int") { add_int_name(pname) } + if str_eq(ptype, "Instant") { + add_instant_name(pname) + } + if str_eq(ptype, "Duration") { + add_duration_name(pname) + } let pi = pi + 1 } return true @@ -1661,6 +2095,8 @@ fn codegen(stmts: [Map], source: String) -> String { state_set("__cap_violations", "") // Clear arity-violation accumulator from any prior compile. state_set("__arity_violations", "") + // Clear temporal-type-violation accumulator from any prior compile. + state_set("__time_violations", "") // Preamble emit_line("#include ") @@ -1831,6 +2267,8 @@ fn codegen(stmts: [Map], source: String) -> String { // so a misuse of a known builtin (wrong arg count) fails the build // with a clear message naming the builtin and its expected arity. emit_arity_violations() + // Temporal-type violations (Instant + Instant, Duration + Int, …). + emit_time_violations() // Return empty string — output was streamed via println "" diff --git a/el-compiler/src/parser.el b/el-compiler/src/parser.el index fe93552..866fb86 100644 --- a/el-compiler/src/parser.el +++ b/el-compiler/src/parser.el @@ -474,10 +474,55 @@ fn parse_block(tokens: [Map], pos: Int) -> Map { // ── Postfix expressions (calls, field access, index) ───────────────────────── +// is_duration_unit — recognise the postfix unit suffix on a numeric literal. +// Used by parse_postfix to detect `30.seconds`-shape time literals before +// falling back to the generic `obj.field` field-access lowering. Singular +// and plural forms map to the same nanosecond multiplier; codegen does the +// arithmetic at compile time. +fn is_duration_unit(name: String) -> Bool { + if name == "nanos" { return true } + if name == "nano" { return true } + if name == "millis" { return true } + if name == "milli" { return true } + if name == "millisecond" { return true } + if name == "milliseconds" { return true } + if name == "second" { return true } + if name == "seconds" { return true } + if name == "minute" { return true } + if name == "minutes" { return true } + if name == "hour" { return true } + if name == "hours" { return true } + if name == "day" { return true } + if name == "days" { return true } + false +} + fn parse_postfix(tokens: [Map], pos: Int) -> Map { let r = parse_primary(tokens, pos) let node = r["node"] let p = r["pos"] + + // Postfix duration literal: `.` where is one of + // nanos | millis | seconds | minutes | hours | days (each with an + // optional plural). We recognise this before the generic Dot-as-field + // path so `30.seconds` lowers to a DurationLit AST node carrying the + // count and the unit, not a field access on an Int. + let primary_kind: String = node["expr"] + if primary_kind == "Int" { + let dot_kind = tok_kind(tokens, p) + if dot_kind == "Dot" { + let unit_kind = tok_kind(tokens, p + 1) + if unit_kind == "Ident" { + let unit_name = tok_value(tokens, p + 1) + if is_duration_unit(unit_name) { + let count_str: String = node["value"] + let node = { "expr": "DurationLit", "count": count_str, "unit": unit_name } + let p = p + 2 + } + } + } + } + let running = true while running { let k = tok_kind(tokens, p) diff --git a/tests/html_sanitizer/cases.json b/tests/html_sanitizer/cases.json new file mode 100644 index 0000000..3791c03 --- /dev/null +++ b/tests/html_sanitizer/cases.json @@ -0,0 +1,150 @@ +{ + "allowlist": "{\"p\":[],\"br\":[],\"strong\":[],\"em\":[],\"u\":[],\"s\":[],\"code\":[],\"pre\":[],\"ul\":[],\"ol\":[],\"li\":[],\"h1\":[],\"h2\":[],\"h3\":[],\"h4\":[],\"blockquote\":[],\"a\":[\"href\",\"title\"]}", + "cases": [ + { + "name": "01-pass-through-list", + "input": "
  1. x
", + "expected": "
  1. x
" + }, + { + "name": "02-paragraphs-with-bold", + "input": "

hello world

second

", + "expected": "

hello world

second

" + }, + { + "name": "03-pre-code-block", + "input": "
npm install
", + "expected": "
npm install
" + }, + { + "name": "04-allowed-https-link", + "input": "click", + "expected": "click" + }, + { + "name": "05-allowed-anchor-link", + "input": "jump", + "expected": "jump" + }, + { + "name": "06-javascript-scheme-blocked", + "input": "click", + "expected": "click" + }, + { + "name": "07-about-scheme-blocked", + "input": "click", + "expected": "click" + }, + { + "name": "08-data-uri-blocked", + "input": "alert(1)\">x", + "expected": "x" + }, + { + "name": "09-script-content-dropped", + "input": "beforeafter", + "expected": "beforeafter" + }, + { + "name": "10-iframe-content-dropped", + "input": "safe", + "expected": "safe" + }, + { + "name": "11-form-content-dropped", + "input": "
x
safe", + "expected": "safe" + }, + { + "name": "12-img-with-onerror-dropped", + "input": "", + "expected": "" + }, + { + "name": "13-comment-injection-bypass-blocked", + "input": "
junk\">x
safe", + "expected": "safe" + }, + { + "name": "14-mixed-legit-and-attack", + "input": "

hello

world

", + "expected": "

hello

world

" + }, + { + "name": "15-pre-encoded-entities-preserved", + "input": "<script>alert(1)</script>", + "expected": "<script>alert(1)</script>" + }, + { + "name": "16-unicode-in-href-preserved", + "input": "x", + "expected": "x" + }, + { + "name": "17-unclosed-tag-passes-through", + "input": "

unclosed", + "expected": "

unclosed" + }, + { + "name": "18-onclick-attribute-stripped-tag-survives", + "input": "

hi

", + "expected": "

hi

" + }, + { + "name": "19-tab-bypass-in-scheme-blocked", + "input": "x", + "expected": "x" + }, + { + "name": "20-uppercase-tag-and-attr-normalised", + "input": "x", + "expected": "x" + }, + { + "name": "21-style-content-dropped", + "input": "visible", + "expected": "visible" + }, + { + "name": "22-object-content-dropped", + "input": "flashsafe", + "expected": "safe" + }, + { + "name": "23-svg-onload-dropped", + "input": "safe", + "expected": "safe" + }, + { + "name": "24-blockquote-passthrough", + "input": "
quoted text
", + "expected": "
quoted text
" + }, + { + "name": "25-headings-passthrough", + "input": "

title

section

", + "expected": "

title

section

" + }, + { + "name": "26-attribute-value-with-gt-byte", + "input": "2\" title=\"a > b\">x", + "expected": "x" + }, + { + "name": "27-nested-script-after-text", + "input": "lead

para

tail", + "expected": "lead

para

tail" + }, + { + "name": "28-empty-input", + "input": "", + "expected": "" + }, + { + "name": "29-plain-text", + "input": "just text, no tags", + "expected": "just text, no tags" + } + ] +} diff --git a/tests/html_sanitizer/run.sh b/tests/html_sanitizer/run.sh new file mode 100755 index 0000000..658d5fa --- /dev/null +++ b/tests/html_sanitizer/run.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# run.sh — build and execute the html_sanitizer acceptance corpus. +# +# Compiles tests/html_sanitizer/runner.el via the canonical native elc, +# links against the shared C runtime, then runs the binary against +# cases.json. Exits non-zero on any FAIL. + +set -euo pipefail +cd "$(dirname "$0")" + +EL_HOME="${EL_HOME:-$(cd ../.. && pwd)}" +ELC="${EL_HOME}/dist/platform/elc" +RUNTIME_DIR="${EL_HOME}/el-compiler/runtime" + +if [ ! -x "${ELC}" ]; then + echo "elc not found at ${ELC}" >&2 + exit 1 +fi + +OUT_C="$(mktemp -t html_sanitizer.XXXXXX).c" +OUT_BIN="$(mktemp -t html_sanitizer.XXXXXX)" +trap 'rm -f "${OUT_C}" "${OUT_BIN}"' EXIT + +echo "==> Compiling runner.el via ${ELC}" +"${ELC}" runner.el > "${OUT_C}" + +echo "==> Linking against ${RUNTIME_DIR}/el_runtime.c" +cc -O2 -I "${RUNTIME_DIR}" "${OUT_C}" "${RUNTIME_DIR}/el_runtime.c" \ + -lcurl -lpthread -o "${OUT_BIN}" + +echo "==> Running" +"${OUT_BIN}" diff --git a/tests/html_sanitizer/runner.el b/tests/html_sanitizer/runner.el new file mode 100644 index 0000000..2a662c0 --- /dev/null +++ b/tests/html_sanitizer/runner.el @@ -0,0 +1,59 @@ +// runner.el — acceptance tests for el_html_sanitize. +// +// Reads cases.json from the cwd, runs every case through +// el_html_sanitize, prints PASS/FAIL per case and a summary, +// and exits non-zero if any case failed. +// +// cases.json shape: +// { +// "allowlist": "", +// "cases": [ +// { "name": "...", "input": "...", "expected": "..." }, +// ... +// ] +// } + +fn run_case(name: String, input: String, expected: String, allowlist: String, idx: Int) -> Int { + let got: String = el_html_sanitize(input, allowlist) + if str_eq(got, expected) { + println("PASS " + name) + return 1 + } else { + println("FAIL " + name) + println(" input: " + input) + println(" expected: " + expected) + println(" got: " + got) + return 0 + } +} + +fn main() -> Int { + let raw: String = fs_read("cases.json") + if str_eq(raw, "") { + println("FATAL could not read cases.json from cwd") + return 2 + } + let allowlist: String = json_get(raw, "allowlist") + let cases_raw: String = json_get_raw(raw, "cases") + let n: Int = json_array_len(cases_raw) + let i: Int = 0 + let pass: Int = 0 + let fail: Int = 0 + while i < n { + let case_obj: String = json_array_get(cases_raw, i) + let cname: String = json_get(case_obj, "name") + let cinp: String = json_get(case_obj, "input") + let cexp: String = json_get(case_obj, "expected") + let r: Int = run_case(cname, cinp, cexp, allowlist, i) + if r == 1 { + let pass = pass + 1 + } else { + let fail = fail + 1 + } + let i = i + 1 + } + println("") + println(int_to_str(pass) + " passed, " + int_to_str(fail) + " failed") + if fail > 0 { return 1 } + return 0 +}