codegen: type-driven dispatch for + between Int idents

Closes the known limitation from the self-host commit: `fn add(a:Int,
b:Int) { a + b }` now compiles to integer addition, not string concat.
Previously the codegen heuristic guessed string concat whenever both
operands were Idents with no literal anchor.

Mechanism
- parser captures the leading type identifier from `let x: T = ...`
  bindings (new "type" field on Let) and from function parameter
  annotations (new "type" field on each param).
- codegen maintains a per-function int-name set in process state via
  state_set("__int_names", csv). cg_fn seeds it from typed parameters;
  cg_stmt extends it from typed `let` bindings and from `let x = <Int
  literal>` (literal inference).
- BinOp Plus: when both sides are Idents and both names are in the
  int-name set, emit arithmetic; otherwise the existing literal-anchor
  heuristic applies, with string concat as the fallback.

This is the first compiler change made entirely through the self-
hosting workflow — no Python bootstrap. Edit el source, run existing
elc on elc-combined.el, cc the output, test. Closure holds at the
new binary.

Tests
- add(40, 2) → 42
- count_to(10) → 45 (let i: Int / let total: Int rebinding)
- Regression suite (tiny/implret/whiletest/lextest) unchanged.

dist/platform/elc updated; .prev preserved.
This commit is contained in:
Will Anderson
2026-04-30 13:13:38 -05:00
parent 5c05ce9b99
commit 2eddaf1fe6
6 changed files with 249 additions and 13 deletions
BIN
View File
Binary file not shown.
+78 -2
View File
@@ -49,6 +49,9 @@ el_val_t cg_stmts(el_val_t stmts, el_val_t indent, el_val_t declared);
el_val_t param_decl(el_val_t param, el_val_t idx);
el_val_t params_to_c(el_val_t params);
el_val_t transform_implicit_return(el_val_t body);
el_val_t is_int_name(el_val_t name);
el_val_t add_int_name(el_val_t name);
el_val_t build_int_names_for_params(el_val_t params);
el_val_t cg_fn(el_val_t stmt);
el_val_t is_fndef(el_val_t stmt);
el_val_t is_top_level_decl(el_val_t stmt);
@@ -878,8 +881,13 @@ el_val_t parse_params(el_val_t tokens, el_val_t pos) {
el_val_t pname = tok_value(tokens, p);
p = (p + 1);
p = expect(tokens, p, EL_STR("Colon"));
el_val_t ptype = EL_STR("");
el_val_t kt = tok_kind(tokens, p);
if (str_eq(kt, EL_STR("Ident"))) {
ptype = tok_value(tokens, p);
}
p = skip_type(tokens, p);
el_val_t param = el_map_new(1, "name", pname);
el_val_t param = el_map_new(2, "name", pname, "type", ptype);
params = native_list_append(params, param);
el_val_t k2 = tok_kind(tokens, p);
if (str_eq(k2, EL_STR("Comma"))) {
@@ -1309,16 +1317,21 @@ el_val_t parse_stmt(el_val_t tokens, el_val_t pos) {
el_val_t p = (pos + 1);
el_val_t name = tok_value(tokens, p);
p = (p + 1);
el_val_t ltype = EL_STR("");
el_val_t k2 = tok_kind(tokens, p);
if (str_eq(k2, EL_STR("Colon"))) {
p = (p + 1);
el_val_t kt = tok_kind(tokens, p);
if (str_eq(kt, EL_STR("Ident"))) {
ltype = tok_value(tokens, p);
}
p = skip_type(tokens, p);
}
p = expect(tokens, p, EL_STR("Eq"));
el_val_t r = parse_expr(tokens, p);
el_val_t val = el_get_field(r, EL_STR("node"));
p = el_get_field(r, EL_STR("pos"));
return make_result(el_map_new(3, "stmt", EL_STR("Let"), "name", name, "value", val), p);
return make_result(el_map_new(4, "stmt", EL_STR("Let"), "name", name, "value", val, "type", ltype), p);
}
if (str_eq(k, EL_STR("Return"))) {
el_val_t p = (pos + 1);
@@ -1690,6 +1703,18 @@ el_val_t cg_expr(el_val_t expr) {
el_val_t op_c = binop_to_c(op);
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("("), left_c), EL_STR(" ")), op_c), EL_STR(" ")), right_c), EL_STR(")"));
}
if (str_eq(left_kind, EL_STR("Ident"))) {
if (str_eq(right_kind, EL_STR("Ident"))) {
el_val_t lname = el_get_field(left, EL_STR("name"));
el_val_t rname = el_get_field(right, EL_STR("name"));
if (is_int_name(lname)) {
if (is_int_name(rname)) {
el_val_t op_c = binop_to_c(op);
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("("), left_c), EL_STR(" ")), op_c), EL_STR(" ")), right_c), EL_STR(")"));
}
}
}
}
if (str_eq(left_kind, EL_STR("Call"))) {
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("el_str_concat("), left_c), EL_STR(", ")), right_c), EL_STR(")"));
}
@@ -1898,6 +1923,14 @@ el_val_t cg_stmt(el_val_t stmt, el_val_t indent, el_val_t declared) {
el_val_t name = el_get_field(stmt, EL_STR("name"));
el_val_t val = el_get_field(stmt, EL_STR("value"));
el_val_t val_c = cg_expr(val);
el_val_t ltype = el_get_field(stmt, EL_STR("type"));
if (str_eq(ltype, EL_STR("Int"))) {
add_int_name(name);
}
el_val_t vk = el_get_field(val, EL_STR("expr"));
if (str_eq(vk, EL_STR("Int"))) {
add_int_name(name);
}
if (list_contains(declared, name)) {
emit_line(el_str_concat(el_str_concat(el_str_concat(el_str_concat(indent, name), EL_STR(" = ")), val_c), EL_STR(";")));
return declared;
@@ -2118,6 +2151,48 @@ el_val_t transform_implicit_return(el_val_t body) {
return 0;
}
el_val_t is_int_name(el_val_t name) {
el_val_t csv = state_get(EL_STR("__int_names"));
if (str_eq(csv, EL_STR(""))) {
return 0;
}
return str_contains(csv, el_str_concat(el_str_concat(EL_STR(","), name), EL_STR(",")));
return 0;
}
el_val_t add_int_name(el_val_t name) {
el_val_t csv = state_get(EL_STR("__int_names"));
if (str_eq(csv, EL_STR(""))) {
csv;
EL_NULL;
EL_STR(",");
}
el_val_t key = el_str_concat(el_str_concat(EL_STR(","), name), EL_STR(","));
if (str_contains(csv, key)) {
return 1;
}
state_set(EL_STR("__int_names"), el_str_concat(el_str_concat(csv, name), EL_STR(",")));
return 1;
return 0;
}
el_val_t build_int_names_for_params(el_val_t params) {
state_set(EL_STR("__int_names"), EL_STR(","));
el_val_t np = native_list_len(params);
el_val_t pi = 0;
while (pi < np) {
el_val_t param = native_list_get(params, pi);
el_val_t pname = el_get_field(param, EL_STR("name"));
el_val_t ptype = el_get_field(param, EL_STR("type"));
if (str_eq(ptype, EL_STR("Int"))) {
add_int_name(pname);
}
pi = (pi + 1);
}
return 1;
return 0;
}
el_val_t cg_fn(el_val_t stmt) {
el_val_t fn_name = el_get_field(stmt, EL_STR("name"));
if (str_eq(fn_name, EL_STR("main"))) {
@@ -2127,6 +2202,7 @@ el_val_t cg_fn(el_val_t stmt) {
el_val_t body = el_get_field(stmt, EL_STR("body"));
el_val_t ret_type = el_get_field(stmt, EL_STR("ret_type"));
el_val_t params_c = params_to_c(params);
build_int_names_for_params(params);
emit_line(el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("el_val_t "), fn_name), EL_STR("(")), params_c), EL_STR(") {")));
el_val_t decl = native_list_empty();
el_val_t np = native_list_len(params);
Vendored Executable
BIN
View File
Binary file not shown.
+68 -2
View File
@@ -170,6 +170,23 @@ fn cg_expr(expr: Map<String, Any>) -> String {
let op_c: String = binop_to_c(op)
return "(" + left_c + " " + op_c + " " + right_c + ")"
}
// Type-driven dispatch: if both sides are Idents declared
// with type Int (parameters annotated `: Int` or let bindings
// annotated `: Int`), this is arithmetic, not concat. The
// current-function int-name set is maintained by cg_fn /
// cg_stmt via state_set("__int_names", csv).
if left_kind == "Ident" {
if right_kind == "Ident" {
let lname: String = left["name"]
let rname: String = right["name"]
if is_int_name(lname) {
if is_int_name(rname) {
let op_c: String = binop_to_c(op)
return "(" + left_c + " " + op_c + " " + right_c + ")"
}
}
}
}
if left_kind == "Call" {
return "el_str_concat(" + left_c + ", " + right_c + ")"
}
@@ -188,8 +205,8 @@ fn cg_expr(expr: Map<String, Any>) -> String {
return "el_str_concat(" + left_c + ", " + right_c + ")"
}
}
// Ident + Ident or Ident + unknown assume string concat
// (This is the ambiguous case: El uses + for both string and integer ops)
// Ident + Ident or Ident + unknown without int-typed evidence
// fall back to string concat (the historical heuristic).
if left_kind == "Ident" {
return "el_str_concat(" + left_c + ", " + right_c + ")"
}
@@ -413,6 +430,17 @@ fn cg_stmt(stmt: Map<String, Any>, indent: String, declared: [String]) -> [Strin
let name: String = stmt["name"]
let val = stmt["value"]
let val_c: String = cg_expr(val)
// If the binding is annotated `: Int` and val is an Int literal,
// register `name` in the per-function int-name set so that later
// `name + ...` dispatches to arithmetic, not concat.
let ltype: String = stmt["type"]
if str_eq(ltype, "Int") {
add_int_name(name)
}
let vk: String = val["expr"]
if str_eq(vk, "Int") {
add_int_name(name)
}
if list_contains(declared, name) {
emit_line(indent + name + " = " + val_c + ";")
return declared
@@ -627,6 +655,41 @@ fn transform_implicit_return(body: [Map<String, Any>]) -> [Map<String, Any>] {
body
}
// Test whether `name` is currently registered as an Int-typed identifier
// for the function being codegened. The set is maintained as a comma-
// bounded CSV in process state; cg_fn seeds it from typed parameters,
// cg_stmt extends it from typed `let` bindings.
fn is_int_name(name: String) -> Bool {
let csv: String = state_get("__int_names")
if str_eq(csv, "") { return false }
return str_contains(csv, "," + name + ",")
}
fn add_int_name(name: String) -> Bool {
let csv: String = state_get("__int_names")
if str_eq(csv, "") { csv = "," }
let key: String = "," + name + ","
if str_contains(csv, key) { return true }
state_set("__int_names", csv + name + ",")
return true
}
fn build_int_names_for_params(params: [Map<String, Any>]) -> Bool {
state_set("__int_names", ",")
let np: Int = native_list_len(params)
let pi = 0
while pi < np {
let param = native_list_get(params, pi)
let pname: String = param["name"]
let ptype: String = param["type"]
if str_eq(ptype, "Int") {
add_int_name(pname)
}
let pi = pi + 1
}
return true
}
fn cg_fn(stmt: Map<String, Any>) -> Void {
let fn_name: String = stmt["name"]
// Skip El's `fn main()` C provides its own main() for top-level stmts
@@ -636,6 +699,9 @@ fn cg_fn(stmt: Map<String, Any>) -> Void {
let body = stmt["body"]
let ret_type: String = stmt["ret_type"]
let params_c: String = params_to_c(params)
// Seed the per-function int-name set so the `+` codegen can dispatch
// arithmetic vs concat on type-annotated identifiers.
build_int_names_for_params(params)
emit_line("el_val_t " + fn_name + "(" + params_c + ") {")
// Seed declared with parameter names so reassignment works
let decl = native_list_empty()
+17 -3
View File
@@ -119,8 +119,15 @@ fn parse_params(tokens: [Map<String, Any>], pos: Int) -> Map<String, Any> {
let pname = tok_value(tokens, p)
let p = p + 1
let p = expect(tokens, p, "Colon")
// Capture the leading type identifier so codegen can dispatch
// arithmetic vs string-concat on `+` based on declared types.
let ptype = ""
let kt = tok_kind(tokens, p)
if kt == "Ident" {
let ptype = tok_value(tokens, p)
}
let p = skip_type(tokens, p)
let param = { "name": pname }
let param = { "name": pname, "type": ptype }
let params = native_list_append(params, param)
let k2 = tok_kind(tokens, p)
if k2 == "Comma" {
@@ -533,17 +540,24 @@ fn parse_stmt(tokens: [Map<String, Any>], pos: Int) -> Map<String, Any> {
let p = pos + 1
let name = tok_value(tokens, p)
let p = p + 1
let ltype = ""
let k2 = tok_kind(tokens, p)
// optional type annotation: name: Type
// optional type annotation: name: Type capture the leading
// identifier so codegen can dispatch arithmetic vs concat on
// `+` between two typed Idents.
if k2 == "Colon" {
let p = p + 1
let kt = tok_kind(tokens, p)
if kt == "Ident" {
let ltype = tok_value(tokens, p)
}
let p = skip_type(tokens, p)
}
let p = expect(tokens, p, "Eq")
let r = parse_expr(tokens, p)
let val = r["node"]
let p = r["pos"]
return make_result({ "stmt": "Let", "name": name, "value": val }, p)
return make_result({ "stmt": "Let", "name": name, "value": val, "type": ltype }, p)
}
// return statement
+86 -6
View File
@@ -658,8 +658,15 @@ fn parse_params(tokens: [Map<String, Any>], pos: Int) -> Map<String, Any> {
let pname = tok_value(tokens, p)
let p = p + 1
let p = expect(tokens, p, "Colon")
// Capture the leading type identifier so codegen can dispatch
// arithmetic vs string-concat on `+` based on declared types.
let ptype = ""
let kt = tok_kind(tokens, p)
if kt == "Ident" {
let ptype = tok_value(tokens, p)
}
let p = skip_type(tokens, p)
let param = { "name": pname }
let param = { "name": pname, "type": ptype }
let params = native_list_append(params, param)
let k2 = tok_kind(tokens, p)
if k2 == "Comma" {
@@ -1072,17 +1079,24 @@ fn parse_stmt(tokens: [Map<String, Any>], pos: Int) -> Map<String, Any> {
let p = pos + 1
let name = tok_value(tokens, p)
let p = p + 1
let ltype = ""
let k2 = tok_kind(tokens, p)
// optional type annotation: name: Type
// optional type annotation: name: Type capture the leading
// identifier so codegen can dispatch arithmetic vs concat on
// `+` between two typed Idents.
if k2 == "Colon" {
let p = p + 1
let kt = tok_kind(tokens, p)
if kt == "Ident" {
let ltype = tok_value(tokens, p)
}
let p = skip_type(tokens, p)
}
let p = expect(tokens, p, "Eq")
let r = parse_expr(tokens, p)
let val = r["node"]
let p = r["pos"]
return make_result({ "stmt": "Let", "name": name, "value": val }, p)
return make_result({ "stmt": "Let", "name": name, "value": val, "type": ltype }, p)
}
// return statement
@@ -1479,6 +1493,23 @@ fn cg_expr(expr: Map<String, Any>) -> String {
let op_c: String = binop_to_c(op)
return "(" + left_c + " " + op_c + " " + right_c + ")"
}
// Type-driven dispatch: if both sides are Idents declared
// with type Int (parameters annotated `: Int` or let bindings
// annotated `: Int`), this is arithmetic, not concat. The
// current-function int-name set is maintained by cg_fn /
// cg_stmt via state_set("__int_names", csv).
if left_kind == "Ident" {
if right_kind == "Ident" {
let lname: String = left["name"]
let rname: String = right["name"]
if is_int_name(lname) {
if is_int_name(rname) {
let op_c: String = binop_to_c(op)
return "(" + left_c + " " + op_c + " " + right_c + ")"
}
}
}
}
if left_kind == "Call" {
return "el_str_concat(" + left_c + ", " + right_c + ")"
}
@@ -1497,8 +1528,8 @@ fn cg_expr(expr: Map<String, Any>) -> String {
return "el_str_concat(" + left_c + ", " + right_c + ")"
}
}
// Ident + Ident or Ident + unknown assume string concat
// (This is the ambiguous case: El uses + for both string and integer ops)
// Ident + Ident or Ident + unknown without int-typed evidence
// fall back to string concat (the historical heuristic).
if left_kind == "Ident" {
return "el_str_concat(" + left_c + ", " + right_c + ")"
}
@@ -1722,6 +1753,17 @@ fn cg_stmt(stmt: Map<String, Any>, indent: String, declared: [String]) -> [Strin
let name: String = stmt["name"]
let val = stmt["value"]
let val_c: String = cg_expr(val)
// If the binding is annotated `: Int` and val is an Int literal,
// register `name` in the per-function int-name set so that later
// `name + ...` dispatches to arithmetic, not concat.
let ltype: String = stmt["type"]
if str_eq(ltype, "Int") {
add_int_name(name)
}
let vk: String = val["expr"]
if str_eq(vk, "Int") {
add_int_name(name)
}
if list_contains(declared, name) {
emit_line(indent + name + " = " + val_c + ";")
return declared
@@ -1936,6 +1978,41 @@ fn transform_implicit_return(body: [Map<String, Any>]) -> [Map<String, Any>] {
body
}
// Test whether `name` is currently registered as an Int-typed identifier
// for the function being codegened. The set is maintained as a comma-
// bounded CSV in process state; cg_fn seeds it from typed parameters,
// cg_stmt extends it from typed `let` bindings.
fn is_int_name(name: String) -> Bool {
let csv: String = state_get("__int_names")
if str_eq(csv, "") { return false }
return str_contains(csv, "," + name + ",")
}
fn add_int_name(name: String) -> Bool {
let csv: String = state_get("__int_names")
if str_eq(csv, "") { csv = "," }
let key: String = "," + name + ","
if str_contains(csv, key) { return true }
state_set("__int_names", csv + name + ",")
return true
}
fn build_int_names_for_params(params: [Map<String, Any>]) -> Bool {
state_set("__int_names", ",")
let np: Int = native_list_len(params)
let pi = 0
while pi < np {
let param = native_list_get(params, pi)
let pname: String = param["name"]
let ptype: String = param["type"]
if str_eq(ptype, "Int") {
add_int_name(pname)
}
let pi = pi + 1
}
return true
}
fn cg_fn(stmt: Map<String, Any>) -> Void {
let fn_name: String = stmt["name"]
// Skip El's `fn main()` C provides its own main() for top-level stmts
@@ -1945,6 +2022,9 @@ fn cg_fn(stmt: Map<String, Any>) -> Void {
let body = stmt["body"]
let ret_type: String = stmt["ret_type"]
let params_c: String = params_to_c(params)
// Seed the per-function int-name set so the `+` codegen can dispatch
// arithmetic vs concat on type-annotated identifiers.
build_int_names_for_params(params)
emit_line("el_val_t " + fn_name + "(" + params_c + ") {")
// Seed declared with parameter names so reassignment works
let decl = native_list_empty()
@@ -2074,7 +2154,7 @@ fn compile(source: String) -> String {
// result to args()[1]. Then run:
// cc -o <prog> <output.c> el_runtime.c
// CLI driver equivalent to: elc <source.el>
// CLI driver
let _argv: [String] = args()
let _src_path: String = native_list_get(_argv, 0)
let _source: String = fs_read(_src_path)