From 8b9d08806da555f7f58d9ba43d05dec39b4c4a49 Mon Sep 17 00:00:00 2001 From: Taylor Holliday Date: Sun, 18 Oct 2026 21:14:11 -0700 Subject: [PATCH] Add u8 type, i8 comparisons, and byte casts Add `u8` as a source-level type. The internal `UInt8` type already existed, so this wires it through the lexer and parser and adds casts between u8 and i32/u32 on every backend. Like i8, u8 has no arithmetic and plain integer literals still infer as i32, so values are made with `as u8`. Add ordered comparisons for i8 and casts between i8 and u8/u32. Fix a register VM bug this exposed: Load8 zero-extends but the i32->i8 cast sign-extends, so a negative i8 read from memory never compared equal to a cast one. Loads of i8 now sign-extend afterwards. i8 is held sign-extended and u8 zero-extended on the register VM. The stack VM's Load8 sign-extends, so it keeps both byte types sign-extended and masks u8 when widening. Co-Authored-By: Claude Fable 5.1 --- docs/GRAMMAR.md | 4 +- docs/language-guide.md | 6 ++- src/checker.rs | 12 +++++ src/jit.rs | 12 ++++- src/lexer.rs | 3 ++ src/llvm_jit.rs | 9 +++- src/parser.rs | 5 +++ src/stack_codegen.rs | 15 +++++++ src/vm_codegen.rs | 35 +++++++++++++-- tests/cases/int_types_i8_compare.lyte | 43 ++++++++++++++++++ tests/cases/int_types_u8.lyte | 44 +++++++++++++++++++ tests/cases/slices/empty_string_bytecode.lyte | 27 ++++++------ 12 files changed, 191 insertions(+), 24 deletions(-) create mode 100644 tests/cases/int_types_i8_compare.lyte create mode 100644 tests/cases/int_types_u8.lyte diff --git a/docs/GRAMMAR.md b/docs/GRAMMAR.md index 9552993a..ec2f5228 100644 --- a/docs/GRAMMAR.md +++ b/docs/GRAMMAR.md @@ -27,7 +27,7 @@ keyword = "if" | "else" | "while" | "for" | "in" | "return" | "var" | "let" | "fn" | "struct" | "enum" | "macro" | "interface" | "where" | "arena" | "as" | "true" | "false" - | "void" | "bool" | "i8" | "i32" | "u32" | "f32" | "f64" ; + | "void" | "bool" | "i8" | "u8" | "i32" | "u32" | "f32" | "f64" ; (* Operators and punctuation *) (* -> or U+2192 (→) arrow @@ -48,7 +48,7 @@ keyword = "if" | "else" | "while" | "for" | "in" | "return" type = basic_type { "->" basic_type } ; basic_type = "void" | "bool" - | "i8" | "i32" | "u32" | "f32" | "f64" + | "i8" | "u8" | "i32" | "u32" | "f32" | "f64" | "⟨" id "⟩" | "typevar" id | "[" type "]" (* slice *) diff --git a/docs/language-guide.md b/docs/language-guide.md index 0716458a..924e4e18 100644 --- a/docs/language-guide.md +++ b/docs/language-guide.md @@ -76,7 +76,8 @@ Note for Lua users: Lua lets one variable hold different kinds of values over ti | `f32` | A decimal number (float) | `3.14`, `-0.5`, `1.0` | | `bool` | True or false | `true`, `false` | | `str` | Text (a string in quotes) | `"hello"` | -| `i8` | A very small whole number, used for individual characters | `65` (the letter `A`) | +| `i8` | A very small whole number from -128 to 127, used for individual characters. Supports comparisons and casts but not arithmetic | `65` (the letter `A`) | +| `u8` | A small whole number from 0 to 255 (an unsigned byte). Like `i8`, it supports casts and equality but not arithmetic or ordering | `(200 as u8)`, `(255 as u8)` | | `u32` | A whole number that cannot be negative (unsigned) | `42`, `0` | Single characters can also be written with single quotes: `'a'`, `'Z'`, `'\n'` (newline). This is called a character literal. @@ -1326,7 +1327,8 @@ This example brings together structs, `f32` math, tuples, and functions — all | `f32` | Decimal number | | `bool` | `true` or `false` | | `str` | Text in double quotes | -| `i8` | Very small whole number (used for characters) | +| `i8` | Very small whole number, -128 to 127 (used for characters; comparisons and casts, no arithmetic) | +| `u8` | Unsigned byte, 0 to 255 (casts only; no arithmetic or comparison) | | `u32` | Whole number, cannot be negative | | `f64` | Higher-precision decimal — present in the language but may not apply in Audulus | diff --git a/src/checker.rs b/src/checker.rs index 9c946271..acef2ea8 100644 --- a/src/checker.rs +++ b/src/checker.rs @@ -155,6 +155,7 @@ impl Default for Checker { impl Checker { pub fn new() -> Self { let int8 = mk_type(Type::Int8); + let uint8 = mk_type(Type::UInt8); let int32: TypeID = mk_type(Type::Int32); let uint32: TypeID = mk_type(Type::UInt32); let types = [Type::Int32, Type::UInt32, Type::Float32, Type::Float64]; @@ -176,6 +177,9 @@ impl Checker { neg_overloads.push(func(t, t)); } + // i8: relational comparisons only (no arithmetic; widen to i32 for that) + rel_overloads.push(func(tuple(vec![int8, int8]), b)); + // f32x4: arithmetic and negation, but not relational comparisons let f32x4 = mk_type(Type::Float32x4); let f32x4_pair = tuple(vec![f32x4, f32x4]); @@ -187,6 +191,14 @@ impl Checker { let cast_overloads = vec![ func(int8, int32), func(int32, int8), + func(uint8, int32), + func(int32, uint8), + func(uint8, uint32), + func(uint32, uint8), + func(int8, uint8), + func(uint8, int8), + func(int8, uint32), + func(uint32, int8), func(int32, uint32), func(uint32, int32), func(int32, float32), diff --git a/src/jit.rs b/src/jit.rs index 2cdc5550..d7949df0 100644 --- a/src/jit.rs +++ b/src/jit.rs @@ -2025,8 +2025,16 @@ impl<'a> FunctionTranslator<'a> { (crate::Type::Float64, crate::Type::Float32) => { self.builder.ins().fdemote(F32, val) } - (crate::Type::Int8, crate::Type::Int32) => self.builder.ins().sextend(I32, val), - (crate::Type::Int32, crate::Type::Int8) => self.builder.ins().ireduce(I8, val), + (crate::Type::Int8, crate::Type::Int32 | crate::Type::UInt32) => { + self.builder.ins().sextend(I32, val) + } + (crate::Type::UInt8, crate::Type::Int32 | crate::Type::UInt32) => { + self.builder.ins().uextend(I32, val) + } + ( + crate::Type::Int32 | crate::Type::UInt32, + crate::Type::Int8 | crate::Type::UInt8, + ) => self.builder.ins().ireduce(I8, val), _ => val, } } diff --git a/src/lexer.rs b/src/lexer.rs index 077e84cf..f7383e42 100644 --- a/src/lexer.rs +++ b/src/lexer.rs @@ -52,6 +52,7 @@ pub enum Token { Void, Bool, Int8, + UInt8, Int32, UInt32, Int64, @@ -200,6 +201,7 @@ impl Lexer { "while" => Token::While, "void" => Token::Void, "i8" => Token::Int8, + "u8" => Token::UInt8, "i32" => Token::Int32, "u32" => Token::UInt32, "f32" => Token::Float32, @@ -646,6 +648,7 @@ mod tests { assert_eq!(tokens("1.0u"), vec![InvalidNumberSuffix(Name::str("u"))]); assert_eq!(tokens("void"), vec![Void]); assert_eq!(tokens("i8"), vec![Int8]); + assert_eq!(tokens("u8"), vec![UInt8]); assert_eq!(tokens("i32"), vec![Int32]); assert_eq!(tokens("⟨"), vec![Lmath]); assert_eq!(tokens("⟩"), vec![Rmath]); diff --git a/src/llvm_jit.rs b/src/llvm_jit.rs index 5c32642f..74f5201e 100644 --- a/src/llvm_jit.rs +++ b/src/llvm_jit.rs @@ -3122,12 +3122,17 @@ impl<'a, 'ctx> FunctionTranslator<'a, 'ctx> { .build_float_cast(val.into_float_value(), self.state.f32_ty(), "fdemote") .unwrap() .into(), - (crate::Type::Int8, crate::Type::Int32) => self + (crate::Type::Int8, crate::Type::Int32 | crate::Type::UInt32) => self .builder() .build_int_s_extend(val.into_int_value(), self.i32_ty(), "sext") .unwrap() .into(), - (crate::Type::Int32, crate::Type::Int8) => self + (crate::Type::UInt8, crate::Type::Int32 | crate::Type::UInt32) => self + .builder() + .build_int_z_extend(val.into_int_value(), self.i32_ty(), "zext") + .unwrap() + .into(), + (crate::Type::Int32 | crate::Type::UInt32, crate::Type::Int8 | crate::Type::UInt8) => self .builder() .build_int_truncate(val.into_int_value(), self.i8_ty(), "trunc") .unwrap() diff --git a/src/parser.rs b/src/parser.rs index c5b51241..d374a950 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -82,6 +82,10 @@ fn parse_basic_type(typevars: &[Name], cx: &mut ParseContext) -> TypeID { cx.next(); Type::Int8 } + Token::UInt8 => { + cx.next(); + Type::UInt8 + } Token::Int32 => { cx.next(); Type::Int32 @@ -1314,6 +1318,7 @@ mod tests { let func = mk_type(Type::Func(tup, int8)); test_type("void", mk_type(Type::Void)); test_type("i8", int8); + test_type("u8", mk_type(Type::UInt8)); test_type("i32", int32); test_type("⟨T⟩", typevar("T")); test_type("typevar T", typevar("T")); diff --git a/src/stack_codegen.rs b/src/stack_codegen.rs index 7432c766..686c60ef 100644 --- a/src/stack_codegen.rs +++ b/src/stack_codegen.rs @@ -3001,6 +3001,21 @@ impl<'a> FunctionTranslator<'a> { (Type::Float64, Type::Float32) => func.emit(StackOp::F64ToF32D), (Type::Int32, Type::Int8) | (Type::UInt32, Type::Int8) => func.emit(StackOp::I32ToI8), (Type::Int8, Type::Int32) => func.emit(StackOp::I8ToI32), + (Type::Int8, Type::UInt32) => { + func.emit(StackOp::I8ToI32); + func.emit(StackOp::I64ToU32); + } + (Type::Int8, Type::UInt8) | (Type::UInt8, Type::Int8) => { + // Same sign-extended byte form on this backend. + } + // This backend's Load8 sign-extends, so a u8 is kept in its + // sign-extended byte form like i8 (that keeps `==` consistent + // between loaded and cast values) and masked when widened. + (Type::Int32 | Type::UInt32, Type::UInt8) => func.emit(StackOp::I32ToI8), + (Type::UInt8, Type::Int32 | Type::UInt32) => { + func.emit(StackOp::I64Const(0xFF)); + func.emit(StackOp::And); + } (Type::Int32, Type::UInt32) | (Type::UInt32, Type::Int32) => { func.emit(StackOp::I64ToU32) } diff --git a/src/vm_codegen.rs b/src/vm_codegen.rs index 7ac7a47b..605d9fee 100644 --- a/src/vm_codegen.rs +++ b/src/vm_codegen.rs @@ -3574,12 +3574,31 @@ impl<'a> FunctionTranslator<'a> { (Type::Float64, Type::Float32) => { func.emit(Opcode::F64ToF32 { dst, src }); } - (Type::Int32, Type::Int8) | (Type::UInt32, Type::Int8) => { + (Type::Int32 | Type::UInt32 | Type::UInt8, Type::Int8) => { func.emit(Opcode::I32ToI8 { dst, src }); } (Type::Int8, Type::Int32) => { func.emit(Opcode::I8ToI32 { dst, src }); } + (Type::Int8, Type::UInt32) => { + // Sign-extend to 32 bits, then keep the low 32 bits as u32. + func.emit(Opcode::I8ToI32 { dst, src }); + func.emit(Opcode::I64ToU32 { dst, src: dst }); + } + (Type::Int32 | Type::UInt32 | Type::Int8, Type::UInt8) + | (Type::UInt8, Type::Int32 | Type::UInt32) => { + // u8 lives in a register as its zero-extended byte value. + let mask = self.alloc_reg(); + func.emit(Opcode::LoadImm { + dst: mask, + value: 0xFF, + }); + func.emit(Opcode::And { + dst, + a: src, + b: mask, + }); + } (Type::Int32, Type::UInt32) | (Type::UInt32, Type::Int32) => { // Same size, just reinterpret — mask to u32. func.emit(Opcode::I64ToU32 { dst, src }); @@ -3699,7 +3718,12 @@ impl<'a> FunctionTranslator<'a> { /// Emit a load instruction based on type. fn emit_load(&self, ty: &TypeID, dst: Reg, addr: Reg, func: &mut VMFunction) { match &**ty { - Type::Bool | Type::Int8 | Type::UInt8 => { + Type::Int8 => { + // Load8 zero-extends; i8 lives in a register sign-extended. + func.emit(Opcode::Load8 { dst, addr }); + func.emit(Opcode::I8ToI32 { dst, src: dst }); + } + Type::Bool | Type::UInt8 => { func.emit(Opcode::Load8 { dst, addr }); } Type::Int32 | Type::UInt32 | Type::Float32 => { @@ -3724,7 +3748,12 @@ impl<'a> FunctionTranslator<'a> { func: &mut VMFunction, ) { match &**ty { - Type::Bool | Type::Int8 | Type::UInt8 => { + Type::Int8 => { + let addr = self.emit_offset_addr(base, offset, func); + func.emit(Opcode::Load8 { dst, addr }); + func.emit(Opcode::I8ToI32 { dst, src: dst }); + } + Type::Bool | Type::UInt8 => { let addr = self.emit_offset_addr(base, offset, func); func.emit(Opcode::Load8 { dst, addr }); } diff --git a/tests/cases/int_types_i8_compare.lyte b/tests/cases/int_types_i8_compare.lyte new file mode 100644 index 00000000..87bb747f --- /dev/null +++ b/tests/cases/int_types_i8_compare.lyte @@ -0,0 +1,43 @@ +// expected stdout: +// compilation successful +// assert(true) +// assert(true) +// assert(true) +// assert(true) +// assert(true) +// assert(true) +// assert(true) +// assert(true) +// assert(true) +// assert(true) +// assert(true) +// assert(true) + +main { + let neg = (0 - 1) as i8 + let small = 5 as i8 + let big = 100 as i8 + + // Signed ordering on i8. + assert(neg < small) + assert(big > small) + assert(neg <= neg) + assert(big >= small) + assert('a' < 'b') + + // A negative i8 loaded from memory equals and orders like a cast one. + var bytes: [i8; 2] + bytes[0] = (0 - 128) as i8 + bytes[1] = 127 as i8 + assert(bytes[0] == (0 - 128) as i8) + assert(bytes[0] < bytes[1]) + assert(bytes[0] < 0 as i8) + + // i8 <-> u8 reinterpret the byte. + assert((neg as u8) as i32 == 255) + assert(((200 as u8) as i8) as i32 == -56) + + // i8 <-> u32 sign-extend then reinterpret. + assert((neg as u32) == 4294967295u32) + assert(((4294967295u32 as i8) as i32) == -1) +} diff --git a/tests/cases/int_types_u8.lyte b/tests/cases/int_types_u8.lyte new file mode 100644 index 00000000..ce98d91f --- /dev/null +++ b/tests/cases/int_types_u8.lyte @@ -0,0 +1,44 @@ +// expected stdout: +// compilation successful +// assert(true) +// assert(true) +// assert(true) +// assert(true) +// assert(true) +// assert(true) +// assert(true) +// assert(true) + +main { + // u8 holds the full 0..255 range, unlike i8. + var a: u8 + a = 200 as u8 + assert(a as i32 == 200) + assert(a as u32 == 200u32) + + var b: u8 + b = 255 as u8 + assert(b as i32 == 255) + + // Narrowing wraps to the low byte. + assert((300 as u8) as i32 == 44) + assert(((0 - 1) as u8) as i32 == 255) + assert((256u32 as u8) as i32 == 0) + + // u8 round-trips through memory as an unsigned byte. + var bytes: [u8; 4] + bytes[0] = 128 as u8 + bytes[1] = 255 as u8 + bytes[2] = 1 as u8 + bytes[3] = 0 as u8 + var sum = 0 + var i = 0 + while i < 4 { + sum = sum + bytes[i] as i32 + i = i + 1 + } + assert(sum == 384) + + // Equality between u8 values. + assert(bytes[1] == b) +} diff --git a/tests/cases/slices/empty_string_bytecode.lyte b/tests/cases/slices/empty_string_bytecode.lyte index 8c0c0855..0630ef96 100644 --- a/tests/cases/slices/empty_string_bytecode.lyte +++ b/tests/cases/slices/empty_string_bytecode.lyte @@ -13,19 +13,20 @@ // 8: IMul { dst: 4, a: 2, b: 3 } // 9: IAdd { dst: 2, a: 1, b: 4 } // 10: Load8 { dst: 3, addr: 2 } -// 11: LoadImm { dst: 2, value: 0 } -// 12: I32ToI8 { dst: 4, src: 2 } -// 13: IEq { dst: 2, a: 3, b: 4 } -// 14: Assert { src: 2 } -// 15: LocalAddr { dst: 2, slot: 2 } -// 16: LoadImm { dst: 3, value: 0 } -// 17: Store8Off { base: 2, offset: 0, src: 3 } -// 18: MemEq { dst: 3, a: 1, b: 2, size: 1 } -// 19: Assert { src: 3 } -// 20: LoadImm { dst: 1, value: 0 } -// 21: Move { dst: 0, src: 1 } -// 22: RestoreRegs { start_reg: 1, count: 4, slot: 32 } -// 23: Return +// 11: I8ToI32 { dst: 3, src: 3 } +// 12: LoadImm { dst: 2, value: 0 } +// 13: I32ToI8 { dst: 4, src: 2 } +// 14: IEq { dst: 2, a: 3, b: 4 } +// 15: Assert { src: 2 } +// 16: LocalAddr { dst: 2, slot: 2 } +// 17: LoadImm { dst: 3, value: 0 } +// 18: Store8Off { base: 2, offset: 0, src: 3 } +// 19: MemEq { dst: 3, a: 1, b: 2, size: 1 } +// 20: Assert { src: 3 } +// 21: LoadImm { dst: 1, value: 0 } +// 22: Move { dst: 0, src: 1 } +// 23: RestoreRegs { start_reg: 1, count: 4, slot: 32 } +// 24: Return main { var s = ""