diff --git a/LANGUAGE.md b/LANGUAGE.md index b2b01ff..bd34c30 100644 --- a/LANGUAGE.md +++ b/LANGUAGE.md @@ -22,6 +22,7 @@ Quilon is a statically-typed, **symbol-based** language (no control-flow keyword | `>>` | Export an item from a module | `>> add = (a, b) => a + b` | | `\|>` | Pipe (first-arg injection) | `x \|> f(a)` ≡ `f(x, a)` | | `for n <- xs => body` | Loop over a collection | `for n <- [1,2,3] => print(n)` | +| `<-` (infix) | Inclusive range → `[]Num` | `1 <- 4` ≡ `[1,2,3,4]` · `4 <- 1` ≡ `[4,3,2,1]` | | `?` `\|` `_` | Pattern match | `v ? \| 0 => "zero" \| _ => "other"` | | `/` | Division **or** sum-type variant separator | `a / b` · `Color = Red / Green` | | `? :` | Ternary | `x < 0 ? -x : x` | @@ -333,6 +334,28 @@ for (val, i) <- xs => print(i) ~ with index ``` The body may be a single expression or a `< >` block. (See `examples/for_loop.ql`.) +### Ranges — infix `lo <- hi` +The infix `<-` operator builds an **inclusive** `[]Num`: +```quilon +1 <- 4 ~ [1, 2, 3, 4] +4 <- 1 ~ [4, 3, 2, 1] (descends when the left end is larger) +5 <- 5 ~ [5] (single point) +``` +It is pure **array sugar** — there is no distinct `Range` type; the result *is* a +`[]Num`, so it composes with `.size`, indexing `[i]`, and `for` loops: +```quilon +r = 2 <- 5 ~ [2, 3, 4, 5] +n = r.size ~ 4 (inclusive count = |hi - lo| + 1) +first = r[0] ~ 2 +for x <- 1 <- 3 => print(x) ~ a range drives a loop like any array +``` +Both ends are full `Num` expressions (they may be dynamic, not just literals); the +direction (ascending vs descending) is decided at runtime. (See `examples/ranges.ql`.) + +> Note: the infix range `<-` is distinct from the `for` header's `<-` +> (`for n <- collection => …`). The `for` form is the loop binder; the infix form, +> *between two value expressions*, is the range constructor. + --- ## Pattern matching @@ -471,6 +494,7 @@ message instead. Any compile error exits with status 1. | Functions, recursion, blocks, type inference | ✅ | | Pipe `\|>` (first-arg injection) | ✅ | | `for n <- collection => body` loops | ✅ | +| Ranges: infix `lo <- hi` → inclusive `[]Num` (descends when `lo > hi`) | ✅ | | 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`) | ✅ | diff --git a/examples/ranges.ql b/examples/ranges.ql new file mode 100644 index 0000000..5738ead --- /dev/null +++ b/examples/ranges.ql @@ -0,0 +1,25 @@ +~ Ranges: infix `<-` builds an inclusive `[]Num`. It is array sugar — the result +~ IS a `[]Num`, so it has `.size`, indexes with `[i]`, and iterates with `for`. +~ `1 <- 4` -> [1, 2, 3, 4] (inclusive endpoints) +~ `4 <- 1` -> [4, 3, 2, 1] (descends when the left end is larger) +<< core.io + +^ = () -> Num => < + asc = 1 <- 4 ~ [1, 2, 3, 4] + desc = 4 <- 1 ~ [4, 3, 2, 1] + + count = asc.size ~ 4 (inclusive count = |hi - lo| + 1) + + ~ Ascending: first endpoint is the small end, last is the large end. + lo = asc[0] ~ 1 + hi = asc[3] ~ 4 + + ~ Descending: the order is reversed — desc[0] is the larger end. + top = desc[0] ~ 4 + bottom = desc[3] ~ 1 + + ~ A range also drives a `for` loop, since it's just a `[]Num`. + for n <- asc => print(n) ~ prints 1, 2, 3, 4 + + count + lo + hi + top + bottom ~ 4 + 1 + 4 + 4 + 1 = exit 14 +> diff --git a/src/ast/nodes.rs b/src/ast/nodes.rs index 0a9ca0d..6c6eab9 100644 --- a/src/ast/nodes.rs +++ b/src/ast/nodes.rs @@ -253,6 +253,17 @@ pub enum Expr { body: Box, span: Span, }, + + // Inclusive range `lo <- hi`: materialized `[]Num` sugar. `1 <- 4` is + // `[1, 2, 3, 4]`; when `lo > hi` it descends (`4 <- 1` is `[4, 3, 2, 1]`). + // There is no distinct Range type — the result IS a `[]Num`, so it composes + // with array ops / `.size` / indexing. (The infix `<-`; the `for` header's + // `<-` is parsed separately and never produces this node.) + Range { + start: Box, + end: Box, + span: Span, + }, } impl Expr { @@ -278,6 +289,7 @@ impl Expr { Expr::Constructor { span, .. } => span, Expr::SumConstructor { span, .. } => span, Expr::ForLoop { span, .. } => span, + Expr::Range { span, .. } => span, } } diff --git a/src/codegen/generator.rs b/src/codegen/generator.rs index fc0320e..a9658db 100644 --- a/src/codegen/generator.rs +++ b/src/codegen/generator.rs @@ -821,6 +821,8 @@ impl<'ctx> CodeGenerator<'ctx> { Expr::Match { expr, arms, .. } => self.generate_match(expr, arms), + Expr::Range { start, end, .. } => self.generate_range(start, end), + Expr::ForLoop { collection, pattern, @@ -1667,6 +1669,167 @@ impl<'ctx> CodeGenerator<'ctx> { .map_err(|e| format!("Failed to load array struct: {:?}", e)) } + /// Materialize an inclusive range `lo <- hi` into a `[]Num` (the `{ptr, size}` + /// array shape, same as `generate_array`). The element count is `|hi - lo| + 1` + /// and the direction (ascending vs descending) is decided at runtime, since the + /// ends can be dynamic: `lo <= hi` counts up (`1 <- 4` → `[1,2,3,4]`), otherwise + /// down (`4 <- 1` → `[4,3,2,1]`). The backing storage is GC-allocated (`__alloc`) + /// so the array may safely escape the current frame. + fn generate_range(&mut self, start: &Expr, end: &Expr) -> Result, String> { + let function = self + .current_function + .ok_or_else(|| "Range must be in a function".to_string())?; + + let i64_type = self.context.i64_type(); + let f64_type = self.context.f64_type(); + + // Evaluate both ends (Num = f64) and truncate to i64 endpoints. + let lo_f = self.generate_expr(start)?.into_float_value(); + let hi_f = self.generate_expr(end)?.into_float_value(); + let lo = self + .builder + .build_float_to_signed_int(lo_f, i64_type, "range_lo") + .map_err(|e| format!("Failed to convert range start: {:?}", e))?; + let hi = self + .builder + .build_float_to_signed_int(hi_f, i64_type, "range_hi") + .map_err(|e| format!("Failed to convert range end: {:?}", e))?; + + // Ascending iff lo <= hi; pick step = +1 / -1 and the inclusive span. + let ascending = self + .builder + .build_int_compare(inkwell::IntPredicate::SLE, lo, hi, "range_asc") + .map_err(|e| format!("Failed to compare range ends: {:?}", e))?; + let one = i64_type.const_int(1, false); + let neg_one = i64_type.const_all_ones(); // -1 in two's complement + let step = self + .builder + .build_select(ascending, one, neg_one, "range_step") + .map_err(|e| format!("Failed to select range step: {:?}", e))? + .into_int_value(); + // |hi - lo| + 1: compute the signed delta once, then pick it or its + // negation so the span is non-negative in either direction. + let delta = self + .builder + .build_int_sub(hi, lo, "range_delta") + .map_err(|e| format!("Failed to subtract range ends: {:?}", e))?; + let neg_delta = self + .builder + .build_int_neg(delta, "range_neg_delta") + .map_err(|e| format!("Failed to negate range delta: {:?}", e))?; + let span_abs = self + .builder + .build_select(ascending, delta, neg_delta, "range_span") + .map_err(|e| format!("Failed to select range span: {:?}", e))? + .into_int_value(); + let count = self + .builder + .build_int_add(span_abs, one, "range_count") + .map_err(|e| format!("Failed to add range count: {:?}", e))?; + + // GC-allocate count * sizeof(f64) bytes for the backing data. + let eight = i64_type.const_int(8, false); + let bytes = self + .builder + .build_int_mul(count, eight, "range_bytes") + .map_err(|e| format!("Failed to size range alloc: {:?}", e))?; + let alloc = self.get_intrinsic("__alloc")?; + let alloc_call = self + .builder + .build_call(alloc, &[bytes.into()], "range_data") + .map_err(|e| format!("Failed to allocate range: {:?}", e))?; + let data_ptr = { + use inkwell::values::AnyValue; + alloc_call.as_any_value_enum().into_pointer_value() + }; + + // Fill loop: for i in 0..count: data[i] = (f64)(lo + i*step). + let counter = self.create_entry_block_alloca("range_i", i64_type.into())?; + self.builder + .build_store(counter, i64_type.const_zero()) + .map_err(|e| format!("Failed to init range counter: {:?}", e))?; + + let header = self.context.append_basic_block(function, "range_header"); + let body = self.context.append_basic_block(function, "range_body"); + let exit = self.context.append_basic_block(function, "range_exit"); + + self.builder + .build_unconditional_branch(header) + .map_err(|e| format!("Failed to branch to range header: {:?}", e))?; + + self.builder.position_at_end(header); + let i = self + .builder + .build_load(i64_type, counter, "i") + .map_err(|e| format!("Failed to load range counter: {:?}", e))? + .into_int_value(); + let cond = self + .builder + .build_int_compare(inkwell::IntPredicate::SLT, i, count, "range_cond") + .map_err(|e| format!("Failed to build range condition: {:?}", e))?; + self.builder + .build_conditional_branch(cond, body, exit) + .map_err(|e| format!("Failed to build range branch: {:?}", e))?; + + self.builder.position_at_end(body); + // value = lo + i*step + let i_step = self + .builder + .build_int_mul(i, step, "range_i_step") + .map_err(|e| format!("Failed to scale range index: {:?}", e))?; + let val_i = self + .builder + .build_int_add(lo, i_step, "range_val_i") + .map_err(|e| format!("Failed to compute range element: {:?}", e))?; + let val_f = self + .builder + .build_signed_int_to_float(val_i, f64_type, "range_val") + .map_err(|e| format!("Failed to convert range element: {:?}", e))?; + let elem_ptr = unsafe { + self.builder + .build_gep(f64_type, data_ptr, &[i], "range_elem") + .map_err(|e| format!("Failed to index range data: {:?}", e))? + }; + self.builder + .build_store(elem_ptr, val_f) + .map_err(|e| format!("Failed to store range element: {:?}", e))?; + let next = self + .builder + .build_int_add(i, one, "range_next") + .map_err(|e| format!("Failed to increment range counter: {:?}", e))?; + self.builder + .build_store(counter, next) + .map_err(|e| format!("Failed to store range counter: {:?}", e))?; + self.builder + .build_unconditional_branch(header) + .map_err(|e| format!("Failed to loop range: {:?}", e))?; + + // Build the { ptr, size } array struct (the shared array/Text shape). + self.builder.position_at_end(exit); + let array_struct_type = self.ptr_len_struct_type(); + let array_struct = self + .builder + .build_alloca(array_struct_type, "range_array") + .map_err(|e| format!("Failed to allocate range struct: {:?}", e))?; + let ptr_field = self + .builder + .build_struct_gep(array_struct_type, array_struct, 0, "range_ptr_field") + .map_err(|e| format!("Failed to get range ptr field: {:?}", e))?; + self.builder + .build_store(ptr_field, data_ptr) + .map_err(|e| format!("Failed to store range ptr: {:?}", e))?; + let size_field = self + .builder + .build_struct_gep(array_struct_type, array_struct, 1, "range_size_field") + .map_err(|e| format!("Failed to get range size field: {:?}", e))?; + self.builder + .build_store(size_field, count) + .map_err(|e| format!("Failed to store range size: {:?}", e))?; + self.builder + .build_load(array_struct_type, array_struct, "range_array") + .map_err(|e| format!("Failed to load range struct: {:?}", e)) + } + fn generate_record( &mut self, fields: &[(String, Expr)], diff --git a/src/parser/ast_parser.rs b/src/parser/ast_parser.rs index a401607..017397b 100644 --- a/src/parser/ast_parser.rs +++ b/src/parser/ast_parser.rs @@ -871,10 +871,10 @@ impl<'a> Parser<'a> { } fn parse_comparison(&mut self) -> Result { - let mut left = self.parse_pipeline()?; + let mut left = self.parse_range()?; while let Some(op) = self.match_comparison() { - let right = self.parse_pipeline()?; + let right = self.parse_range()?; let span = Span::new(left.span().start, right.span().end); left = Expr::BinOp { left: Box::new(left), @@ -887,6 +887,27 @@ impl<'a> Parser<'a> { Ok(left) } + /// Infix range `lo <- hi` → inclusive `[]Num` (see the `Expr::Range` node). + /// Non-associative: consumes at most one `<-`, so `a <- b <- c` is rejected. + /// Only general expression position reaches here; the `for` header consumes its + /// own `<-` in `parse_for_loop`, so `for n <- coll` never parses as a range. + fn parse_range(&mut self) -> Result { + let left = self.parse_pipeline()?; + + if self.check(&TokenKind::LeftArrow) { + self.advance(); // consume `<-` + let right = self.parse_pipeline()?; + let span = Span::new(left.span().start, right.span().end); + return Ok(Expr::Range { + start: Box::new(left), + end: Box::new(right), + span, + }); + } + + Ok(left) + } + fn parse_pipeline(&mut self) -> Result { let mut left = self.parse_additive()?; @@ -1948,6 +1969,39 @@ mod tests { } } + #[test] + fn test_parse_infix_range() { + // `1 <- 4` in general expression position parses as an Expr::Range, + // NOT a for-loop (no `for` keyword precedes it). + let tokens = Lexer::tokenize("r = 1 <- 4").unwrap(); + let program = parse(&tokens).expect("range should parse"); + if let Item::VarDecl(v) = &program.items[0] { + assert!( + matches!(v.value, Expr::Range { .. }), + "expected Expr::Range, got {:?}", + v.value + ); + } else { + panic!("expected a var decl"); + } + } + + #[test] + fn test_infix_range_does_not_capture_for_header() { + // CRITICAL coexistence: the `for` header's `<-` must still parse as a + // for-loop, never as an infix range. `for n <- [1,2,3]` is a ForLoop. + let tokens = Lexer::tokenize("test = => for n <- [1, 2, 3] => print(n)").unwrap(); + let program = parse(&tokens).expect("for loop should still parse"); + if let Item::FunctionDecl(func) = &program.items[0] { + assert!( + matches!(func.body, Expr::ForLoop { .. }), + "for header must parse as ForLoop, not Range" + ); + } else { + panic!("expected a function decl"); + } + } + #[test] fn test_parse_nested_for_loops_with_blocks() { let tokens = diff --git a/src/typechecker/checker.rs b/src/typechecker/checker.rs index 902cb2a..97e203a 100644 --- a/src/typechecker/checker.rs +++ b/src/typechecker/checker.rs @@ -1531,6 +1531,15 @@ impl TypeChecker { } } + Expr::Range { start, end, span } => { + // `lo <- hi` materializes an inclusive `[]Num`; both ends must be Num. + let start_type = self.infer_expr(start)?; + self.check_type_compatibility(&Type::Num, &start_type, span)?; + let end_type = self.infer_expr(end)?; + self.check_type_compatibility(&Type::Num, &end_type, span)?; + Ok(Type::Array(Box::new(Type::Num))) + } + Expr::ForLoop { collection, pattern, diff --git a/tests/examples_test.rs b/tests/examples_test.rs index 15ff876..b7c3321 100644 --- a/tests/examples_test.rs +++ b/tests/examples_test.rs @@ -45,6 +45,7 @@ const EXPECTED_EXIT: &[(&str, i32)] = &[ ("pattern_match.ql", 50), ("arrays.ql", 5), ("for_loop.ql", 0), + ("ranges.ql", 14), ("pipeline.ql", 25), ("text.ql", 7), ("io.ql", 0), diff --git a/tests/ranges_test.rs b/tests/ranges_test.rs new file mode 100644 index 0000000..2d11006 --- /dev/null +++ b/tests/ranges_test.rs @@ -0,0 +1,97 @@ +// Ranges: the infix `<-` operator builds an inclusive `[]Num`. +// `1 <- 4` -> [1, 2, 3, 4] (inclusive) +// `4 <- 1` -> [4, 3, 2, 1] (descends when the left end is larger) +// It is array sugar — no distinct Range type — so the result composes with +// `.size`, indexing, and `for`. These tests drive the full pipeline (lex -> +// parse -> typecheck -> codegen -> JIT) and assert the real exit code. + +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; cargo runs tests in parallel. +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"); + let mut checker = TypeChecker::new(); + checker + .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); +} + +/// `(1 <- 4).size == 4` — an inclusive range has `|hi - lo| + 1` elements. +/// (`.size` needs a named receiver in 0.9, so bind the range first.) +#[test] +fn range_size_is_inclusive() { + assert_exit("^ = () -> Num => <\n r = 1 <- 4\n r.size\n>", 4); +} + +/// A single-point range `5 <- 5` is `[5]` — size 1. +#[test] +fn range_single_point_has_size_one() { + assert_exit("^ = () -> Num => <\n r = 5 <- 5\n r.size\n>", 1); +} + +/// Ascending `1 <- 4` is `[1, 2, 3, 4]`: summing the four endpoints by index +/// gives 1 + 2 + 3 + 4 = 10. (Index-summed, not loop-accumulated, so the test +/// is independent of mutable-accumulator behavior.) +#[test] +fn ascending_range_values_in_order() { + assert_exit( + "^ = () -> Num => <\n r = 1 <- 4\n r[0] + r[1] + r[2] + r[3]\n>", + 10, + ); +} + +/// Descending `4 <- 1` is `[4, 3, 2, 1]`: the first element is the LARGER end. +/// Encode the order as 1000*r[0] + 100*r[1] + 10*r[2] + r[3] = 4321. +#[test] +fn descending_range_is_reversed() { + assert_exit( + "^ = () -> Num => <\n r = 4 <- 1\n 1000*r[0] + 100*r[1] + 10*r[2] + r[3]\n>", + 4321, + ); +} + +/// Range ends can be dynamic (not just literals): `a <- b` with bound `a`/`b` +/// still materializes correctly, and chooses direction at runtime. +#[test] +fn range_with_dynamic_ends() { + assert_exit( + "^ = () -> Num => <\n a = 2\n b = 5\n r = a <- b\n r.size + r[0] + r[3]\n>", + // [2,3,4,5]: size 4 + first 2 + last 5 = 11 + 11, + ); +} + +/// A range is just a `[]Num`, so it drives a `for` loop like any array. +#[test] +fn range_drives_for_loop() { + // for over [1,2,3] yields Num 0 (loop result), then return the size to prove + // the range materialized. + assert_exit( + "^ = () -> Num => <\n r = 1 <- 3\n for n <- r => n\n r.size\n>", + 3, + ); +} + +/// CRITICAL coexistence: the new infix `<-` must NOT break the `for` header's +/// own `<-`. `for n <- [...]` must still parse, type-check, and run end-to-end +/// exactly as before. (Parse-shape coexistence — that the header parses as a +/// `ForLoop`, not a `Range` — is asserted separately in the parser unit tests.) +#[test] +fn for_loop_over_literal_array_still_runs() { + assert_exit( + "^ = () -> Num => <\n xs = [10, 20, 30]\n for n <- xs => n\n xs.size\n>", + 3, + ); +}