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/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 new file mode 100644 index 000000000..7b058c185 --- /dev/null +++ b/new_changelogs/20261101T184733Z-fixed-3479-string-index-local-type.md @@ -0,0 +1,8 @@ +- **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"