From 5f9cad5908c351e9bbb554f828449ba705ae812a Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Fri, 8 May 2026 08:20:13 -0500 Subject: [PATCH] fix(elc): eliminate OOM in --emit-header by using token-level signature scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The --emit-header path previously called parse() which builds the entire program AST in memory before writing the .elh file. For checkout.el (~491 lines with HTML template trees and deep BinOp string-concat chains), this exhausted memory before the header could be written. Fix: replace parse() + emit_header() with scan_fn_sigs_el() + emit_header_from_sigs(). The new path tokenises the source once, then walks the flat token list skipping over function bodies entirely — peak memory is O(tokens) instead of O(whole-program AST). New functions in parser.el: - scan_type_el: reads a type annotation and returns its El source string - scan_params_el: reads (name: Type, ...) and returns El params string - scan_fn_sigs_el: token-level scan that collects El-style fn signatures without building any expression AST nodes New function in compiler.el: - emit_header_from_sigs: writes .elh from scan_fn_sigs_el output Self-hosting check: elc compiled with new elc, diff of outputs is identical (zero difference). Smoke test: elc --emit-header checkout.el produces correct three-entry .elh (previously truncated at two entries due to mid-parse OOM). --- lang/el-compiler/src/compiler.el | 42 +++++- lang/el-compiler/src/parser.el | 213 +++++++++++++++++++++++++++++++ 2 files changed, 250 insertions(+), 5 deletions(-) diff --git a/lang/el-compiler/src/compiler.el b/lang/el-compiler/src/compiler.el index a4f5fdc..5086b16 100644 --- a/lang/el-compiler/src/compiler.el +++ b/lang/el-compiler/src/compiler.el @@ -287,6 +287,9 @@ fn type_node_to_el(t: Map) -> String { // emit_header — write a .elh file from parsed statements. // Scans for FnDef nodes and emits 'extern fn' declarations. +// NOTE: This function requires the full AST. Prefer emit_header_from_sigs +// for the --emit-header path — it works from a token-level scan without +// building expression ASTs, avoiding OOM on large files. fn emit_header(stmts: [Map], hdr_path: String) -> Void { let n: Int = native_list_len(stmts) let i = 0 @@ -325,6 +328,32 @@ fn emit_header(stmts: [Map], hdr_path: String) -> Void { let ok: Bool = fs_write(hdr_path, content) } +// emit_header_from_sigs — write a .elh file from pre-scanned El signatures. +// Uses the output of scan_fn_sigs_el() — no full AST required. +// Peak memory is O(tokens) rather than O(whole-program AST), which prevents +// OOM on large files with HTML template bodies or deep BinOp chains. +fn emit_header_from_sigs(sigs: [Map], hdr_path: String) -> Void { + let n: Int = native_list_len(sigs) + let i: Int = 0 + let parts: [String] = native_list_empty() + let parts = native_list_append(parts, "// auto-generated by elc --emit-header — do not edit\n") + while i < n { + let sig = native_list_get(sigs, i) + let kind: String = sig["kind"] + if str_eq(kind, "fn") { + let name: String = sig["name"] + let params_el: String = sig["params_el"] + let ret_el: String = sig["ret_el"] + if str_eq(ret_el, "") { let ret_el = "Any" } + let line: String = "extern fn " + name + "(" + params_el + ") -> " + ret_el + let parts = native_list_append(parts, line + "\n") + } + let i = i + 1 + } + let content: String = str_join(parts, "") + let ok: Bool = fs_write(hdr_path, content) +} + // ── Import resolution ──────────────────────────────────────────────────────── // // elc supports two forms of import: @@ -536,16 +565,19 @@ fn main() -> Void { let src_path: String = native_list_get(positional, 0) - // When --emit-header is requested, parse the source file directly - // (without inlining imports) and write out a .elh file alongside the .c. + // When --emit-header is requested, lex the source file and do a + // token-level signature scan (no full AST) to write a .elh file. + // This avoids OOM on large files with HTML template bodies or deep + // BinOp chains (e.g. checkout.el) — parse() builds O(whole-program AST) + // while scan_fn_sigs_el keeps peak memory at O(tokens). if do_emit_header { let raw_source: String = fs_read(src_path) let hdr_tokens: [Any] = lex(raw_source) - let hdr_stmts: [Map] = parse(hdr_tokens) + let hdr_sigs: [Map] = scan_fn_sigs_el(hdr_tokens) el_release(hdr_tokens) let hdr_path: String = str_slice(src_path, 0, str_len(src_path) - 3) + ".elh" - emit_header(hdr_stmts, hdr_path) - el_release(hdr_stmts) + emit_header_from_sigs(hdr_sigs, hdr_path) + el_release(hdr_sigs) } let source: String = resolve_imports(src_path) diff --git a/lang/el-compiler/src/parser.el b/lang/el-compiler/src/parser.el index 1e24675..e4de3b9 100644 --- a/lang/el-compiler/src/parser.el +++ b/lang/el-compiler/src/parser.el @@ -2065,6 +2065,219 @@ fn skip_expr_to_stmt_boundary(tokens: [Any], pos: Int) -> Int { p } +// scan_type_el — read a type annotation starting at pos and return its El +// source representation as a string, plus the new position. +// Returns { "el": String, "pos": Int }. +// Handles: Ident, [Type], Map, Type?, Type (same shapes as skip_type). +fn scan_type_el(tokens: [Any], pos: Int) -> Map { + let k: String = tok_kind(tokens, pos) + // Array type: [Type] + if str_eq(k, "LBracket") { + let p: Int = pos + 1 + let inner = scan_type_el(tokens, p) + let inner_str: String = inner["el"] + let p = inner["pos"] + el_release(inner) + let p = expect(tokens, p, "RBracket") + return { "el": "[" + inner_str + "]", "pos": p } + } + // Named type (possibly generic or optional) + if str_eq(k, "Ident") { + let name: String = tok_value(tokens, pos) + let p: Int = pos + 1 + let k2: String = tok_kind(tokens, p) + if str_eq(k2, "Lt") { + // Generic params: collect until matching > + let p = p + 1 + let depth: Int = 1 + let parts: [String] = native_list_empty() + let parts = native_list_append(parts, name + "<") + let running: Bool = true + while running { + let kk: String = tok_kind(tokens, p) + if str_eq(kk, "Eof") { + let running = false + } else { + if str_eq(kk, "Lt") { + let depth = depth + 1 + let parts = native_list_append(parts, "<") + let p = p + 1 + } else { + if str_eq(kk, "Gt") { + let depth = depth - 1 + let p = p + 1 + if depth <= 0 { + let parts = native_list_append(parts, ">") + let running = false + } else { + let parts = native_list_append(parts, ">") + } + } else { + if str_eq(kk, "Comma") { + let parts = native_list_append(parts, ", ") + let p = p + 1 + } else { + let parts = native_list_append(parts, tok_value(tokens, p)) + let p = p + 1 + } + } + } + } + } + let k3: String = tok_kind(tokens, p) + if str_eq(k3, "QuestionMark") { let p = p + 1 } + let result: String = str_join(parts, "") + el_release(parts) + return { "el": result, "pos": p } + } + // Optional marker + if str_eq(k2, "QuestionMark") { + return { "el": name + "?", "pos": p + 1 } + } + return { "el": name, "pos": p } + } + // Fallback: unknown token, treat as Any + { "el": "Any", "pos": pos + 1 } +} + +// scan_params_el — scan a parameter list `(name: Type, ...)` starting at +// position `pos` (which should point at LParen) and return the El parameter +// declaration string (e.g. "a: String, b: Int") along with the new position. +// Returns { "el": String, "pos": Int }. +// Used by scan_fn_sigs_el for --emit-header without building full AST. +fn scan_params_el(tokens: [Any], pos: Int) -> Map { + let p: Int = expect(tokens, pos, "LParen") + let parts: [String] = native_list_empty() + let going: Bool = true + while going { + let kk: String = tok_kind(tokens, p) + if str_eq(kk, "RParen") { + let going = false + } else { + if str_eq(kk, "Eof") { + let going = false + } else { + let pname: String = tok_value(tokens, p) + let p = p + 1 + let p = expect(tokens, p, "Colon") + let tr = scan_type_el(tokens, p) + let ptype: String = tr["el"] + let p = tr["pos"] + el_release(tr) + let parts = native_list_append(parts, pname + ": " + ptype) + let k2: String = tok_kind(tokens, p) + if str_eq(k2, "Comma") { + let p = p + 1 + } + } + } + } + let p = expect(tokens, p, "RParen") + let el_str: String = str_join(parts, ", ") + el_release(parts) + { "el": el_str, "pos": p } +} + +// scan_fn_sigs_el — lightweight token-level pre-scan for --emit-header. +// +// Like scan_fn_sigs but captures El-style type strings instead of C types. +// Only records fn/extern_fn entries (header generation ignores lets/blocks). +// +// Descriptor shape: +// { "kind": "fn"|"extern_fn", "name": String, +// "params_el": String, <- El param list, e.g. "a: String, b: Int" +// "ret_el": String } <- El return type, e.g. "String" or "Void" +// +// Peak memory: O(tokens) with no expression AST allocation. +fn scan_fn_sigs_el(tokens: [Any]) -> [Map] { + let total: Int = native_list_len(tokens) / 2 + let sigs: [Map] = native_list_empty() + let pos: Int = 0 + let going: Bool = true + while going { + if pos >= total { + let going = false + } else { + let k: String = tok_kind(tokens, pos) + if str_eq(k, "Eof") { + let going = false + } else { + // --- fn definition --- + if str_eq(k, "Fn") { + let p: Int = pos + 1 + let name: String = tok_value(tokens, p) + let p = p + 1 + let pr = scan_params_el(tokens, p) + let params_el: String = pr["el"] + let p = pr["pos"] + el_release(pr) + // read return type + let ret_el: String = "Any" + let k2: String = tok_kind(tokens, p) + if str_eq(k2, "Arrow") { + let p = p + 1 + let tr = scan_type_el(tokens, p) + let ret_el = tr["el"] + let p = tr["pos"] + el_release(tr) + } + // skip body + let k3: String = tok_kind(tokens, p) + if str_eq(k3, "LBrace") { + let p = skip_to_rbrace(tokens, p) + } + if !str_eq(name, "main") { + let sigs = native_list_append(sigs, { + "kind": "fn", + "name": name, + "params_el": params_el, + "ret_el": ret_el + }) + } + let pos = p + } else { + // --- extern fn --- + if str_eq(k, "Extern") { + let p: Int = pos + 1 + let k2: String = tok_kind(tokens, p) + if str_eq(k2, "Fn") { + let p = p + 1 + let name: String = tok_value(tokens, p) + let p = p + 1 + let pr = scan_params_el(tokens, p) + let params_el: String = pr["el"] + let p = pr["pos"] + el_release(pr) + let ret_el: String = "Any" + let k3: String = tok_kind(tokens, p) + if str_eq(k3, "Arrow") { + let p = p + 1 + let tr = scan_type_el(tokens, p) + let ret_el = tr["el"] + let p = tr["pos"] + el_release(tr) + } + let sigs = native_list_append(sigs, { + "kind": "extern_fn", + "name": name, + "params_el": params_el, + "ret_el": ret_el + }) + let pos = p + } else { + let pos = pos + 1 + } + } else { + // Let, Cgi, Service, Import, Type, Enum, From — skip to boundary. + let p: Int = pos + 1 + let p = skip_expr_to_stmt_boundary(tokens, p) + let pos = p + }}} + } + } + sigs +} + // scan_params_c — scan a parameter list `(name: Type, ...)` starting at // position `pos` (which should point at LParen) and return the C parameter // declaration string along with the new position.