From d3c3536345fa6a6bf98425c5c9a790bc14cb3c2c Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:15:10 +0000 Subject: [PATCH 1/2] fix: type string indexing as byte (#3479) When indexing a string (`s[i]`), type inference was leaving the node type unset because it only handled `TYPE_ARRAY`. As a result, `infer_type` returned `TYPE_UNKNOWN`, leaving any local bound to `s[i]` without a known type until codegen defaulted it to `int` with a warning. Infer string indexing as `TYPE_BYTE` (an unsigned 8-bit integer / `unsigned char` in C), matching the language definition in docs/language-reference.md ("s[i] reads one [byte]"), existing typed contexts, and unsigned byte semantics. Add regression test tests/regression/test_issue3479_string_index_local.ae covering all string sources (local, parameter, struct field, function return value, StringSeq, string[] element), high-byte indexing ("\xC3"[0] == 195), arithmetic/comparison/range match, and zero warning verification. Co-authored-by: paul-hammant <82182+paul-hammant@users.noreply.github.com> --- compiler/analysis/type_inference.c | 4 + ...733Z-fixed-3479-string-index-local-type.md | 4 + .../test_issue3479_string_index_local.ae | 125 ++++++++++++++++++ 3 files changed, 133 insertions(+) create mode 100644 new_changelogs/20261101T184733Z-fixed-3479-string-index-local-type.md create mode 100644 tests/regression/test_issue3479_string_index_local.ae diff --git a/compiler/analysis/type_inference.c b/compiler/analysis/type_inference.c index e90031a10..56bc81f4d 100644 --- a/compiler/analysis/type_inference.c +++ b/compiler/analysis/type_inference.c @@ -1091,6 +1091,10 @@ void collect_expression_constraints(ASTNode* node, InferenceContext* ctx) { if (array_type && array_type->kind == TYPE_ARRAY && array_type->element_type) { free_type(node->node_type); node->node_type = clone_type(array_type->element_type); + } else if (array_type && array_type->kind == TYPE_STRING) { + /* #3479: indexing a string reads one byte */ + free_type(node->node_type); + node->node_type = create_type(TYPE_BYTE); } } break; diff --git a/new_changelogs/20261101T184733Z-fixed-3479-string-index-local-type.md b/new_changelogs/20261101T184733Z-fixed-3479-string-index-local-type.md new file mode 100644 index 000000000..6b114d497 --- /dev/null +++ b/new_changelogs/20261101T184733Z-fixed-3479-string-index-local-type.md @@ -0,0 +1,4 @@ +- **Binding one byte of a string to a local no longer leaves the local's type unresolved.** + Type inference now types string indexing (`s[i]`) as `byte`, matching the language's + byte read semantics and eliminating the warning in codegen that defaulted the local to `int` + (#3479). diff --git a/tests/regression/test_issue3479_string_index_local.ae b/tests/regression/test_issue3479_string_index_local.ae new file mode 100644 index 000000000..760558c6d --- /dev/null +++ b/tests/regression/test_issue3479_string_index_local.ae @@ -0,0 +1,125 @@ +// #3479: Binding one byte of a string to a local leaves the local's type +// to codegen (defaulting to int with a warning). +// +// Every way the string can arrive: +// - a local +// - a parameter +// - a struct field +// - a function's return value (f()[0]) +// - a string from a *StringSeq and a string[] element +// High byte ("\xC3"[0]) must read 195, not -61. +// Arithmetic (s[0] + 1), comparison (s[0] == 88), and match over range work. +// Compiled with warnings as errors (verified via build log check). + +import std.os +import std.fs +import std.mem +import std.list +import std.string + +extern exit(code: int) + +struct StringContainer { + text: string +} + +fn_return_str() -> string { + return "ABC" +} + +check_param(param_str: string, index: int) -> int { + c = param_str[index] + return c as int +} + +check(ok: bool, what: string) { + if !ok { + println("FAIL: ${what}") + exit(1) + } +} + +prints_a_warning(log: string) -> bool { + if string.contains(log, "warning[") == 1 { + return true + } + return string.starts_with(log, "warning:") == 1 || string.contains(log, "\nwarning:") == 1 +} + +main() { + // 1. Local string + s = "XYZ" + c_local = s[0] + check(c_local == 88, "local s[0] == 88") + + // 2. Parameter + c_param = check_param("PARAM", 0) + check(c_param == 80, "param string[0] == 80") + + // 3. Struct field + cont = StringContainer { text: "FIELD" } + c_field = cont.text[0] + check(c_field == 70, "struct field text[0] == 70") + + // 4. Function return value + c_fn = fn_return_str()[0] + check(c_fn == 65, "function return[0] == 65") + + // 5. String from a *StringSeq and string[] element + let seq: *StringSeq = ["SEQ1", "SEQ2"] + s_from_seq = string.seq_head(seq) + c_seq = s_from_seq[0] + check(c_seq == 83, "*StringSeq head string[0] == 83 ('S')") + + arr = ["ARR1", "ARR2"] + c_arr = arr[0][0] + check(c_arr == 65, "string[] element[0][0] == 65 ('A')") + + // 6. High byte: "\xC3"[0] must read 195, not -61 + high_str = "\xC3" + c_high = high_str[0] + check(c_high == 195, "high byte \"\\xC3\"[0] must read 195, not -61") + + // 7. Arithmetic and comparison + d = s[0] + 1 + check(d == 89, "d = s[0] + 1 must be 89") + check(s[0] == 88, "s[0] == 88 comparison") + + // 8. Match over a range + matched_uppercase = false + match c_local { + 65..=90 -> matched_uppercase = true + _ -> matched_uppercase = false + } + check(matched_uppercase, "match c { 65..=90 -> ... } works for inferred local") + + // 9. Warnings as errors verification: + // Build this test with ae build and verify no warnings are emitted in the build log. + out_dir = "build" + log_file = "${out_dir}/test_3479_build.log" + argv = list.new() + list.add(argv, "build") + list.add(argv, "--with=all") + list.add(argv, "tests/regression/test_issue3479_string_index_local.ae") + list.add(argv, "--emit=csrc") + tok, serr = os.spawn_proc_to("./build/ae", argv, null, log_file) + list.free(argv) + if serr != "" { + println("FAIL: spawn failed: ${serr}") + exit(1) + } + toks = list.new() + list.add(toks, mem.long_to_ptr(tok as long)) + _, code, _ = os.wait_any(toks) + list.free(toks) + check(code == 0, "ae build exited 0") + build_log, _ = fs.read(log_file) + _ = fs.delete(log_file) + _ = fs.delete("build/test_issue3479_string_index_local.c") + _ = fs.delete("build/test_issue3479_string_index_local.h") + _ = fs.delete("build/test_issue3479_string_index_local.catalog.json") + + check(!prints_a_warning(build_log), "build log must not contain any warnings (clean build)") + + println("PASS: issue3479_string_index_local") +} From fe1c69a844b26398d072217b484b44c8ab75882f Mon Sep 17 00:00:00 2001 From: Paul Hammant Date: Sun, 1 Nov 2026 21:53:06 +0000 Subject: [PATCH 2/2] typechecker: a local bound to s[i] is a byte (#3479) `c = s[i]` left the local untyped: inference set an element type only for an array base, so a string base fell through and codegen defaulted the local to int with "unresolved type in codegen, defaulting to int", which fails `make check-examples`. The read is a byte, so the local is one, as a local bound to a byte[] element is. A high byte reads 195, not -61, and arithmetic stored back into the local wraps (88 - 100 is 244, where the int default gave -12); `s[i] as int` keeps a signed result. The test is an integration directory: a fixture binding the read from a local, a parameter, a field, a returned string and *StringSeq / string[] elements, and a script that builds it with no warning and runs it. Reworks Jules's draft (paul-hammant/aether-fork#49, Jules task 7456359029880539017): the fix is Jules's; the test no longer re-runs `./build/ae build --with=all` from inside itself, and the changelog and language reference say what the byte type means for arithmetic. Co-Authored-By: Claude Opus 5.5 --- docs/language-reference.md | 2 +- ...733Z-fixed-3479-string-index-local-type.md | 12 +- .../string_index_local.ae | 77 +++++++++++ .../test_string_index_local_type.sh | 41 ++++++ .../test_issue3479_string_index_local.ae | 125 ------------------ 5 files changed, 127 insertions(+), 130 deletions(-) create mode 100644 tests/integration/string_index_local_type/string_index_local.ae create mode 100755 tests/integration/string_index_local_type/test_string_index_local_type.sh delete mode 100644 tests/regression/test_issue3479_string_index_local.ae diff --git a/docs/language-reference.md b/docs/language-reference.md index 8cb8447ad..4dd4fe0f4 100644 --- a/docs/language-reference.md +++ b/docs/language-reference.md @@ -413,7 +413,7 @@ main() { - **`s[i]` is bounds-checked.** An index outside `0 ..< s.len` is a runtime panic naming the source line, the index and the length (`slice index 5 out of range for length 3`). It is an ordinary panic: `try` catches it, and an actor step that trips it dies cleanly. A negative index is out of range, never a read before the buffer. An index into a fixed-size `T[N]` array is checked the same way, against `N` ([Array Types](#array-types)). - **`s[lo..hi]`** is the half-open sub-slice `[lo, hi)`, checked the same way (`lo <= hi <= s.len`). `s[lo..]` runs to the end, `s[..hi]` from the start, `s[..]` is the whole thing. The base may be a slice or a `T[N]` array; the result is always a `T[]` over the *same* elements, so a write through it is visible through the original. - **A `T[N]` array converts to `T[]` implicitly** wherever a slice is expected: a parameter, a `T[]`-typed binding or field, a `return` from a `-> T[]` function. The length travels with it. `null`, and the empty literal `[]`, convert to the empty slice: `.len` is `0`, it views no storage, and it compares equal to `null`. So `s == []` is `s == null` (it asks whether `s` views any storage; test `s.len == 0` for emptiness), a C function's `T[]` parameter takes `[]` as the null pointer, and an if-expression or a `match` given to a `T[]` converts each arm on its own. A module-level `var` or `const` of type `T[]` takes either as its initial value. -- **A string is not a slice.** Passing one where a `T[]` is expected (a parameter, a binding, a `return`) is a type error; `string.bytes(s)` is the bounded, read-only `byte[]` view of a string's bytes, and it decays to their pointer at a C extern like any slice. Reading it costs what any slice's read does; a store through it (an element store, `+=`, `++`), or through any view made from it (a sub-slice, a pattern's tail), in this function or one it is given to, panics with "a write through a read-only view of a string", and one the checker can see through a local bound to it is a compile error (E0200) (#3025). A string's own bytes are read-only too: `s[i]` reads one, and a write (`s[i] = v`, `s[i] += v`, `s[i]++`) is a compile error (E0200). Build the new text as a new string, or work on the bytes in a `byte[N]` array of your own. +- **A string is not a slice.** Passing one where a `T[]` is expected (a parameter, a binding, a `return`) is a type error; `string.bytes(s)` is the bounded, read-only `byte[]` view of a string's bytes, and it decays to their pointer at a C extern like any slice. Reading it costs what any slice's read does; a store through it (an element store, `+=`, `++`), or through any view made from it (a sub-slice, a pattern's tail), in this function or one it is given to, panics with "a write through a read-only view of a string", and one the checker can see through a local bound to it is a compile error (E0200) (#3025). A string's own bytes are read-only too: `s[i]` reads one (a `byte`, so `c = s[i]` binds a `byte` local), and a write (`s[i] = v`, `s[i] += v`, `s[i]++`) is a compile error (E0200). Build the new text as a new string, or work on the bytes in a `byte[N]` array of your own. - **A slice decays to its pointer** wherever a `ptr` is expected, exactly as a `T[N]` array does: a `ptr` parameter, a C extern's parameter (including one declared `T[]`), a comparison against `null`, and pointer arithmetic (`s + n` is a `ptr` to element `n`). `s as ptr` spells the same conversion explicitly, for a `ptr` return or binding. The length is dropped at that boundary; nothing else about the extern ABI changes. - **`make([]T, n)`** allocates `n` zeroed elements and returns them as a bounded slice. The local that holds it releases it when it goes out of scope ([Slice ownership](#slice-ownership)), and `free(s)` releases it early, and first what the elements hold: each element is a place a store into it takes a reference for (a string, a closure, a struct that holds one), so the free gives each back, as a struct's destroy does its fields (#3159). The elements of C memory viewed as a slice hold no reference, and its free releases only the memory. Only the slice that holds such a buffer, or a view of a raw pointer the program owns from its start (`(p as T[])[0..n]`), may be freed: a view owns nothing. `free` of a sub-slice (`b[2..4]`, `b[1..]`, a list pattern's tail), of an array viewed as a slice, of `string.bytes(s)`, or of an array `T[N]`, is a compile error where the checker sees it (a local bound once to one, or the expression itself), and a named panic at run time anywhere else (`free of a view of elements another slice or an array holds, which owns nothing`). Before, the allocator was given a pointer it never handed out. In a module that defines a `free` of its own (`bignum.free`, `json.free`), `free(s)` of a slice is still this release, unless the module's `free` itself takes a slice: the module's function would only see the slice's pointer (#3116). - **`T[][]` is a slice of slices**, and `T[][][]` one deeper: each further `[]` wraps the type once more, in any position a type is written (a parameter, a return type, a field, a typed local, a function type). `make([]int[], n)` builds one; `g[i] = row` stores a row, `g[i][j]` reads an element and `g[i].len` is a row's length. The outer slice owns its rows: a store into `g[i]` releases the row it replaces, and releasing the outer slice (its owner's scope exit, or `free(g)`) releases every row first; `free(g[i])` releases a row early and leaves it empty. A row read out (`r = g[i]`) is a view of it. Only the empty `[]` repeats; there is no `T[][3]`. diff --git a/new_changelogs/20261101T184733Z-fixed-3479-string-index-local-type.md b/new_changelogs/20261101T184733Z-fixed-3479-string-index-local-type.md index 6b114d497..7b058c185 100644 --- a/new_changelogs/20261101T184733Z-fixed-3479-string-index-local-type.md +++ b/new_changelogs/20261101T184733Z-fixed-3479-string-index-local-type.md @@ -1,4 +1,8 @@ -- **Binding one byte of a string to a local no longer leaves the local's type unresolved.** - Type inference now types string indexing (`s[i]`) as `byte`, matching the language's - byte read semantics and eliminating the warning in codegen that defaulted the local to `int` - (#3479). +- **A local bound to one byte of a string is a `byte`** (#3479). `c = s[i]` (a + local, parameter, field or returned string) left the local untyped until + codegen defaulted it to `int`, with "unresolved type in codegen, defaulting + to int", which fails `make check-examples`. The read is a byte, so the local + is one, as a local bound to a `byte[]` element is: a high byte reads 195 + (not -61), and arithmetic stored back into the local wraps (`c = s[0]; c = + c - 100` is 244 for "X"), where the int default gave -12. Widen with `as + int` (`s[0] as int - 100`) to keep a signed result. diff --git a/tests/integration/string_index_local_type/string_index_local.ae b/tests/integration/string_index_local_type/string_index_local.ae new file mode 100644 index 000000000..d8d763871 --- /dev/null +++ b/tests/integration/string_index_local_type/string_index_local.ae @@ -0,0 +1,77 @@ +// #3479: a local bound to one byte of a string (`c = s[i]`) is typed by the +// read, a byte, as a local bound to an element of a byte[] is. It used to be +// left unresolved until codegen defaulted it to int, with a warning. +// test_string_index_local_type.sh builds this with no warning and runs it; +// the sweep runs it as well. +import std.string + +extern exit(code: int) + +struct Holder { + text: string +} + +returned() -> string { + return "ABC" +} + +from_param(text: string, i: int) -> int { + c = text[i] + return c as int +} + +check(ok: bool, what: string) { + if !ok { + println("FAIL: ${what}") + exit(1) + } +} + +main() { + s = "XYZ" + c = s[0] + check(c == 88, "a local's s[0] is 88") + + check(from_param("PARAM", 0) == 80, "a parameter's text[0] is 80") + + h = Holder { text: "FIELD" } + f = h.text[0] + check(f == 70, "a field's text[0] is 70") + + r = returned()[0] + check(r == 65, "a returned string's [0] is 65") + + let seq: *StringSeq = ["SEQ1", "SEQ2"] + head = string.seq_head(seq) + q = head[0] + check(q == 83, "a *StringSeq head's [0] is 83") + + arr = ["ARR1", "ARR2"] + a = arr[0][0] + check(a == 65, "a string[] element's [0] is 65") + + // A byte is unsigned: a high byte reads 195, not -61. + high = "\xC3" + b = high[0] + check(b == 195, "\"\\xC3\"[0] is 195") + + d = s[0] + 1 + check(d == 89, "s[0] + 1 is 89") + check(s[0] == 88, "s[0] == 88") + + upper = false + match c { + 65..=90 -> upper = true + _ -> upper = false + } + check(upper, "a match over 65..=90 takes the byte") + + // The local is a byte, so arithmetic stored back into it wraps, as a + // byte[] element's local does; widen with `as int` to go below 0. + w = s[0] + w = w - 100 + check(w == 244, "a byte local's 88 - 100 wraps to 244") + check(s[0] as int - 100 == -12, "s[0] as int - 100 is -12") + + println("PASS: string_index_local_type") +} diff --git a/tests/integration/string_index_local_type/test_string_index_local_type.sh b/tests/integration/string_index_local_type/test_string_index_local_type.sh new file mode 100755 index 000000000..5d034894b --- /dev/null +++ b/tests/integration/string_index_local_type/test_string_index_local_type.sh @@ -0,0 +1,41 @@ +#!/bin/sh +# #3479: a local bound to one byte of a string (`c = s[0]`, `c = text[i]`) +# was left untyped by inference, and codegen defaulted it to int with +# "warning: unresolved type in codegen, defaulting to int", which fails +# `make check-examples` (warnings are errors there). The read is a byte, so +# the local is a byte. +# +# string_index_local.ae binds the read from a local, a parameter, a field, a +# returned string and *StringSeq / string[] elements, and checks the values +# (a high byte is 195, not -61). Building it must print no warning at all; +# running it must pass. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AE="$ROOT/build/ae" +export AETHER_HOME="$ROOT" + +[ -x "$AE" ] || [ -x "$AE.exe" ] || { echo " [SKIP] string_index_local_type: ae not built"; exit 0; } + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +cp "$SCRIPT_DIR/string_index_local.ae" "$TMP/" + +if ! (cd "$TMP" && "$AE" build string_index_local.ae -o prog > build.log 2>&1); then + echo " [FAIL] string_index_local_type: build failed" + sed 's/^/ /' "$TMP/build.log" | head -20 + exit 1 +fi +if grep -q "warning" "$TMP/build.log"; then + echo " [FAIL] string_index_local_type: the build warned" + sed 's/^/ /' "$TMP/build.log" | head -20 + exit 1 +fi +out="$(cd "$TMP" && ./prog 2>&1)" +case "$out" in + *"PASS: string_index_local_type"*) ;; + *) echo " [FAIL] string_index_local_type: the program failed" + echo "$out" | sed 's/^/ /' + exit 1 ;; +esac +echo " [PASS] string_index_local_type: s[i] binds a byte local with no warning, from every source" diff --git a/tests/regression/test_issue3479_string_index_local.ae b/tests/regression/test_issue3479_string_index_local.ae deleted file mode 100644 index 760558c6d..000000000 --- a/tests/regression/test_issue3479_string_index_local.ae +++ /dev/null @@ -1,125 +0,0 @@ -// #3479: Binding one byte of a string to a local leaves the local's type -// to codegen (defaulting to int with a warning). -// -// Every way the string can arrive: -// - a local -// - a parameter -// - a struct field -// - a function's return value (f()[0]) -// - a string from a *StringSeq and a string[] element -// High byte ("\xC3"[0]) must read 195, not -61. -// Arithmetic (s[0] + 1), comparison (s[0] == 88), and match over range work. -// Compiled with warnings as errors (verified via build log check). - -import std.os -import std.fs -import std.mem -import std.list -import std.string - -extern exit(code: int) - -struct StringContainer { - text: string -} - -fn_return_str() -> string { - return "ABC" -} - -check_param(param_str: string, index: int) -> int { - c = param_str[index] - return c as int -} - -check(ok: bool, what: string) { - if !ok { - println("FAIL: ${what}") - exit(1) - } -} - -prints_a_warning(log: string) -> bool { - if string.contains(log, "warning[") == 1 { - return true - } - return string.starts_with(log, "warning:") == 1 || string.contains(log, "\nwarning:") == 1 -} - -main() { - // 1. Local string - s = "XYZ" - c_local = s[0] - check(c_local == 88, "local s[0] == 88") - - // 2. Parameter - c_param = check_param("PARAM", 0) - check(c_param == 80, "param string[0] == 80") - - // 3. Struct field - cont = StringContainer { text: "FIELD" } - c_field = cont.text[0] - check(c_field == 70, "struct field text[0] == 70") - - // 4. Function return value - c_fn = fn_return_str()[0] - check(c_fn == 65, "function return[0] == 65") - - // 5. String from a *StringSeq and string[] element - let seq: *StringSeq = ["SEQ1", "SEQ2"] - s_from_seq = string.seq_head(seq) - c_seq = s_from_seq[0] - check(c_seq == 83, "*StringSeq head string[0] == 83 ('S')") - - arr = ["ARR1", "ARR2"] - c_arr = arr[0][0] - check(c_arr == 65, "string[] element[0][0] == 65 ('A')") - - // 6. High byte: "\xC3"[0] must read 195, not -61 - high_str = "\xC3" - c_high = high_str[0] - check(c_high == 195, "high byte \"\\xC3\"[0] must read 195, not -61") - - // 7. Arithmetic and comparison - d = s[0] + 1 - check(d == 89, "d = s[0] + 1 must be 89") - check(s[0] == 88, "s[0] == 88 comparison") - - // 8. Match over a range - matched_uppercase = false - match c_local { - 65..=90 -> matched_uppercase = true - _ -> matched_uppercase = false - } - check(matched_uppercase, "match c { 65..=90 -> ... } works for inferred local") - - // 9. Warnings as errors verification: - // Build this test with ae build and verify no warnings are emitted in the build log. - out_dir = "build" - log_file = "${out_dir}/test_3479_build.log" - argv = list.new() - list.add(argv, "build") - list.add(argv, "--with=all") - list.add(argv, "tests/regression/test_issue3479_string_index_local.ae") - list.add(argv, "--emit=csrc") - tok, serr = os.spawn_proc_to("./build/ae", argv, null, log_file) - list.free(argv) - if serr != "" { - println("FAIL: spawn failed: ${serr}") - exit(1) - } - toks = list.new() - list.add(toks, mem.long_to_ptr(tok as long)) - _, code, _ = os.wait_any(toks) - list.free(toks) - check(code == 0, "ae build exited 0") - build_log, _ = fs.read(log_file) - _ = fs.delete(log_file) - _ = fs.delete("build/test_issue3479_string_index_local.c") - _ = fs.delete("build/test_issue3479_string_index_local.h") - _ = fs.delete("build/test_issue3479_string_index_local.catalog.json") - - check(!prints_a_warning(build_log), "build log must not contain any warnings (clean build)") - - println("PASS: issue3479_string_index_local") -}