From 252ad04c9639fc1bdf2c0384538c865b03232ae5 Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Sun, 3 May 2026 15:43:20 -0500 Subject: [PATCH 1/3] add break and continue statements to El --- el-compiler/src/codegen.el | 15 ++++++++++-- el-compiler/src/lexer.el | 30 ++++++++++++----------- el-compiler/src/parser.el | 50 +++++++++++++++++++++++--------------- 3 files changed, 60 insertions(+), 35 deletions(-) diff --git a/el-compiler/src/codegen.el b/el-compiler/src/codegen.el index e1971c4..c2aec70 100644 --- a/el-compiler/src/codegen.el +++ b/el-compiler/src/codegen.el @@ -135,8 +135,9 @@ fn emit_blank() -> Void { fn binop_to_c(op: String) -> String { if op == "Plus" { return "+" } if op == "Minus" { return "-" } - if op == "Star" { return "*" } - if op == "Slash" { return "/" } + if op == "Star" { return "*" } + if op == "Slash" { return "/" } + if op == "Percent" { return "%" } if op == "EqEq" { return "==" } if op == "NotEq" { return "!=" } if op == "Lt" { return "<" } @@ -1078,6 +1079,16 @@ fn cg_stmt(stmt: Map, indent: String, declared: [String]) -> [Strin return declared } + if kind == "Break" { + emit_line(indent + "break;") + return declared + } + + if kind == "Continue" { + emit_line(indent + "continue;") + return declared + } + // Bare reassignment: `name = expr`. Always emits a plain C assignment // (no `el_val_t` prefix) - by construction the parser only produces // Assign for an existing identifier. If the name happens NOT to be in diff --git a/el-compiler/src/lexer.el b/el-compiler/src/lexer.el index 6649e91..cef3b33 100644 --- a/el-compiler/src/lexer.el +++ b/el-compiler/src/lexer.el @@ -1,4 +1,4 @@ -// lexer.el — el self-hosting lexer +// lexer.el - el self-hosting lexer // // Tokenises an el source string into a list of token maps. // Each token is a Map with keys: @@ -8,9 +8,9 @@ // Entry point: fn lex(source: String) -> [Map] // // Uses native_string_chars to split the source into a chars list, -// then indexes it with native_list_get — avoids O(N²) string cloning. +// then indexes it with native_list_get - avoids O(N-) string cloning. -// ── Character helpers ───────────────────────────────────────────────────────── +// -- Character helpers --------------------------------------------------------- fn lex_is_digit(ch: String) -> Bool { if ch == "0" { return true } @@ -101,7 +101,7 @@ fn make_tok(kind: String, value: String) -> Map { { "kind": kind, "value": value } } -// ── Keyword lookup ──────────────────────────────────────────────────────────── +// -- Keyword lookup ------------------------------------------------------------ fn keyword_kind(word: String) -> String { if word == "let" { return "Let" } @@ -147,13 +147,15 @@ fn keyword_kind(word: String) -> String { if word == "accessor" { return "Accessor" } if word == "vessel" { return "Vessel" } if word == "extern" { return "Extern" } + if word == "break" { return "Break" } + if word == "continue" { return "Continue" } "" } -// ── Scan helpers ────────────────────────────────────────────────────────────── +// -- Scan helpers -------------------------------------------------------------- // All scan helpers receive the chars list and total length. -// scan_digits — advance i while chars[i] is a digit +// scan_digits - advance i while chars[i] is a digit // Returns { "text": ..., "pos": i } fn scan_digits(chars: [String], start: Int, total: Int) -> Map { let i = start @@ -175,7 +177,7 @@ fn scan_digits(chars: [String], start: Int, total: Int) -> Map { { "text": str_join(parts, ""), "pos": i } } -// scan_ident — advance i while chars[i] is alphanumeric or underscore +// scan_ident - advance i while chars[i] is alphanumeric or underscore fn scan_ident(chars: [String], start: Int, total: Int) -> Map { let i = start let parts: [String] = native_list_empty() @@ -196,14 +198,14 @@ fn scan_ident(chars: [String], start: Int, total: Int) -> Map { { "text": str_join(parts, ""), "pos": i } } -// ── Code-bearing string detection + comment strip ──────────────────────────── -// Inline JS/CSS literals embedded in El source (e.g. blobs +// -- Code-bearing string detection + comment strip ---------------------------- +// Inline JS/CSS literals embedded in El source (e.g. blobs // or stylesheet payloads inside string literals) carry their own line and // block comments. Those comments leak into the served HTML and reveal build // notes the visitor should never see. We strip them at the lexer so every // downstream consumer (codegen-c, codegen-js, parser) gets the cleaned form. // -// looks_like_code — heuristic gate so we only strip strings that actually +// looks_like_code - heuristic gate so we only strip strings that actually // embed JS or CSS. Plain prose, hex blobs, JSON, etc. pass through verbatim. fn substr_at(chars: [String], start: Int, total: Int, needle: String) -> Bool { @@ -245,7 +247,7 @@ fn looks_like_code(s: String) -> Bool { false } -// strip_code_comments — character-by-character walk. Tracks JS string state +// strip_code_comments - character-by-character walk. Tracks JS string state // (single, double, backtick) and never strips inside one. Backslash escapes // inside JS strings consume the next char verbatim. URLs like https:// are // preserved by checking the previous char before treating // as a line @@ -398,7 +400,7 @@ fn strip_code_comments(s: String) -> String { str_join(out_parts, "") } -// scan_string — scan a quoted string literal, handling \" escapes. +// scan_string - scan a quoted string literal, handling \" escapes. // Starts AFTER the opening quote. Returns { "text": content, "pos": i_after_close } fn scan_string(chars: [String], start: Int, total: Int) -> Map { let i = start @@ -458,7 +460,7 @@ fn scan_string(chars: [String], start: Int, total: Int) -> Map { { "text": str_join(parts, ""), "pos": i } } -// ── Main lexer ──────────────────────────────────────────────────────────────── +// -- Main lexer ---------------------------------------------------------------- fn lex(source: String) -> [Map] { let chars: [String] = native_string_chars(source) @@ -711,7 +713,7 @@ fn lex(source: String) -> [Map] { let tokens = native_list_append(tokens, make_tok("QuestionMark", "?")) let i = i + 1 } else { - // unknown char — skip + // unknown char - skip let i = i + 1 } } diff --git a/el-compiler/src/parser.el b/el-compiler/src/parser.el index e91a288..7742935 100644 --- a/el-compiler/src/parser.el +++ b/el-compiler/src/parser.el @@ -1,4 +1,4 @@ -// parser.el — el self-hosting recursive descent parser +// parser.el - el self-hosting recursive descent parser // // Consumes the token list produced by lexer.el and builds a list of AST // statement maps. Each statement and expression is a Map. @@ -11,7 +11,7 @@ // // Entry point: fn parse(tokens: [Map]) -> [Map] -// ── Token access helpers ────────────────────────────────────────────────────── +// -- Token access helpers ------------------------------------------------------ fn tok_at(tokens: [Map], pos: Int) -> Map { native_list_get(tokens, pos) @@ -36,13 +36,13 @@ fn expect(tokens: [Map], pos: Int, kind: String) -> Int { pos + 1 } -// ── Result helpers ──────────────────────────────────────────────────────────── +// -- Result helpers ------------------------------------------------------------ fn make_result(node: Map, pos: Int) -> Map { { "node": node, "pos": pos } } -// ── Type annotation parser ──────────────────────────────────────────────────── +// -- Type annotation parser ---------------------------------------------------- // Skips over a type annotation, returning the new position. // Types can be: Ident, [Type], Map, Type?, Type @@ -100,8 +100,8 @@ fn skip_type(tokens: [Map], pos: Int) -> Int { pos + 1 } -// ── Parameter list ──────────────────────────────────────────────────────────── -// Parses (name: Type, name: Type, ...) — returns { "params": [...], "pos": ... } +// -- Parameter list ------------------------------------------------------------ +// Parses (name: Type, name: Type, ...) - returns { "params": [...], "pos": ... } fn parse_params(tokens: [Map], pos: Int) -> Map { let p = expect(tokens, pos, "LParen") @@ -140,7 +140,7 @@ fn parse_params(tokens: [Map], pos: Int) -> Map { { "params": params, "pos": p } } -// ── Expression parsing ──────────────────────────────────────────────────────── +// -- Expression parsing -------------------------------------------------------- fn parse_primary(tokens: [Map], pos: Int) -> Map { let k = tok_kind(tokens, pos) @@ -212,14 +212,14 @@ fn parse_primary(tokens: [Map], pos: Int) -> Map { // // Suppression: when parse_if / parse_while / parse_for / parse_match // are parsing a head expression, they set __no_block_expr=1 so a stray - // `{` here doesn't get gobbled as a Map literal — it belongs to the + // `{` here doesn't get gobbled as a Map literal - it belongs to the // following block. Without this, `if a || b { ... }` could mis-parse // (the `||` recursion lands at `{` and tries to read the if-body as a // Map, then loops forever when keys don't match `Str: expr`). if k == "LBrace" { let no_block: String = state_get("__no_block_expr") if str_eq(no_block, "1") { - // Fall through to fallback — caller will see `{` and treat it + // Fall through to fallback - caller will see `{` and treat it // as the start of the block they're expecting. return make_result({ "expr": "Nil" }, pos) } @@ -300,7 +300,7 @@ fn parse_primary(tokens: [Map], pos: Int) -> Map { // token kinds for the deploy/retry DSLs, but they're also valid as // parameter names and local variables. When one of these appears in // expression position (where only an Ident makes sense), treat it as - // an Ident carrying the original text — otherwise references to a + // an Ident carrying the original text - otherwise references to a // parameter named `target` compile to EL_NULL. if k == "Target" { return make_result({ "expr": "Ident", "name": v }, pos + 1) } if k == "To" { return make_result({ "expr": "Ident", "name": v }, pos + 1) } @@ -472,9 +472,9 @@ fn parse_block(tokens: [Map], pos: Int) -> Map { { "stmts": stmts, "pos": p } } -// ── Postfix expressions (calls, field access, index) ───────────────────────── +// -- Postfix expressions (calls, field access, index) ------------------------- -// is_duration_unit — recognise the postfix unit suffix on a numeric literal. +// 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 @@ -578,7 +578,7 @@ fn parse_postfix(tokens: [Map], pos: Int) -> Map { make_result(node, p) } -// ── Binary expression precedence climbing ──────────────────────────────────── +// -- Binary expression precedence climbing ------------------------------------ fn op_precedence(kind: String) -> Int { if kind == "Or" { return 1 } @@ -593,6 +593,7 @@ fn op_precedence(kind: String) -> Int { if kind == "Minus" { return 5 } if kind == "Star" { return 6 } if kind == "Slash" { return 6 } + if kind == "Percent" { return 6 } 0 } @@ -609,6 +610,7 @@ fn is_binop(kind: String) -> Bool { if kind == "Minus" { return true } if kind == "Star" { return true } if kind == "Slash" { return true } + if kind == "Percent" { return true } false } @@ -641,7 +643,7 @@ fn parse_expr(tokens: [Map], pos: Int) -> Map { parse_binop(tokens, pos, 1) } -// ── Statement parsing ───────────────────────────────────────────────────────── +// -- Statement parsing --------------------------------------------------------- fn parse_stmt(tokens: [Map], pos: Int) -> Map { let k = tok_kind(tokens, pos) @@ -653,7 +655,7 @@ fn parse_stmt(tokens: [Map], pos: Int) -> Map { let p = p + 1 let ltype = "" let k2 = tok_kind(tokens, p) - // optional type annotation: name: Type — capture the leading + // optional type annotation: name: Type - capture the leading // identifier so codegen can dispatch arithmetic vs concat on // `+` between two typed Idents. if k2 == "Colon" { @@ -687,7 +689,7 @@ fn parse_stmt(tokens: [Map], pos: Int) -> Map { return make_result({ "stmt": "Return", "value": val }, p) } - // extern fn declaration (no body — forward declaration for separate compilation) + // extern fn declaration (no body - forward declaration for separate compilation) if k == "Extern" { let p = pos + 1 let k2: String = tok_kind(tokens, p) @@ -858,6 +860,16 @@ fn parse_stmt(tokens: [Map], pos: Int) -> Map { return make_result({ "stmt": "While", "cond": cond, "body": body }, p) } + // break statement + if k == "Break" { + return make_result({ "stmt": "Break" }, pos + 1) + } + + // continue statement + if k == "Continue" { + return make_result({ "stmt": "Continue" }, pos + 1) + } + // for loop if k == "For" { let p = pos + 1 @@ -876,7 +888,7 @@ fn parse_stmt(tokens: [Map], pos: Int) -> Map { return make_result({ "stmt": "For", "item": item_name, "list": list_expr, "body": body }, p) } - // @decorator — capture decorator name and attach to following stmt + // @decorator - capture decorator name and attach to following stmt if k == "At" { let p = pos + 1 let dec_name = tok_value(tokens, p) @@ -1039,7 +1051,7 @@ fn parse_stmt(tokens: [Map], pos: Int) -> Map { make_result({ "stmt": "Expr", "value": val }, p) } -// ── Top-level parse ──────────────────────────────────────────────────────────── +// -- Top-level parse ------------------------------------------------------------ fn parse(tokens: [Map]) -> [Map] { let total: Int = native_list_len(tokens) @@ -1058,7 +1070,7 @@ fn parse(tokens: [Map]) -> [Map] { let stmt = r["node"] let new_pos: Int = r["pos"] let stmts = native_list_append(stmts, stmt) - // Guard against infinite loops — if pos didn't advance, force it + // Guard against infinite loops - if pos didn't advance, force it if new_pos <= pos { let pos = pos + 1 } else { From 49a8a1c24ba49c1bda0d35c5686621c48841db3f Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Sun, 3 May 2026 15:44:19 -0500 Subject: [PATCH 2/3] add % modulo operator to El lexer, parser, codegen Lexer and parser already had Percent token and precedence on the compiler/string-interp branch. This commit adds the missing is_int_expr case for Percent so that modulo expressions over Int operands are correctly typed as Int (enabling arithmetic dispatch rather than falling through to string concat or untyped paths). binop_to_c already mapped Percent -> % at HEAD; only is_int_expr needed the Percent arm. --- el-compiler/src/codegen.el | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/el-compiler/src/codegen.el b/el-compiler/src/codegen.el index c2aec70..7a94911 100644 --- a/el-compiler/src/codegen.el +++ b/el-compiler/src/codegen.el @@ -1771,6 +1771,12 @@ fn is_int_expr(expr: Map) -> Bool { } return false } + if str_eq(op, "Percent") { + if is_int_expr(expr["left"]) { + if is_int_expr(expr["right"]) { return true } + } + return false + } return false } return false From f271f9d9d8e7a3342033d3880177866f943af516 Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Sun, 3 May 2026 15:44:58 -0500 Subject: [PATCH 3/3] add for-range loops to El (for i in 0..n) Adds `for i in start..end` (exclusive) and `for i in start..=end` (inclusive) range loop syntax. Existing `for item in list` iteration is preserved; the parser branches on DotDot/DotDotEq presence after the start expression. Lexer adds DotDot and DotDotEq tokens with longer-match-first priority. Codegen emits a C `for` loop with the loop variable scoped to the statement; inclusive uses `<=`, exclusive `<`. --- el-compiler/src/codegen.el | 22 ++++++++++++++++++++++ el-compiler/src/lexer.el | 20 ++++++++++++++++++-- el-compiler/src/parser.el | 30 ++++++++++++++++++++++++++++-- 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/el-compiler/src/codegen.el b/el-compiler/src/codegen.el index 7a94911..5eed473 100644 --- a/el-compiler/src/codegen.el +++ b/el-compiler/src/codegen.el @@ -1141,6 +1141,28 @@ fn cg_stmt(stmt: Map, indent: String, declared: [String]) -> [Strin return declared } + if kind == "ForRange" { + let var_name: String = stmt["var"] + let start_expr = stmt["start"] + let end_expr = stmt["end"] + let inclusive: Bool = stmt["inclusive"] + let body = stmt["body"] + let start_c: String = cg_expr(start_expr) + let end_c: String = cg_expr(end_expr) + // Loop variable introduced as a C local scoped to the for statement. + // Body gets its own declared clone so let-bindings don't leak out. + let body_decl = native_list_clone(declared) + let body_decl = native_list_append(body_decl, var_name) + if inclusive { + emit_line(indent + "for (el_val_t " + var_name + " = " + start_c + "; " + var_name + " <= " + end_c + "; " + var_name + "++) {") + } else { + emit_line(indent + "for (el_val_t " + var_name + " = " + start_c + "; " + var_name + " < " + end_c + "; " + var_name + "++) {") + } + cg_stmts(body, indent + " ", body_decl) + emit_line(indent + "}") + return declared + } + if kind == "FnDef" { return declared } if kind == "TypeDef" { return declared } if kind == "EnumDef" { return declared } diff --git a/el-compiler/src/lexer.el b/el-compiler/src/lexer.el index cef3b33..1ecd95e 100644 --- a/el-compiler/src/lexer.el +++ b/el-compiler/src/lexer.el @@ -698,8 +698,24 @@ fn lex(source: String) -> [Map] { let i = i + 1 } else { if ch == "." { - let tokens = native_list_append(tokens, make_tok("Dot", ".")) - let i = i + 1 + // Check for ..= (inclusive range) before .. (exclusive range) before single . + let peek2_i = i + 2 + let peek2_ch = "" + if peek2_i < total { + let peek2_ch: String = native_list_get(chars, peek2_i) + } + if peek_ch == "." { + if peek2_ch == "=" { + let tokens = native_list_append(tokens, make_tok("DotDotEq", "..=")) + let i = i + 3 + } else { + let tokens = native_list_append(tokens, make_tok("DotDot", "..")) + let i = i + 2 + } + } else { + let tokens = native_list_append(tokens, make_tok("Dot", ".")) + let i = i + 1 + } } else { if ch == ";" { let tokens = native_list_append(tokens, make_tok("Semicolon", ";")) diff --git a/el-compiler/src/parser.el b/el-compiler/src/parser.el index 7742935..0e5375a 100644 --- a/el-compiler/src/parser.el +++ b/el-compiler/src/parser.el @@ -870,7 +870,7 @@ fn parse_stmt(tokens: [Map], pos: Int) -> Map { return make_result({ "stmt": "Continue" }, pos + 1) } - // for loop + // for loop (range or list iteration) if k == "For" { let p = pos + 1 let item_name = tok_value(tokens, p) @@ -880,8 +880,34 @@ fn parse_stmt(tokens: [Map], pos: Int) -> Map { state_set("__no_block_expr", "1") let r = parse_expr(tokens, p) state_set("__no_block_expr", prev_no_block) - let list_expr = r["node"] + let start_expr = r["node"] let p = r["pos"] + // Check for range operator: .. (exclusive) or ..= (inclusive) + let range_k = tok_kind(tokens, p) + if range_k == "DotDot" { + // exclusive range: for i in start..end + let p = p + 1 + let r2 = parse_expr(tokens, p) + let end_expr = r2["node"] + let p = r2["pos"] + let r3 = parse_block(tokens, p) + let body = r3["stmts"] + let p = r3["pos"] + return make_result({ "stmt": "ForRange", "var": item_name, "start": start_expr, "end": end_expr, "inclusive": false, "body": body }, p) + } + if range_k == "DotDotEq" { + // inclusive range: for i in start..=end + let p = p + 1 + let r2 = parse_expr(tokens, p) + let end_expr = r2["node"] + let p = r2["pos"] + let r3 = parse_block(tokens, p) + let body = r3["stmts"] + let p = r3["pos"] + return make_result({ "stmt": "ForRange", "var": item_name, "start": start_expr, "end": end_expr, "inclusive": true, "body": body }, p) + } + // No range operator: regular for-in (list iteration) + let list_expr = start_expr let r2 = parse_block(tokens, p) let body = r2["stmts"] let p = r2["pos"]