From 2b108c022f7ac8e4d9f145afd50cdcd10f83bcee Mon Sep 17 00:00:00 2001 From: Assaf Sapir Date: Sat, 27 Jun 2026 17:35:40 +0300 Subject: [PATCH] M3: Text-in-composite codegen (type-oracle side-table) Codegen recovered LLVM types from runtime BasicValueEnum::get_type() at every READ site and hardcoded f64, corrupting any non-f64 value nested in a composite: Text in a record/array, nested arrays, and Ok(text)/NotOk(text). Fix (the start of M4 "authoritative types in codegen"): the type checker now stashes a per-expression type side-table (the "type oracle", HashMap, returned from check_program). Codegen consults it at the three read sites instead of assuming f64: - array index GEP/load uses the index expression's recorded element type - record field access/GEP rebuilds the real struct type from the record's declared field types (record ABI unchanged: var alloca holds a pointer to the struct) - match-result alloca/load uses the match's recorded result type A value_repr_type() helper maps a declared Type to its in-composite value representation (arrays -> {ptr,i64} struct inline; records -> pointer; unresolved Generic -> f64), kept distinct from type_to_llvm()'s by-reference lowering. The checker also specializes a constructed Result variant's generic payload to the concrete arg type (so Ok("x") carries Text) and prefers the concrete arm type as a match result; types_compatible() keeps differently- specialized values of the same sum type compatible. Also fixes two latent bugs surfaced in review: - named-type constructor fields are reordered to declaration order before lowering, so out-of-order construction can't mis-GEP a field slot - a match whose result type came from a never-constructed generic arm no longer hard-errors in codegen Ships examples/composites.ql (Text record field + array of Text + nested array, exit 12), wired into the examples gate (JIT + native AOT clang & gcc), plus tests/composite_text_test.rs. Flips the Text-in-composite / sum-payload rows in LANGUAGE.md to done. Co-Authored-By: Claude Opus 4.8 (1M context) --- LANGUAGE.md | 14 +- examples/composites.ql | 19 +++ examples/records.ql | 3 +- examples/result.ql | 4 +- src/build.rs | 3 +- src/codegen/generator.rs | 280 +++++++++++++++++++++++++++++------ src/jit.rs | 4 +- src/lexer/token.rs | 2 +- src/main.rs | 9 +- src/typechecker/checker.rs | 152 +++++++++++++++---- src/typechecker/mod.rs | 2 +- tests/composite_text_test.rs | 224 ++++++++++++++++++++++++++++ tests/examples_test.rs | 1 + tests/module_test.rs | 1 + 14 files changed, 633 insertions(+), 85 deletions(-) create mode 100644 examples/composites.ql create mode 100644 tests/composite_text_test.rs diff --git a/LANGUAGE.md b/LANGUAGE.md index 610478b..6eddeb6 100644 --- a/LANGUAGE.md +++ b/LANGUAGE.md @@ -81,7 +81,10 @@ Anonymous structs with named fields: user = { name = "Alice", age = 30 } n = user.name ``` -(See `examples/records.ql`.) +Fields may hold any type — `Text`, arrays, nested arrays, etc. — and read back at +their real type (no numeric-only restriction). (See `examples/records.ql` and +`examples/composites.ql`, which exercises a `Text` record field, an array of `Text`, +and a nested array together.) ### Named record types with methods Methods take an implicit `it` (the receiver): @@ -141,8 +144,8 @@ classify = v => v ? | Ok(x) => x * 2 | NotOk(e) => 0 ``` -Numeric payloads work end-to-end. (See `examples/result.ql`. Non-numeric payloads in -some positions: see [Known limitations](#known-limitations).) +Payloads work end-to-end for `Num`, `Bool`, and `Text` (e.g. `Ok("done")` / +`NotOk("error")`). (See `examples/result.ql` and `examples/composites.ql`.) #### `/` — sum-type separator vs. division `/` is the division operator **and** the sum-type variant separator. They are told apart @@ -401,11 +404,11 @@ message instead. Any compile error exits with status 1. | Pattern matching (numbers, wildcard, identifiers, sum-type variants) | ✅ | | User-defined sum types (`/` separator), exhaustive matching, payload binding | ✅ | | `Result` as a normal predefined sum type (`Ok`/`NotOk`) | ✅ | -| Sum-type payloads: `Num` / `Bool` (and `Text`, see limitations) | ✅ | +| Sum-type payloads: `Num` / `Bool` / `Text` | ✅ | | Modules: `<< core.io`, file-path imports, `>>` exports | ✅ | | I/O: `print` / `eprint` / `write` | ✅ | | Conservative GC (Boehm) | ✅ | -| `Text` in records/arrays, or as a sum-type payload (`Ok(text)`) | 🚧 | +| `Text` (and nested arrays) in records/arrays, or as a sum-type payload (`Ok(text)`) | ✅ | | Command-line `argv` (argc works; argv is a placeholder) | 🚧 | | Generics, closures, `while` loops | ❌ | | Array methods (`map`/`filter`/`reduce`), string interpolation | ❌ | @@ -416,7 +419,6 @@ message instead. Any compile error exits with status 1. 0.9 is a stable **core**, not the whole language. Notably: -- **Non-numeric data in composites isn't sound yet.** `Text` inside a record or an array, and non-numeric sum-type payloads such as `Ok("x")` / `NotOk("error")`, do not type-check correctly in 0.9 — numeric payloads and numeric records/arrays work. Planned for a later release. - **Array `.size` works only on a named receiver** (`xs.size`), not on a literal/expression (`[1,2,3].size`). - A user-defined `print`/`eprint` is honored by the type checker but the code generator still lowers the built-in — overriding the runtime body is a follow-up. - **No generics, closures, or `while` loops.** The module system is minimal (`core.io` built-in + file-path imports). diff --git a/examples/composites.ql b/examples/composites.ql new file mode 100644 index 0000000..dbb28dd --- /dev/null +++ b/examples/composites.ql @@ -0,0 +1,19 @@ +~ Text and other non-numeric values nested inside composites round-trip correctly: +~ a `Text` field of a record, an array of `Text`, and a nested array all read back +~ with their real type (no f64 corruption). Codegen recovers each element/field type +~ from the type oracle rather than assuming `Num`. +^ = () -> Num => < + ~ Record with a Text field: read it back, count its graphemes. + user = { name = "Quilon", n = 7 } + nameLen = user.name.length ~ "Quilon" -> 6 + + ~ Array of Text: index it, then take the byte length of the element. + words = ["a", "cde"] + wordLen = words[1].size ~ "cde" -> 3 + + ~ Nested array (array of arrays): double-index it. + grid = [[1, 2], [3, 4]] + cell = grid[1][0] ~ 3 + + nameLen + wordLen + cell ~ exit 6 + 3 + 3 = 12 +> diff --git a/examples/records.ql b/examples/records.ql index 9d1a946..0035084 100644 --- a/examples/records.ql +++ b/examples/records.ql @@ -1,6 +1,5 @@ ~ Records are anonymous structs with named fields; access fields with `.`. -~ (Fields are numeric here — Text/array fields inside composites await richer -~ payload support; see LANGUAGE.md "Known limitations".) +~ (Fields can hold any type — Text, arrays, etc.; see examples/composites.ql.) ^ = () -> Num => < rect = { width = 4, height = 7 } rect.width * rect.height ~ exit 28 diff --git a/examples/result.ql b/examples/result.ql index f58a9ce..1ea6d3d 100644 --- a/examples/result.ql +++ b/examples/result.ql @@ -1,6 +1,6 @@ ~ The built-in `Result` sum type: `Ok(value)` for success, `NotOk(error)` for -~ failure. Pattern-match to extract the payload. (Numeric payloads; richer payload -~ types await generics — see LANGUAGE.md.) +~ failure. Pattern-match to extract the payload. Payloads may be Num, Bool, or Text +~ (e.g. `Ok("done")`); see examples/composites.ql and LANGUAGE.md. ^ = () -> Num => < outcome = Ok(42) outcome ? diff --git a/src/build.rs b/src/build.rs index 70c197a..425ce33 100644 --- a/src/build.rs +++ b/src/build.rs @@ -26,7 +26,8 @@ fn emit_object(program: &Program, obj_path: &Path) -> Result<(), String> { .map_err(|e| format!("Failed to initialize native target: {e}"))?; let context = Context::create(); - let mut generator = CodeGenerator::new(&context, "main"); + // Build the generator with the type oracle installed (precise composite read types). + let mut generator = CodeGenerator::with_oracle(&context, "main", program)?; // Populates, verifies, and builds the C `main` wrapper around `^`. generator.generate(program)?; let module = generator.module(); diff --git a/src/codegen/generator.rs b/src/codegen/generator.rs index 4182e29..ee294c9 100644 --- a/src/codegen/generator.rs +++ b/src/codegen/generator.rs @@ -51,6 +51,57 @@ pub struct CodeGenerator<'ctx> { // payloads are sized per-value at construction — see `register_builtin_sum_types`). sum_layouts: HashMap>>, current_function: Option>, + // The type oracle: authoritative inferred types for every expression, keyed by span, + // produced by the type checker (see `TypeOracle`). Codegen consults it at READ sites + // (array index, record-field access, match-arm result) to recover the *declared* + // element/field/result LLVM type instead of guessing `f64` from a runtime value. + // Populated at the start of `generate`; empty before then. + oracle: TypeOracle, +} + +/// Codegen-side view of the type checker's [`TypeTable`] — the "type oracle". +/// +/// # Why this exists +/// Codegen used to recover LLVM types from runtime `BasicValueEnum::get_type()`, which +/// loses element/field types at every READ site and hardcodes `f64`. That corrupts any +/// non-`f64` payload nested in a composite — `Text` in a record/array, nested arrays, +/// `Ok(text)`/`NotOk(text)`. The fix is to thread the *declared* types (already computed +/// by the checker) through to the read sites. +/// +/// # API (for downstream M3 waves: array methods, spread, args/env) +/// The single primitive is [`TypeOracle::expr_type`] — the inferred `Type` of any +/// expression, looked up by its source `Span`. The checker records the *result* type of +/// every node, so the element type of an `arr[i]` is `expr_type()`, the +/// type of `rec.field` is `expr_type()`, and a `match`'s result is +/// `expr_type()` — there is no need for per-shape accessors, the read +/// site just asks for the type of the whole node it is lowering. +/// +/// Lookups are by `Span` (one per AST node), so the oracle is AST-shape-agnostic and +/// additive: new expression kinds get types recorded automatically by `infer_expr`. A +/// `None` means the span wasn't recorded (e.g. the IR-only codegen tests that skip the +/// type-check pass); callers fall back to their historical `f64` assumption. +/// +/// LIMITATION (tracked for a later M-wave): a `Span` is a byte range with no file/module +/// identity, and the `<<` import system lexes each module independently (offsets restart +/// at 0) before merging items into one `Program`. Two expressions in different modules can +/// therefore share a span and collide in the table (last-inferred wins). Today's imported +/// modules are numeric helpers/intrinsics with no composite reads, so this is latent, not +/// live; the robust fix is a stable per-node id (or a `(module, span)` key) assigned at +/// parse time. Until then, the oracle is only fully sound for single-file programs. +#[derive(Default)] +struct TypeOracle { + table: crate::typechecker::TypeTable, +} + +impl TypeOracle { + fn new(table: crate::typechecker::TypeTable) -> Self { + Self { table } + } + + /// The inferred type of `expr`, by its span. `None` if the checker didn't record it. + fn expr_type(&self, expr: &Expr) -> Option<&Type> { + self.table.get(expr.span()) + } } impl<'ctx> CodeGenerator<'ctx> { @@ -69,6 +120,7 @@ impl<'ctx> CodeGenerator<'ctx> { sum_variants: HashMap::new(), sum_layouts: HashMap::new(), current_function: None, + oracle: TypeOracle::default(), }; codegen.register_builtin_sum_types(); codegen @@ -95,6 +147,41 @@ impl<'ctx> CodeGenerator<'ctx> { &self.module } + /// Construct a generator with its **type oracle** already installed. + /// + /// Real compilation paths (`quilon run`/`compile`/`build`) reach codegen with a + /// `program` that already passed the front-end type check (in `driver::front_end`), + /// but that check's `TypeTable` isn't threaded down to here — so we re-derive it by + /// type-checking once more and harvesting the table. The re-check is deliberate: it + /// keeps every codegen entry point (CLI, JIT, tests) oracle-backed through one call + /// without each caller having to carry the table, and `check_program` is a pure + /// function of the AST, so the second run cannot disagree with the first. (If the + /// double pass ever shows up in compile-time profiles, the fix is to have + /// `front_end` return its table and feed it via [`set_type_table`].) A failure here + /// would mean codegen was handed an unchecked program — surfaced as an internal error. + pub fn with_oracle( + context: &'ctx Context, + module_name: &str, + program: &Program, + ) -> Result { + let table = crate::typechecker::TypeChecker::new() + .check_program(program) + .map_err(|e| format!("internal: type check failed before codegen: {e}"))?; + let mut codegen = Self::new(context, module_name); + codegen.set_type_table(table); + Ok(codegen) + } + + /// Install the **type oracle** (the type checker's per-expression `TypeTable`) that + /// codegen consults at read sites to recover precise element/field/match-result + /// types. The companion to [`with_oracle`] for callers that already hold a table. + /// Without it the oracle is empty, every lookup misses, and read sites fall back to + /// their historical `f64` assumption — which is what the IR-only codegen tests (no + /// typecheck pass) rely on. + pub fn set_type_table(&mut self, table: crate::typechecker::TypeTable) { + self.oracle = TypeOracle::new(table); + } + pub fn generate(&mut self, program: &Program) -> Result { // Pre-pass: register all user sum-type variants so constructors and pattern // dispatch resolve regardless of declaration order relative to their uses. @@ -606,21 +693,31 @@ impl<'ctx> CodeGenerator<'ctx> { Expr::Record { fields, .. } => self.generate_record(fields), Expr::Constructor { - type_name: _, - fields, - .. + type_name, fields, .. } => { - // Constructors have the same representation as records - self.generate_record(fields) + // A named-type instance has the same struct representation as a record, + // but its field SLOTS follow the type's DECLARATION order — which is the + // order `record_types` and the type oracle use to index/GEP fields later. + // The constructor call may list fields in any order, so reorder them to + // declaration order before lowering; otherwise a later `obj.field` read + // would GEP the wrong slot (silent corruption once fields differ in type). + let ordered = self.constructor_fields_in_decl_order(type_name, fields); + self.generate_record(&ordered) } Expr::FieldAccess { expr, field, .. } => self.generate_field_access(expr, field), Expr::FieldAssign { target, value, .. } => self.generate_field_assign(target, value), - Expr::Index { expr, index, .. } => self.generate_index(expr, index), + Expr::Index { + expr: array, index, .. + } => self.generate_index(expr, array, index), - Expr::Match { expr, arms, .. } => self.generate_match(expr, arms), + Expr::Match { + expr: scrutinee, + arms, + .. + } => self.generate_match(expr, scrutinee, arms), Expr::ForLoop { collection, @@ -1402,6 +1499,29 @@ impl<'ctx> CodeGenerator<'ctx> { .map_err(|e| format!("Failed to load array struct: {:?}", e)) } + /// Reorder a constructor call's `fields` into the named type's DECLARATION order so + /// the lowered struct's slot order matches what `record_types` and the type oracle + /// use to index fields. Falls back to the provided order if the type's field list + /// isn't registered. (The expressions are cloned — constructor field lists are tiny.) + fn constructor_fields_in_decl_order( + &self, + type_name: &str, + fields: &[(String, Expr)], + ) -> Vec<(String, Expr)> { + let Some(decl_order) = self.named_type_fields.get(type_name) else { + return fields.to_vec(); + }; + decl_order + .iter() + .filter_map(|fname| { + fields + .iter() + .find(|(provided, _)| provided == fname) + .cloned() + }) + .collect() + } + fn generate_record( &mut self, fields: &[(String, Expr)], @@ -1617,11 +1737,13 @@ impl<'ctx> CodeGenerator<'ctx> { } // Regular record field access: resolve a pointer to the field inside the - // record's memory (shared by the in-place field-write path) and load it. - if let Some(field_ptr) = self.record_field_pointer(expr, field_name)? { + // record's memory (shared by the in-place field-write path) and load it with the + // field's declared LLVM type from the oracle (NOT a hardcoded `f64`), so a + // `Text`/array field reads back correctly. + if let Some((field_ptr, field_llvm)) = self.record_field_pointer(expr, field_name)? { return self .builder - .build_load(self.context.f64_type(), field_ptr, field_name) + .build_load(field_llvm, field_ptr, field_name) .map_err(|e| format!("Failed to load field: {:?}", e)); } @@ -1645,7 +1767,7 @@ impl<'ctx> CodeGenerator<'ctx> { return Err("Field-write target must be a field access".to_string()); }; let new_value = self.generate_expr(value)?; - let field_ptr = self + let (field_ptr, _field_llvm) = self .record_field_pointer(expr, field)? .ok_or_else(|| format!("Unknown record for field write: {}", field))?; self.builder @@ -1654,20 +1776,27 @@ impl<'ctx> CodeGenerator<'ctx> { Ok(self.unit_value().into()) } - /// Pointer to `base.field` inside the record's memory, the shared primitive for - /// both reads (`generate_field_access`) and in-place writes - /// (`generate_field_assign`). `base` must be a record-typed identifier (a - /// variable such as `u`, or the method receiver `it`); the variable's alloca - /// holds a pointer to the struct. Records are a flat struct of f64 fields (the - /// current numeric-record layout), so a field cannot itself hold a record — - /// chained paths (`a.b.c`) are rejected by the type checker before reaching - /// codegen. Returns `Ok(None)` when `base` isn't a tracked record (so the read - /// path can fall through to its Text/array `.size` handling). + /// Pointer to `base.field` inside the record's memory, plus the field's value-repr + /// LLVM type — the shared primitive for both reads (`generate_field_access`) and + /// in-place writes (`generate_field_assign`). + /// + /// `base` must be a record/named-type identifier (a variable such as `u`, or the + /// method receiver `it`); the variable's alloca holds a pointer-to-struct (the + /// record ABI). The struct's field types are recovered from the **type oracle** (the + /// record's declared field types), mapped through `value_repr_type` so the + /// reconstructed struct type matches exactly how `generate_record` laid it out — + /// `Text`/array/etc. fields keep their real type instead of being treated as `f64`. + /// The returned LLVM type is what the read site must `load` (and the write site is + /// already type-checked to match). + /// + /// Nested records (`a.b.c`) are rejected by the type checker before codegen, so a + /// single GEP level suffices. Returns `Ok(None)` when `base` isn't a tracked record + /// (so the read path can fall through to its Text/array `.size` handling). fn record_field_pointer( &mut self, base: &Expr, field: &str, - ) -> Result>, String> { + ) -> Result, BasicTypeEnum<'ctx>)>, String> { let Expr::Ident { name, .. } = base else { return Ok(None); }; @@ -1678,6 +1807,22 @@ impl<'ctx> CodeGenerator<'ctx> { return Ok(None); }; + // Reconstruct the struct field types from the oracle (the record's declared + // field types), in declared order, so the GEP type matches construction. Fall + // back to all-`f64` only if the oracle has no record type for `base` (it always + // should for a tracked record) — preserving the historical numeric layout. The + // loaded field's own LLVM type is then just the indexed slot, computed once here. + let field_types: Vec = match self.oracle.expr_type(base) { + Some(Type::Record(fields)) | Some(Type::Named { fields, .. }) => fields + .clone() + .iter() + .map(|(_, t)| self.value_repr_type(t)) + .collect::, _>>()?, + _ => vec![self.context.f64_type().into(); field_names.len()], + }; + let field_llvm = field_types[field_idx]; + let struct_type = self.context.struct_type(&field_types, false); + // The variable's alloca holds a pointer to the struct; load it. let (var_ptr, _) = self .variables @@ -1693,11 +1838,6 @@ impl<'ctx> CodeGenerator<'ctx> { .map_err(|e| format!("Failed to load struct pointer: {:?}", e))? .into_pointer_value(); - // Reconstruct the (all-f64) struct type for the GEP. - let field_types: Vec = - vec![self.context.f64_type().into(); field_names.len()]; - let struct_type = self.context.struct_type(&field_types, false); - let field_ptr = self .builder .build_struct_gep( @@ -1707,16 +1847,20 @@ impl<'ctx> CodeGenerator<'ctx> { &format!("field_{}_ptr", field), ) .map_err(|e| format!("Failed to build field GEP: {:?}", e))?; - Ok(Some(field_ptr)) + Ok(Some((field_ptr, field_llvm))) } + /// Lower an array index `array[index]`. `index_node` is the whole `Expr::Index` + /// (used to look up the element type in the oracle — the checker records an index + /// expression's type as its element type); `array` and `index_expr` are its parts. fn generate_index( &mut self, - expr: &Expr, + index_node: &Expr, + array: &Expr, index_expr: &Expr, ) -> Result, String> { // Generate the array expression - let array_val = self.generate_expr(expr)?; + let array_val = self.generate_expr(array)?; // Generate the index expression let index_val = self.generate_expr(index_expr)?; @@ -1765,33 +1909,38 @@ impl<'ctx> CodeGenerator<'ctx> { return Err("Index must be a number".to_string()); }; - // Use GEP to get element pointer - // For now, assume elements are f64 + // Element LLVM type comes from the type oracle (the index expression's type + // IS the element type), NOT from a hardcoded `f64` — so `Text`/array/record + // elements load correctly. The element memory was laid out by `generate_array` + // using this same value representation. + let elem_llvm = self.oracle_value_type(index_node)?; + + // Use GEP (indexing by element type) to get the element pointer, then load it. let elem_ptr = unsafe { self.builder - .build_gep(self.context.f64_type(), data_ptr, &[index_i64], "elem_ptr") + .build_gep(elem_llvm, data_ptr, &[index_i64], "elem_ptr") .map_err(|e| format!("Failed to build GEP: {:?}", e))? }; - // Load the element self.builder - .build_load(self.context.f64_type(), elem_ptr, "elem") + .build_load(elem_llvm, elem_ptr, "elem") .map_err(|e| format!("Failed to load element: {:?}", e)) } else { Err("Can only index into arrays".to_string()) } } + /// Lower a `match` (`scrutinee ? | pat => body ...`). `match_expr` is the whole + /// `Expr::Match` node (used only to look up the match's result type in the oracle); + /// `scrutinee` is the value being matched. fn generate_match( &mut self, - expr: &Expr, + match_expr: &Expr, + scrutinee: &Expr, arms: &[MatchArm], ) -> Result, String> { - // For now, implement a simplified version that only handles constructor patterns - // and wildcards for Option-like types - // Evaluate the expression being matched - let match_val = self.generate_expr(expr)?; + let match_val = self.generate_expr(scrutinee)?; // Get the current function let function = self @@ -1813,10 +1962,12 @@ impl<'ctx> CodeGenerator<'ctx> { } let cont_block = self.context.append_basic_block(function, "match_cont"); - // Create a phi node to collect results from all arms - // We need to determine the result type - for now assume f64 - let result_alloca = - self.create_entry_block_alloca("match_result", self.context.f64_type().into())?; + // The result type of the match (the common type of its arm bodies) comes from + // the type oracle — NOT a hardcoded `f64` — so a match yielding `Text` (e.g. the + // `Ok(text)` payload) allocates and loads a `Text` struct rather than corrupting + // it through an f64 slot. Falls back to `f64` if the oracle didn't record it. + let result_llvm = self.oracle_value_type(match_expr)?; + let result_alloca = self.create_entry_block_alloca("match_result", result_llvm)?; // Jump to first check self.builder @@ -1863,9 +2014,9 @@ impl<'ctx> CodeGenerator<'ctx> { // Position at continuation block self.builder.position_at_end(cont_block); - // Load the result + // Load the result with the match's declared result type (see `result_llvm`). self.builder - .build_load(self.context.f64_type(), result_alloca, "match_result") + .build_load(result_llvm, result_alloca, "match_result") .map_err(|e| format!("Failed to load result: {:?}", e)) } @@ -2215,6 +2366,45 @@ impl<'ctx> CodeGenerator<'ctx> { } } + /// The **value representation** of a Quilon type — the LLVM type that a value of + /// `ty` is materialized as by `generate_expr` and stored inline inside a composite. + /// Read sites that GEP/load an element/field/match-result must size it with THIS + /// function so the type matches how the value was stored at construction. It differs + /// from [`type_to_llvm`] in three places: + /// - `Array` — an array *value* is the `{ ptr, i64 }` struct `generate_array` + /// produces and stores inline (so a nested array `[][]T` keeps that struct as its + /// element), whereas `type_to_llvm` lowers `[]T` to a bare opaque pointer. + /// - `Record` / `Named` — a record *value* is a POINTER to its struct (the record + /// ABI: `generate_record` returns the alloca), not the struct by value. + /// - `Generic` — a payload type variable that survived to a read site (e.g. a match + /// whose result type was taken from a never-constructed variant's generic arm) + /// has no concrete LLVM type; it falls back to the canonical numeric payload + /// representation `f64`, matching how generic/unknown payloads are materialized + /// elsewhere (`payload_slot_type`). This keeps such a program compiling (it did + /// before the oracle existed) rather than erroring in `type_to_llvm`. + fn value_repr_type(&self, ty: &Type) -> Result, String> { + match ty { + Type::Array(_) => Ok(self.ptr_len_struct_type().into()), + Type::Record(_) | Type::Named { .. } => { + Ok(self.context.ptr_type(AddressSpace::default()).into()) + } + Type::Generic { .. } => Ok(self.context.f64_type().into()), + _ => self.type_to_llvm(ty), + } + } + + /// The value-representation LLVM type to use when GEPing/loading the result of `expr` + /// (an `arr[i]`, `rec.field`, or `match`), taken from the type oracle. This is the + /// single read-site policy: ask the oracle for `expr`'s inferred type and lower it + /// via [`value_repr_type`]; if the oracle has no entry (e.g. the IR-only codegen + /// tests that skip the type-check pass), fall back to the historical `f64`. + fn oracle_value_type(&self, expr: &Expr) -> Result, String> { + match self.oracle.expr_type(expr) { + Some(t) => self.value_repr_type(t), + None => Ok(self.context.f64_type().into()), + } + } + fn type_to_llvm(&self, ty: &Type) -> Result, String> { match ty { Type::Num => Ok(self.context.f64_type().into()), diff --git a/src/jit.rs b/src/jit.rs index da28a1f..0a5a770 100644 --- a/src/jit.rs +++ b/src/jit.rs @@ -29,7 +29,9 @@ pub fn run_program(program: &Program) -> Result { .map_err(|e| format!("Failed to initialize native target: {}", e))?; let context = Context::create(); - let mut generator = CodeGenerator::new(&context, "main"); + // Build the generator with the type oracle installed (so read sites recover precise + // element/field/match-result types instead of assuming f64). + let mut generator = CodeGenerator::with_oracle(&context, "main", program)?; // Populate, verify, and emit the module (also builds the `main` wrapper). generator.generate(program)?; diff --git a/src/lexer/token.rs b/src/lexer/token.rs index 5b3a748..0a7f321 100644 --- a/src/lexer/token.rs +++ b/src/lexer/token.rs @@ -4,7 +4,7 @@ use logos::Logos; use std::fmt; /// Source code position span -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Span { pub start: usize, pub end: usize, diff --git a/src/main.rs b/src/main.rs index 4dc4b81..25abdd1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -105,7 +105,14 @@ fn main() { // Generate LLVM IR use inkwell::context::Context; let context = Context::create(); - let mut generator = codegen::CodeGenerator::new(&context, "main"); + let mut generator = + match codegen::CodeGenerator::with_oracle(&context, "main", &program) { + Ok(g) => g, + Err(e) => { + eprintln!("❌ Code generation error: {}", e); + std::process::exit(1); + } + }; let ir = match generator.generate(&program) { Ok(ir) => ir, diff --git a/src/typechecker/checker.rs b/src/typechecker/checker.rs index 0f5f0d0..b4c52da 100644 --- a/src/typechecker/checker.rs +++ b/src/typechecker/checker.rs @@ -237,6 +237,15 @@ impl Environment { /// A method's signature and body: (params, return type, body expression). type MethodDef = (Vec, Option, Expr); +/// The **type oracle**: a side-table mapping each expression's source `Span` to the +/// `Type` the checker inferred for it. Produced by `check_program` and consumed by +/// codegen so that READ sites (array indexing, record-field access, match-arm results) +/// recover the *declared* element / field / result type instead of guessing `f64` from +/// a runtime LLVM value. Spans are unique per expression (every AST node carries its +/// own source span), so they make a stable, AST-agnostic key. See the consumer-side +/// wrapper `codegen::TypeOracle`. +pub type TypeTable = std::collections::HashMap; + pub struct TypeChecker { env: Environment, // Registry of methods: (TypeName, MethodName) -> method definition @@ -247,6 +256,9 @@ pub struct TypeChecker { // body containing `it.field := …` (or a call to another setter on `it`). // Calling such a method requires a `:=`-bound (mutable) receiver. setter_methods: std::collections::HashSet<(String, String)>, + // The type oracle (see `TypeTable`): every inferred expression type, keyed by span, + // populated as a side effect of `infer_expr` and returned by `check_program`. + type_table: TypeTable, } impl Default for TypeChecker { @@ -262,6 +274,7 @@ impl TypeChecker { methods: std::collections::HashMap::new(), sum_types: std::collections::HashMap::new(), setter_methods: std::collections::HashSet::new(), + type_table: TypeTable::new(), }; // Add built-in sum types to the environment @@ -342,11 +355,43 @@ impl TypeChecker { span: span.clone(), }); } + let mut arg_types = Vec::with_capacity(args.len()); for (field_type, arg) in field_types.iter().zip(args.iter()) { let arg_type = self.infer_expr(arg)?; self.check_type_compatibility(field_type, &arg_type, span)?; + arg_types.push(arg_type); + } + + // For a sum type with GENERIC payload positions (the built-in `Result`'s + // `Ok(T)` / `NotOk(E)`), specialize the constructed variant's generic fields to + // the concrete argument types. This lets `Ok("x")` carry `Text` (not the opaque + // `T`), so a later `match` binds the payload at its real type and `.length` / + // field access on it type-check — the front-end half of making `Ok(text)` / + // `NotOk(text)` round-trip (codegen already preserves the payload's LLVM type). + // Non-generic field types (user sum types, already concrete) pass through. + let specialized = Self::specialize_variant(&sum_type, variant, &arg_types); + Ok(Some(specialized)) + } + + /// Return `sum_type` with the `variant`'s generic payload fields replaced by the + /// corresponding concrete `arg_types`. Only `Type::Generic` fields are substituted; + /// already-concrete fields are left as declared, and other variants are untouched. + /// Clones the sum type once and mutates only the matched variant's generic fields in + /// place, rather than rebuilding every (mostly unchanged) sibling variant. + fn specialize_variant(sum_type: &Type, variant: &str, arg_types: &[Type]) -> Type { + let mut specialized = sum_type.clone(); + if let Type::Sum { variants, .. } = &mut specialized + && let Some(v) = variants.iter_mut().find(|v| v.name == variant) + { + for (i, field) in v.fields.iter_mut().enumerate() { + if matches!(field, Type::Generic { .. }) + && let Some(arg) = arg_types.get(i) + { + *field = arg.clone(); + } + } } - Ok(Some(sum_type)) + specialized } /// If `variant` names a constructor of some registered sum type, return that @@ -377,11 +422,15 @@ impl TypeChecker { } } - pub fn check_program(&mut self, program: &Program) -> Result<(), TypeError> { + /// Type-check `program` and, on success, return the **type oracle** (`TypeTable`): + /// every expression's inferred type keyed by its source span. Codegen consumes this + /// to recover precise element/field/match-result types at read sites. The table is + /// taken (moved) out of the checker, so a checker is single-use per program. + pub fn check_program(&mut self, program: &Program) -> Result { for item in &program.items { self.check_item(item)?; } - Ok(()) + Ok(std::mem::take(&mut self.type_table)) } fn check_item(&mut self, item: &Item) -> Result<(), TypeError> { @@ -770,7 +819,19 @@ impl TypeChecker { Ok(()) } + /// Infer an expression's type, **recording it in the type oracle** (`type_table`) + /// keyed by the expression's source span. This is the public inference entry point; + /// the per-node logic lives in `infer_expr_inner`. The recorded side-table is what + /// `check_program` returns and codegen consults to recover the precise element / + /// field / match-result types it would otherwise lose at read sites (see + /// `TypeOracle` in codegen). Only successfully-typed expressions are recorded. fn infer_expr(&mut self, expr: &Expr) -> Result { + let ty = self.infer_expr_inner(expr)?; + self.type_table.insert(expr.span().clone(), ty.clone()); + Ok(ty) + } + + fn infer_expr_inner(&mut self, expr: &Expr) -> Result { match expr { Expr::Number { .. } => Ok(Type::Num), Expr::String { .. } => Ok(Type::Text), @@ -1366,11 +1427,22 @@ impl TypeChecker { self.env.pop_scope(); - // All arms must return same type - if let Some(ref expected_type) = result_type { - self.check_type_compatibility(expected_type, &body_type, &arm.span)?; - } else { - result_type = Some(body_type); + // All arms must agree (compatibly). Prefer the most concrete arm type as the + // result: an arm binding an un-specialized payload (`Generic`, e.g. a + // never-constructed `NotOk(e) => e`) must not make the whole match's result + // type generic when another arm yields a concrete type — codegen needs a + // concrete result type to size the match value. So when the running result is + // `Generic` and this arm is concrete, upgrade to the concrete type. + match result_type { + Some(ref expected_type) => { + self.check_type_compatibility(expected_type, &body_type, &arm.span)?; + if matches!(expected_type, Type::Generic { .. }) + && !matches!(body_type, Type::Generic { .. }) + { + result_type = Some(body_type); + } + } + None => result_type = Some(body_type), } } @@ -1502,23 +1574,53 @@ impl TypeChecker { got: &Type, span: &Span, ) -> Result<(), TypeError> { - // Allow any type to match a generic type parameter - match expected { - Type::Generic { .. } => Ok(()), - _ => match got { - Type::Generic { .. } => Ok(()), - _ => { - if expected == got { - Ok(()) - } else { - Err(TypeError::TypeMismatch { - expected: Box::new(expected.clone()), - got: Box::new(got.clone()), - span: span.clone(), - }) - } - } - }, + if Self::types_compatible(expected, got) { + Ok(()) + } else { + Err(TypeError::TypeMismatch { + expected: Box::new(expected.clone()), + got: Box::new(got.clone()), + span: span.clone(), + }) + } + } + + /// Structural type compatibility with a `Generic` wildcard. A `Generic` on either + /// side matches anything (no real type variables yet). For sum types this recurses + /// into the variants so that the SAME sum type carrying a specialized payload in one + /// value and a generic/`$` payload in another (e.g. `Ok("x")` vs `Ok($)`, both + /// `Result`) stays compatible — the constructor result is specialized to the actual + /// payload type (see `specialize_variant`) purely so a match can bind the payload at + /// its real type; that specialization must NOT make two `Result` values incompatible. + fn types_compatible(a: &Type, b: &Type) -> bool { + match (a, b) { + // A generic stands in for any type (forward-compat with future generics). + (Type::Generic { .. }, _) | (_, Type::Generic { .. }) => true, + ( + Type::Sum { + name: n1, + variants: v1, + }, + Type::Sum { + name: n2, + variants: v2, + }, + ) => { + // Same sum type (by name) with structurally-compatible variants: same + // variant names in order, payload fields pairwise compatible. + n1 == n2 + && v1.len() == v2.len() + && v1.iter().zip(v2).all(|(x, y)| { + x.name == y.name + && x.fields.len() == y.fields.len() + && x.fields + .iter() + .zip(&y.fields) + .all(|(fa, fb)| Self::types_compatible(fa, fb)) + }) + } + (Type::Array(e1), Type::Array(e2)) => Self::types_compatible(e1, e2), + _ => a == b, } } } diff --git a/src/typechecker/mod.rs b/src/typechecker/mod.rs index 937ecd5..ace42dc 100644 --- a/src/typechecker/mod.rs +++ b/src/typechecker/mod.rs @@ -3,4 +3,4 @@ pub mod checker; pub mod inference; -pub use checker::TypeChecker; +pub use checker::{TypeChecker, TypeTable}; diff --git a/tests/composite_text_test.rs b/tests/composite_text_test.rs new file mode 100644 index 0000000..68e4305 --- /dev/null +++ b/tests/composite_text_test.rs @@ -0,0 +1,224 @@ +// Text (and other non-`Num` values) nested inside composites must round-trip +// through codegen without f64 corruption. Codegen recovers each element/field/ +// match-result type from the type-oracle side-table (see `typechecker::TypeTable` +// and `codegen::TypeOracle`) instead of assuming `f64` at READ sites. +// +// These are execution tests: full pipeline (lex -> parse -> typecheck -> codegen -> +// JIT) asserting the real exit code, so a corrupted value would surface as a wrong +// (often garbage) exit status. + +use quilon::jit; +use quilon::lexer::Lexer; +use quilon::parser; +use quilon::typechecker::TypeChecker; +use std::sync::Mutex; + +// LLVM JIT / target init isn't thread-safe; serialize across cargo's parallel tests. +static JIT_LOCK: Mutex<()> = Mutex::new(()); + +fn assert_exit(src: &str, expected: i32) { + let _guard = JIT_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let tokens = Lexer::tokenize(src).expect("lexing failed"); + let program = parser::parse(&tokens).expect("parsing failed"); + TypeChecker::new() + .check_program(&program) + .expect("type checking failed"); + let code = jit::run_program(&program).expect("execution failed"); + assert_eq!(code, expected, "unexpected exit code for source:\n{src}"); +} + +// --- Text field inside a record ------------------------------------------------- + +#[test] +fn record_text_field_reads_back_as_text() { + // `.name` is a `Text` field; reading it then taking `.length` must see a real + // Text struct, not an f64 reinterpretation. "Quilon" -> 6 graphemes. + assert_exit( + r#" + ^ = () -> Num => < + user = { name = "Quilon", n = 7 } + user.name.length + > + "#, + 6, + ); +} + +#[test] +fn record_mixed_text_and_num_fields() { + // A record mixing a `Text` field and a `Num` field: both read back correctly, + // and the numeric field isn't shifted by the Text field's wider layout. + // "ab".size (2) + 40 = 42. + assert_exit( + r#" + ^ = () -> Num => < + r = { label = "ab", count = 40 } + r.label.size + r.count + > + "#, + 42, + ); +} + +// --- Array of Text -------------------------------------------------------------- + +#[test] +fn array_of_text_indexes_to_text() { + // `[]Text`: indexing yields a `Text` value (not f64). "cde".size = 3. + assert_exit( + r#" + ^ = () -> Num => < + words = ["a", "cde"] + words[1].size + > + "#, + 3, + ); +} + +#[test] +fn array_of_text_iterated() { + // Indexing both elements and summing their byte lengths: "ab"(2)+"cdef"(4)=6. + assert_exit( + r#" + ^ = () -> Num => < + words = ["ab", "cdef"] + words[0].size + words[1].size + > + "#, + 6, + ); +} + +// --- Nested arrays -------------------------------------------------------------- + +#[test] +fn nested_array_double_index() { + // `[][]Num`: the outer element is itself an array struct; double-indexing must + // load the inner array struct first, then the Num. grid[1][0] = 3. + assert_exit( + r#" + ^ = () -> Num => < + grid = [[1, 2], [3, 4]] + grid[1][0] + > + "#, + 3, + ); +} + +#[test] +fn nested_array_sum_of_cells() { + // Several cells from a nested array: 1 + 4 + 6 = 11. + assert_exit( + r#" + ^ = () -> Num => < + grid = [[1, 2], [3, 4], [5, 6]] + grid[0][0] + grid[1][1] + grid[2][1] + > + "#, + 11, + ); +} + +// --- Text as a sum-type payload (Ok/NotOk) -------------------------------------- + +#[test] +fn result_ok_text_payload_round_trips() { + // `Ok("...")`: the Text payload survives construction AND the match-arm result + // alloca/load (no f64 corruption of the match result). "hello".length = 5. + assert_exit( + r#" + ^ = () -> Num => < + r = Ok("hello") + r ? | Ok(x) => x.length | NotOk(e) => 0 + > + "#, + 5, + ); +} + +#[test] +fn result_notok_text_payload_round_trips() { + // `NotOk("...")` with a Text payload. "boom!".size = 5. + assert_exit( + r#" + ^ = () -> Num => < + r = NotOk("boom!") + r ? | Ok(x) => 0 | NotOk(e) => e.size + > + "#, + 5, + ); +} + +#[test] +fn user_sum_type_text_payload_round_trips() { + // A user-defined sum type with `Text` payloads in both variants; matching binds + // the payload at its real type. "hi there".length = 8. + assert_exit( + r#" + Msg = Hello(Text) / Bye(Text) + ^ = () -> Num => < + m = Hello("hi there") + m ? | Hello(t) => t.length | Bye(t) => t.length + > + "#, + 8, + ); +} + +#[test] +fn match_result_type_from_unconstructed_generic_arm_compiles() { + // Regression: the match's result type is taken from the FIRST arm (`NotOk(e) => e`), + // whose payload `e` stays an un-specialized `Generic` (NotOk is never constructed + // here). The oracle records the match result as `Generic`; codegen must fall back to + // the numeric (f64) representation rather than erroring on an unlowerable type. + // Ok(7) -> the Ok arm runs -> 7. + assert_exit( + r#" + ^ = () -> Num => < + r = Ok(7) + r ? | NotOk(e) => e | Ok(x) => x + > + "#, + 7, + ); +} + +#[test] +fn named_constructor_fields_out_of_declaration_order() { + // Regression: a named-type constructor may list fields in any order; the lowered + // struct slots must follow DECLARATION order (what field reads GEP against). With a + // mixed Text+Num record and the call order reversed, a wrong slot order would read + // the Text field as a Num (or vice versa). "ab".size (2) + 40 = 42. + assert_exit( + r#" + User = { + name :: Text, + age :: Num + } + ^ = () -> Num => < + u = User { age = 40, name = "ab" } + u.name.size + u.age + > + "#, + 42, + ); +} + +#[test] +fn match_returning_text_then_measured() { + // The match itself yields `Text` (both arms return a Text), measured afterward. + // Picks "longer" (6 graphemes). + assert_exit( + r#" + ^ = () -> Num => < + r = Ok("longer") + s = r ? | Ok(x) => x | NotOk(e) => e + s.length + > + "#, + 6, + ); +} diff --git a/tests/examples_test.rs b/tests/examples_test.rs index fec7662..6ad3dd8 100644 --- a/tests/examples_test.rs +++ b/tests/examples_test.rs @@ -49,6 +49,7 @@ const EXPECTED_EXIT: &[(&str, i32)] = &[ ("text.ql", 7), ("io.ql", 0), ("records.ql", 28), + ("composites.ql", 12), ("methods.ql", 35), ("mutation.ql", 42), ("result.ql", 84), diff --git a/tests/module_test.rs b/tests/module_test.rs index 25ac325..04b3526 100644 --- a/tests/module_test.rs +++ b/tests/module_test.rs @@ -19,6 +19,7 @@ fn check_with_base(source: &str, base_dir: &Path) -> Result<(), String> { let mut checker = TypeChecker::new(); checker .check_program(&linked) + .map(|_| ()) .map_err(|e| format!("type: {}", e)) }