Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,30 @@ All notable changes to EigenScript are documented here.

## [Unreleased]

### Fixed
- **Four silent-wrong-answer builtin contracts** (#312, #314, #316, #317),
batch-fixed after the 2026-07 backlog triage:
- `min`/`max` are now true N-ary reductions over a flat list of numbers
(#317). The old code read only the first two elements — `max of [1, 2, 3]`
returned **2** — and returned `0` for a 1-element list. Now
`min of [5]``5`, `max of [1, 2, 3]``3`, and a bare number returns
itself (mirroring `sum`); an empty list or a non-number element keeps
the documented `0` fallback.
- The string predicates `index_of`/`contains`/`starts_with`/`ends_with`
reject non-string operands (#316) instead of folding them to `""`,
which spuriously matched everything (`index_of of [[1,2,3], 2]` returned
`0` — "found at index 0" — in violation of its "or -1" contract). Misses
now answer `-1` (`index_of`) / `0` (the predicates).
- `get_at`/`set_at`/`char_at` honor negative indices exactly like the `[]`
operator (#312) — `get_at of [xs, -1]` is the last element (was: silent
`0`/no-op/`""`), including the 2-D `get_at`/`set_at` row/col paths.
Out-of-range indices keep the old miss defaults.
- Passing a directory as the script path exits `1` with the clean
`Error: cannot read file '...'` path (#314) instead of SIGABRT via a
bogus 9-exabyte `xmalloc` (`ftell` on a directory reports `LONG_MAX`);
`read_file_util` now rejects non-regular files up front, which covers
`load_file` and the `--fmt`/`--lint` paths too.

### Changed
- **`chr` is the byte-writing inverse of `ord`** (#435): `chr of n` now emits
the raw byte for any integer 1–255 (was: silent `""` for anything above
Expand Down
14 changes: 7 additions & 7 deletions docs/BUILTINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ numeric fast paths used by reassignment and `unobserved` blocks.
| `append` | `append of [list, item]` | Append item to list (mutates list) |
| `concat` | `concat of [a, b]` | Concatenate two lists into new list |
| `range` | `range of n` or `range of [start, end]` | Generate integer list [0..n) or [start..end) |
| `set_at` | `set_at of [list, index, value]` | Set element at index (mutates list) |
| `get_at` | `get_at of [list, index]` | Get element at index |
| `set_at` | `set_at of [list, index, value]` | Set element at index (mutates list); negative indices count from the end, like `[]` |
| `get_at` | `get_at of [list, index]` | Get element at index; negative indices count from the end, like `[]` |
| `copy_into` | `copy_into of [dest, src, offset]` | Copy src elements into dest starting at offset |
| `num_copy` | `num_copy of value` | Create independent copy of numeric value |
| `hex` | `hex of n` or `hex of [n, nibbles]` | Uppercase hex string of a non-negative integer, zero-padded to `nibbles` (never truncated). Raises on negatives, fractions, non-numbers |
Expand All @@ -65,11 +65,11 @@ numeric fast paths used by reassignment and `unobserved` blocks.
|------|-----------|-------------|
| `str_lower` | `str_lower of s` | Convert to lowercase |
| `str_upper` | `str_upper of s` | Convert to uppercase |
| `char_at` | `char_at of [s, index]` | Single character at index as string ("" if out of range) |
| `contains` | `contains of [haystack, needle]` | 1 if haystack contains needle, else 0 |
| `char_at` | `char_at of [s, index]` | Single character at index as string ("" if out of range); negative indices count from the end, like `[]` |
| `contains` | `contains of [haystack, needle]` | 1 if haystack contains needle, else 0 (non-string operands are 0, never a spurious match) |
| `starts_with` | `starts_with of [s, prefix]` | 1 if s starts with prefix, else 0 |
| `ends_with` | `ends_with of [s, suffix]` | 1 if s ends with suffix, else 0 |
| `index_of` | `index_of of [haystack, needle]` | First index of needle in haystack, or -1 |
| `index_of` | `index_of of [haystack, needle]` | First index of needle in haystack, or -1 (non-string operands are -1) |
| `substr` | `substr of [s, start, length]` | Extract substring |
| `split` | `split of [s, delim]` | Split string by delimiter into list |
| `scan_ints` | `scan_ints of s` or `scan_ints of [s, comment_marker]` | C-backed scan of whitespace-delimited signed integer tokens, optionally skipping comment lines |
Expand Down Expand Up @@ -316,8 +316,8 @@ automatically at exit.
| Name | Signature | Description |
|------|-----------|-------------|
| `abs` | `abs of x` | Absolute value |
| `min` | `min of [a, b]` | Smaller of two numbers |
| `max` | `max of [a, b]` | Larger of two numbers |
| `min` | `min of [n1, n2, ...]` | Smallest of a list of numbers (any length >= 1) |
| `max` | `max of [n1, n2, ...]` | Largest of a list of numbers (any length >= 1) |
| `floor` | `floor of x` | Round down to integer |
| `ceil` | `ceil of x` | Round up to integer |
| `round` | `round of x` | Round to nearest integer |
Expand Down
86 changes: 54 additions & 32 deletions src/builtins.c
Original file line number Diff line number Diff line change
Expand Up @@ -1054,20 +1054,21 @@ Value* builtin_str_lower(Value *arg) {
return r;
}

/* #316: the string predicates reject non-string operands outright. The old
* idiom folded them to "" — and an empty needle/prefix/suffix matches
* everything, so `contains of [[1,2,3], 2]` reported a spurious hit. */
Value* builtin_contains(Value *arg) {
if (!arg || arg->type != VAL_LIST || arg->data.list.count < 2) return make_num(0);
const char *haystack = "", *needle = "";
if (arg->data.list.items[0]->type == VAL_STR) haystack = arg->data.list.items[0]->data.str;
if (arg->data.list.items[1]->type == VAL_STR) needle = arg->data.list.items[1]->data.str;
return make_num(strstr(haystack, needle) != NULL ? 1 : 0);
Value *h = arg->data.list.items[0], *n = arg->data.list.items[1];
if (!h || h->type != VAL_STR || !n || n->type != VAL_STR) return make_num(0);
return make_num(strstr(h->data.str, n->data.str) != NULL ? 1 : 0);
}

Value* builtin_starts_with(Value *arg) {
if (!arg || arg->type != VAL_LIST || arg->data.list.count < 2) return make_num(0);
const char *str = "", *prefix = "";
if (arg->data.list.items[0]->type == VAL_STR) str = arg->data.list.items[0]->data.str;
if (arg->data.list.items[1]->type == VAL_STR) prefix = arg->data.list.items[1]->data.str;
return make_num(strncmp(str, prefix, strlen(prefix)) == 0 ? 1 : 0);
Value *s = arg->data.list.items[0], *p = arg->data.list.items[1];
if (!s || s->type != VAL_STR || !p || p->type != VAL_STR) return make_num(0);
return make_num(strncmp(s->data.str, p->data.str, strlen(p->data.str)) == 0 ? 1 : 0);
}

Value* builtin_split(Value *arg) {
Expand Down Expand Up @@ -1546,7 +1547,8 @@ Value* builtin_str_upper(Value *arg) {
}

/* ==== BUILTIN: char_at ==== */
/* char_at of [string, index] → single character as string, or "" if out of range */
/* char_at of [string, index] → single character as string, or "" if out of range.
* Negative indices count from the end, matching the [] operator (#312). */
Value* builtin_char_at(Value *arg) {
if (!arg || arg->type != VAL_LIST || arg->data.list.count < 2) return make_str("");
Value *str_val = arg->data.list.items[0];
Expand All @@ -1555,6 +1557,7 @@ Value* builtin_char_at(Value *arg) {
return make_str("");
int idx = (int)idx_val->data.num;
int len = strlen(str_val->data.str);
if (idx < 0) idx += len;
if (idx < 0 || idx >= len) return make_str("");
char buf[2] = { str_val->data.str[idx], '\0' };
return make_str(buf);
Expand All @@ -1563,9 +1566,9 @@ Value* builtin_char_at(Value *arg) {
/* ==== BUILTIN: ends_with ==== */
Value* builtin_ends_with(Value *arg) {
if (!arg || arg->type != VAL_LIST || arg->data.list.count < 2) return make_num(0);
const char *str = "", *suffix = "";
if (arg->data.list.items[0]->type == VAL_STR) str = arg->data.list.items[0]->data.str;
if (arg->data.list.items[1]->type == VAL_STR) suffix = arg->data.list.items[1]->data.str;
Value *sv = arg->data.list.items[0], *xv = arg->data.list.items[1];
if (!sv || sv->type != VAL_STR || !xv || xv->type != VAL_STR) return make_num(0);
const char *str = sv->data.str, *suffix = xv->data.str;
int slen = strlen(str), xlen = strlen(suffix);
if (xlen > slen) return make_num(0);
return make_num(strcmp(str + slen - xlen, suffix) == 0 ? 1 : 0);
Expand Down Expand Up @@ -1601,15 +1604,15 @@ Value* builtin_substr(Value *arg) {
}

/* ==== BUILTIN: index_of ==== */
/* index_of of [haystack, needle] → first index, or -1 */
/* index_of of [haystack, needle] → first index, or -1. Non-string operands
* are a miss (-1), never a fold-to-"" false positive at index 0 (#316). */
Value* builtin_index_of(Value *arg) {
if (!arg || arg->type != VAL_LIST || arg->data.list.count < 2) return make_num(-1);
const char *haystack = "", *needle = "";
if (arg->data.list.items[0]->type == VAL_STR) haystack = arg->data.list.items[0]->data.str;
if (arg->data.list.items[1]->type == VAL_STR) needle = arg->data.list.items[1]->data.str;
const char *p = strstr(haystack, needle);
Value *h = arg->data.list.items[0], *n = arg->data.list.items[1];
if (!h || h->type != VAL_STR || !n || n->type != VAL_STR) return make_num(-1);
const char *p = strstr(h->data.str, n->data.str);
if (!p) return make_num(-1);
return make_num((double)(p - haystack));
return make_num((double)(p - h->data.str));
}

/* ================================================================
Expand Down Expand Up @@ -1680,21 +1683,27 @@ Value* builtin_abs(Value *arg) {
return make_num(fabs(arg->data.num));
}

Value* builtin_min(Value *arg) {
if (!arg || arg->type != VAL_LIST || arg->data.list.count < 2) return make_num(0);
Value *a = arg->data.list.items[0];
Value *b = arg->data.list.items[1];
if (!a || a->type != VAL_NUM || !b || b->type != VAL_NUM) return make_num(0);
return make_num(a->data.num < b->data.num ? a->data.num : b->data.num);
/* #317: min/max are N-ary reductions over a flat list of numbers — the old
* code silently read only items[0..1] (`max of [1,2,3]` was 2) and returned 0
* for a 1-element list. A single bare number returns itself (mirrors `sum`);
* an empty list or any non-number element keeps the old 0 fallback rather
* than inventing a partial answer. */
static Value* minmax_reduce(Value *arg, int want_max) {
if (arg && arg->type == VAL_NUM) return make_num(arg->data.num);
if (!arg || arg->type != VAL_LIST || arg->data.list.count < 1) return make_num(0);
double best = 0.0;
for (int i = 0; i < arg->data.list.count; i++) {
Value *v = arg->data.list.items[i];
if (!v || v->type != VAL_NUM) return make_num(0);
if (i == 0 || (want_max ? v->data.num > best : v->data.num < best))
best = v->data.num;
}
return make_num(best);
}

Value* builtin_max(Value *arg) {
if (!arg || arg->type != VAL_LIST || arg->data.list.count < 2) return make_num(0);
Value *a = arg->data.list.items[0];
Value *b = arg->data.list.items[1];
if (!a || a->type != VAL_NUM || !b || b->type != VAL_NUM) return make_num(0);
return make_num(a->data.num > b->data.num ? a->data.num : b->data.num);
}
Value* builtin_min(Value *arg) { return minmax_reduce(arg, 0); }

Value* builtin_max(Value *arg) { return minmax_reduce(arg, 1); }

Value* builtin_pi(Value *arg) {
(void)arg;
Expand Down Expand Up @@ -2368,9 +2377,14 @@ Value* builtin_json_path(Value *arg) {
char* read_file_util(const char *path, long *out_size) {
FILE *f = fopen(path, "rb");
if (!f) return NULL;
/* #314: fopen succeeds on a directory, and ftell then reports LONG_MAX —
* which sailed straight into xmalloc's fatal-OOM abort. Reject
* directories here so callers hit their existing clean error paths. */
struct stat st;
if (fstat(fileno(f), &st) == 0 && !S_ISREG(st.st_mode)) { fclose(f); return NULL; }
fseek(f, 0, SEEK_END);
long size = ftell(f);
if (size < 0) { fclose(f); return NULL; }
if (size < 0 || size == LONG_MAX) { fclose(f); return NULL; }
fseek(f, 0, SEEK_SET);
char *buf = xmalloc(size + 1);
if (!buf) { fclose(f); return NULL; }
Expand Down Expand Up @@ -3919,6 +3933,8 @@ Value* builtin_set_at(Value *arg) {
Value *list = arg->data.list.items[0];
int idx = (arg->data.list.items[1]->type == VAL_NUM) ? (int)arg->data.list.items[1]->data.num : 0;
Value *val = arg->data.list.items[2];
/* #312: negative indices count from the end, matching []. */
if (list->type == VAL_LIST && idx < 0) idx += list->data.list.count;
if (list->type == VAL_LIST && idx >= 0 && idx < list->data.list.count) {
val_incref(val);
val_decref(list->data.list.items[idx]);
Expand All @@ -3932,8 +3948,10 @@ Value* builtin_set_at(Value *arg) {
int row = (arg->data.list.items[1]->type == VAL_NUM) ? (int)arg->data.list.items[1]->data.num : 0;
int col = (arg->data.list.items[2]->type == VAL_NUM) ? (int)arg->data.list.items[2]->data.num : 0;
Value *val = arg->data.list.items[3];
if (list->type == VAL_LIST && row < 0) row += list->data.list.count;
if (list->type == VAL_LIST && row >= 0 && row < list->data.list.count) {
Value *rowv = list->data.list.items[row];
if (rowv->type == VAL_LIST && col < 0) col += rowv->data.list.count;
if (rowv->type == VAL_LIST && col >= 0 && col < rowv->data.list.count) {
val_incref(val);
val_decref(rowv->data.list.items[col]);
Expand All @@ -3953,6 +3971,8 @@ Value* builtin_get_at(Value *arg) {
if (argc == 2) {
Value *list = arg->data.list.items[0];
int idx = (arg->data.list.items[1]->type == VAL_NUM) ? (int)arg->data.list.items[1]->data.num : 0;
/* #312: negative indices count from the end, matching []. */
if (list->type == VAL_LIST && idx < 0) idx += list->data.list.count;
if (list->type == VAL_LIST && idx >= 0 && idx < list->data.list.count) {
val_incref(list->data.list.items[idx]);
return list->data.list.items[idx];
Expand All @@ -3963,8 +3983,10 @@ Value* builtin_get_at(Value *arg) {
Value *list = arg->data.list.items[0];
int row = (arg->data.list.items[1]->type == VAL_NUM) ? (int)arg->data.list.items[1]->data.num : 0;
int col = (arg->data.list.items[2]->type == VAL_NUM) ? (int)arg->data.list.items[2]->data.num : 0;
if (list->type == VAL_LIST && row < 0) row += list->data.list.count;
if (list->type == VAL_LIST && row >= 0 && row < list->data.list.count) {
Value *rowv = list->data.list.items[row];
if (rowv->type == VAL_LIST && col < 0) col += rowv->data.list.count;
if (rowv->type == VAL_LIST && col >= 0 && col < rowv->data.list.count) {
val_incref(rowv->data.list.items[col]);
return rowv->data.list.items[col];
Expand Down
17 changes: 17 additions & 0 deletions tests/run_all_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2067,6 +2067,23 @@ check_eigs_suite "recv-blocked worker doesn't hang exit" test_spawn_channel_exit
echo "[104] Worker Arena Return (no cross-thread UAF, #302)"
check_eigs_suite "worker arena return deep-copied before detach" test_spawn_arena_return.eigs "All tests passed" 1

# [105] Builtin contract fixes (#312 negative indices, #316 predicate
# type-rejection, #317 min/max N-ary reduction) + #314: a directory as the
# script path must take the clean cannot-read-file exit, not xmalloc's
# fatal-OOM SIGABRT (ftell on a directory reports LONG_MAX).
echo "[105] Builtin Contracts (#312/#314/#316/#317)"
check_eigs_suite "negative indices, predicate rejection, min/max reduction" test_builtin_contracts.eigs "All tests passed" 1
TOTAL=$((TOTAL + 1))
DIR_OUT=$(./eigenscript ../tests 2>&1); DIR_RC=$?
if [ "$DIR_RC" -eq 1 ] && echo "$DIR_OUT" | grep -q "cannot read file"; then
PASS=$((PASS + 1))
echo " PASS: directory script arg exits 1 with a clean error"
else
FAIL=$((FAIL + 1))
echo " FAIL: directory script arg (rc=$DIR_RC, want 1 + 'cannot read file')"
echo "$DIR_OUT" | head -3
fi

# [78] spawn with multiple args (0.13.0).
echo "[78] Spawn With Multiple Args (22 checks)"
SP_OUTPUT=$(./eigenscript ../tests/test_spawn_args.eigs 2>&1); SP_OUTPUT_RC=$?
Expand Down
49 changes: 49 additions & 0 deletions tests/test_builtin_contracts.eigs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Builtin contract fixes: #312 (negative indices), #316 (string predicates
# reject non-strings), #317 (min/max are true N-ary reductions).
load_file of "lib/test.eigs"

# ---- #317: min/max N-ary reduction ----
assert_eq of [min of [5], 5, "min of 1-element list is the element"]
assert_eq of [max of [5], 5, "max of 1-element list is the element"]
assert_eq of [min of [3, 7], 3, "min 2-arg unchanged"]
assert_eq of [max of [3, 7], 7, "max 2-arg unchanged"]
assert_eq of [max of [1, 2, 3], 3, "max reduces past the first two elements"]
assert_eq of [min of [4, 2, 9, 1, 5], 1, "min N-ary reduction"]
assert_eq of [max of [-3, -1, -2], -1, "max of all-negative list"]
assert_eq of [min of (7), 7, "min of a bare number is itself (mirrors sum)"]
assert_eq of [max of [], 0, "max of empty list keeps the 0 fallback"]
assert_eq of [max of ["a", 5], 0, "non-number element keeps the 0 fallback"]

# ---- #316: string predicates reject non-string operands ----
assert_eq of [index_of of [[1, 2, 3], 2], -1, "index_of list haystack is a miss"]
assert_eq of [index_of of [123, 2], -1, "index_of number haystack is a miss"]
assert_eq of [index_of of ["abc", 5], -1, "index_of non-string needle is a miss"]
assert_eq of [index_of of ["abc", "c"], 2, "index_of still finds real needles"]
assert_eq of [contains of [[1, 2, 3], 2], 0, "contains list haystack is 0"]
assert_eq of [contains of ["abc", 5], 0, "contains non-string needle is 0"]
assert_eq of [contains of ["abc", "b"], 1, "contains still matches"]
assert_eq of [starts_with of [[1, 2, 3], 1], 0, "starts_with non-strings is 0"]
assert_eq of [starts_with of ["abc", "ab"], 1, "starts_with still matches"]
assert_eq of [ends_with of [999, 9], 0, "ends_with non-strings is 0"]
assert_eq of [ends_with of ["abc", "bc"], 1, "ends_with still matches"]
assert_eq of [ends_with of ["abc", ""], 1, "empty suffix still matches a real string"]

# ---- #312: builtin accessors honor negative indices like [] ----
xs is [10, 20, 30]
assert_eq of [get_at of [xs, -1], 30, "get_at -1 is the last element"]
assert_eq of [get_at of [xs, -1], xs[-1], "get_at -1 matches the [] operator"]
assert_eq of [get_at of [xs, -3], 10, "get_at -len is the first element"]
assert_eq of [get_at of [xs, -4], 0, "get_at past-the-start keeps the 0 miss"]
set_at of [xs, -1, 99]
assert_eq of [xs[2], 99, "set_at -1 writes the last element"]
set_at of [xs, -4, 111]
assert_eq of [xs[0], 10, "set_at past-the-start stays a no-op"]
grid is [[1, 2], [3, 4]]
assert_eq of [get_at of [grid, -1, -1], 4, "2D get_at negative row and col"]
set_at of [grid, -1, -2, 77]
assert_eq of [grid[1][0], 77, "2D set_at negative row and col"]
assert_eq of [char_at of ["hello", -1], "o", "char_at -1 is the last char"]
assert_eq of [char_at of ["hello", -1], "hello"[-1], "char_at -1 matches the [] operator"]
assert_eq of [char_at of ["hi", -3], "", "char_at past-the-start keeps the empty miss"]

test_summary of null
2 changes: 1 addition & 1 deletion tests/test_string_math.eigs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ assert of [(str_upper of "ABC") == "ABC", "SU3 already upper"]
assert of [(char_at of ["hello", 0]) == "h", "CA1 first char"]
assert of [(char_at of ["hello", 4]) == "o", "CA2 last char"]
assert of [(char_at of ["hello", 5]) == "", "CA3 out of range"]
assert of [(char_at of ["hello", -1]) == "", "CA4 negative index"]
assert of [(char_at of ["hello", -1]) == "o", "CA4 negative index counts from the end (#312)"]

# ---- ends_with ----
assert of [(ends_with of ["hello.eigs", ".eigs"]) == 1, "EW1 match"]
Expand Down
Loading