diff --git a/docs/BUILTINS.md b/docs/BUILTINS.md index 207b7b1..17d6b98 100644 --- a/docs/BUILTINS.md +++ b/docs/BUILTINS.md @@ -64,11 +64,13 @@ numeric fast paths used by reassignment and `unobserved` blocks. | `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 | +| `list_slice` | `list_slice of [list, start, end]` | New list with the elements of [start, end) — dual of `copy_into`. Negative indices count from the end, like `[]`; bounds then clamp to [0, len]. `start >= end` gives `[]`. Never raises on bounds | | `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 | | `sort` | `sort of list` | Sort an all-number or all-string list in-place (numeric / lexicographic). Mixed or non-scalar elements raise — use `sort_by` for records. Returns the list | | `list_truncate` | `list_truncate of [list, new_len]` | Shrink list in-place to new_len items. No-op if new_len >= length. Returns the list | | `list_remove_at` | `list_remove_at of [list, index]` | Remove element at index, shift tail down (mutates). No-op if out of bounds. Returns the list | +| `list_insert_at` | `list_insert_at of [list, index, value]` | Insert value at index, shift tail up (mutates) — dual of `list_remove_at`. `index == len` appends; any other out-of-bounds index is a no-op. Returns the list | | `sort_by` | `sort_by of [list, key_fn]` | Sort list by numeric keys from key_fn (qsort, O(n log n), stable). Returns a new sorted list | | `dispatch` | `dispatch of [table, key, arg]` | Index list `table` by numeric `key` and call the resulting function with `arg` — a jump table (mirrors the `OP_DISPATCH` fast path). `key` must be a number. An ordinary builtin, not a special form: a user binding of the name wins (#459 — the fast path steps aside for any unit that rebinds `dispatch`, references `eval`, or compiles against an env where it is already rebound), and the parenthesized `dispatch of ([t, k, a])` form is one argument per #355/#405 | diff --git a/src/builtins.c b/src/builtins.c index 7a3963e..850afb7 100644 --- a/src/builtins.c +++ b/src/builtins.c @@ -4056,7 +4056,8 @@ static const char *SANDBOX_ALLOW[] = { "bit_and", "bit_not", "bit_or", "bit_shl", "bit_shr", "bit_xor", /* list / sequence (operate on the value handed in) */ "append", "concat", "contains", "copy_into", "get_at", "index_of", "len", - "list_remove_at", "list_truncate", "set_at", "sort", "sort_by", "range", + "list_insert_at", "list_remove_at", "list_slice", "list_truncate", "set_at", + "sort", "sort_by", "range", /* dict */ "dict_set", "dict_remove", "has_key", "keys", "values", /* string + regex (pure) */ @@ -4241,6 +4242,38 @@ Value* builtin_copy_into(Value *arg) { return dest; } +/* ==== BUILTIN: list_slice ==== */ +/* list_slice of [list, start, end] → new list with the elements of [start, end). + * Dual of copy_into (which copies a range IN). Negative indices count from the + * end (like get_at/set_at, #312); both bounds then clamp to [0, len]. + * start >= end gives []. Never raises on bounds. */ +Value* builtin_list_slice(Value *arg) { + if (!arg || arg->type != VAL_LIST || arg->data.list.count < 3) return make_null(); + Value *list = arg->data.list.items[0]; + if (!list || list->type != VAL_LIST) return make_null(); + Value *start_v = arg->data.list.items[1]; + Value *end_v = arg->data.list.items[2]; + if (!start_v || start_v->type != VAL_NUM || !end_v || end_v->type != VAL_NUM) + return make_null(); + int n = list->data.list.count; + int start = (int)start_v->data.num; + int end = (int)end_v->data.num; + if (start < 0) start += n; + if (end < 0) end += n; + if (start < 0) start = 0; + if (start > n) start = n; + if (end < 0) end = 0; + if (end > n) end = n; + if (start >= end) return make_list(0); + int out_n = end - start; + /* #292: charge the new slots, same as concat. */ + if (!sandbox_charge((size_t)out_n * sizeof(Value *))) return make_null(); + Value *result = make_list(out_n); + for (int i = start; i < end; i++) + list_append(result, list->data.list.items[i]); + return result; +} + /* ==== BUILTIN: num_copy ==== */ /* num_copy of val → fresh heap-allocated copy of a numeric Value. * Use to extract a scalar from arena before arena_reset. */ @@ -6319,6 +6352,40 @@ Value* builtin_list_remove_at(Value *arg) { return list; } +/* list_insert_at of [list, index, value] — insert value at index, shift tail + * up. Dual of list_remove_at. index == count appends; any other out-of-bounds + * index is a no-op. Returns the list. */ +Value* builtin_list_insert_at(Value *arg) { + if (!arg || arg->type != VAL_LIST || arg->data.list.count < 3) return make_null(); + Value *list = arg->data.list.items[0]; + Value *idx_val = arg->data.list.items[1]; + Value *val = arg->data.list.items[2]; + if (!list || list->type != VAL_LIST) return make_null(); + if (!idx_val || idx_val->type != VAL_NUM) return list; + int idx = (int)idx_val->data.num; + int count = list->data.list.count; + if (idx < 0 || idx > count) return list; + if (idx == count) { + list_append(list, val); + return list; + } + /* Grow by one slot (list_append handles capacity + arena lists), then move + * the tail up one place and drop the value into the vacated slot. + * list_append increfs the old last element, but the memmove overwrites + * its original slot with a raw pointer copy (no decref) — balance that + * spurious incref afterwards so old_last keeps exactly the one reference + * its single remaining slot owns. It still occupies slot [count] at that + * point, so the decref cannot free it. */ + Value *old_last = list->data.list.items[count - 1]; + list_append(list, old_last); + memmove(&list->data.list.items[idx + 1], &list->data.list.items[idx], + (count - idx) * sizeof(Value *)); + val_incref(val); + list->data.list.items[idx] = val; + val_decref(old_last); + return list; +} + /* sort_by of [list, key_fn] — sort list by numeric keys from key_fn. * Evaluates key_fn once per element, then qsorts by key (ascending). * Stable tiebreak by original index. Returns a NEW sorted list. */ @@ -6689,6 +6756,7 @@ void register_builtins(Env *env) { env_set_local_owned(env, "tensor_load", make_builtin(builtin_tensor_load)); #endif /* !EIGENSCRIPT_FREESTANDING */ env_set_local_owned(env, "copy_into", make_builtin(builtin_copy_into)); + env_set_local_owned(env, "list_slice", make_builtin(builtin_list_slice)); env_set_local_owned(env, "num_copy", make_builtin(builtin_num_copy)); env_set_local_owned(env, "concat", make_builtin(builtin_concat)); env_set_local_owned(env, "range", make_builtin(builtin_range)); @@ -6745,6 +6813,7 @@ void register_builtins(Env *env) { env_set_local_owned(env, "sort", make_builtin(builtin_sort)); env_set_local_owned(env, "list_truncate", make_builtin(builtin_list_truncate)); env_set_local_owned(env, "list_remove_at", make_builtin(builtin_list_remove_at)); + env_set_local_owned(env, "list_insert_at", make_builtin(builtin_list_insert_at)); env_set_local_owned(env, "sort_by", make_builtin(builtin_sort_by)); env_set_local_owned(env, "close_channel", make_builtin(builtin_close_channel)); env_set_local_owned(env, "channel_closed", make_builtin(builtin_channel_closed)); diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index 2017d35..b7bea05 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -2360,6 +2360,36 @@ else fi echo "" +# [121] list_insert_at builtin +echo "[121] List Insert At (8 checks)" +LIA_OUTPUT=$(./eigenscript ../tests/test_list_insert_at.eigs 2>&1); LIA_OUTPUT_RC=$? +if rc_ok "$LIA_OUTPUT_RC" "$LIA_OUTPUT" && echo "$LIA_OUTPUT" | grep -q "All tests passed"; then + TOTAL=$((TOTAL + 8)) + PASS=$((PASS + 8)) + echo " PASS: all 8 list_insert_at checks" +else + TOTAL=$((TOTAL + 8)) + FAIL=$((FAIL + 8)) + echo " FAIL: list_insert_at tests" + echo "$LIA_OUTPUT" | grep -iE "assert|error|FAIL" | head -5 +fi +echo "" + +# [122] list_slice builtin +echo "[122] List Slice (8 checks)" +LSL_OUTPUT=$(./eigenscript ../tests/test_list_slice.eigs 2>&1); LSL_OUTPUT_RC=$? +if rc_ok "$LSL_OUTPUT_RC" "$LSL_OUTPUT" && echo "$LSL_OUTPUT" | grep -q "All tests passed"; then + TOTAL=$((TOTAL + 8)) + PASS=$((PASS + 8)) + echo " PASS: all 8 list_slice checks" +else + TOTAL=$((TOTAL + 8)) + FAIL=$((FAIL + 8)) + echo " FAIL: list_slice tests" + echo "$LSL_OUTPUT" | grep -iE "assert|error|FAIL" | head -5 +fi +echo "" + # [65] sort_by builtin echo "[65] Sort By (9 checks)" SBY_OUTPUT=$(./eigenscript ../tests/test_sort_by.eigs 2>&1); SBY_OUTPUT_RC=$? diff --git a/tests/test_list_insert_at.eigs b/tests/test_list_insert_at.eigs new file mode 100644 index 0000000..96ff20e --- /dev/null +++ b/tests/test_list_insert_at.eigs @@ -0,0 +1,81 @@ +checks is 0 +passed is 0 + +# T1: insert middle element +a is [10, 20, 40] +list_insert_at of [a, 2, 30] +checks += 1 +if (len of a) == 4 and a[0] == 10 and a[1] == 20 and a[2] == 30 and a[3] == 40: + passed += 1 + +# T2: insert at start (prepend) +b is [10, 20, 30] +list_insert_at of [b, 0, 5] +checks += 1 +if (len of b) == 4 and b[0] == 5 and b[1] == 10 and b[3] == 30: + passed += 1 + +# T3: insert at index == length (append) +c is [10, 20, 30] +list_insert_at of [c, 3, 99] +checks += 1 +if (len of c) == 4 and c[2] == 30 and c[3] == 99: + passed += 1 + +# T4: out-of-bounds negative — no-op +d is [1, 2, 3] +list_insert_at of [d, -1, 9] +checks += 1 +if (len of d) == 3 and d[0] == 1 and d[2] == 3: + passed += 1 + +# T5: out-of-bounds too large — no-op +e is [1, 2, 3] +list_insert_at of [e, 5, 9] +checks += 1 +if (len of e) == 3 and e[0] == 1 and e[2] == 3: + passed += 1 + +# T6: insert into empty list +f is [] +list_insert_at of [f, 0, 42] +checks += 1 +if (len of f) == 1 and f[0] == 42: + passed += 1 + +# T7: returns the list +g is [1, 2, 3] +result is list_insert_at of [g, 1, 99] +checks += 1 +if (len of result) == 4 and result[0] == 1 and result[1] == 99 and result[2] == 2: + passed += 1 + +# T8: dual round-trip — insert then remove_at restores the original +h is [1, 2, 3, 4] +list_insert_at of [h, 1, 99] +list_remove_at of [h, 1] +checks += 1 +if (len of h) == 4 and h[0] == 1 and h[1] == 2 and h[2] == 3 and h[3] == 4: + passed += 1 + +# T9: repeated middle-inserts — stresses the shifted-tail path (prepend at +# index 0 forces a full tail memmove every iteration; also a refcount-leak +# regression guard for the old-last-element slot) +s is [] +n is 1 +loop while n <= 200: + list_insert_at of [s, 0, n] + n is n + 1 +total is 0 +j is 0 +loop while j < len of s: + total is total + s[j] + j is j + 1 +checks += 1 +if (len of s) == 200 and s[0] == 200 and s[199] == 1 and total == 20100: + passed += 1 + +if passed == checks: + print of "All tests passed" +else: + print of f"FAIL: {passed}/{checks} passed" diff --git a/tests/test_list_slice.eigs b/tests/test_list_slice.eigs new file mode 100644 index 0000000..38a5a45 --- /dev/null +++ b/tests/test_list_slice.eigs @@ -0,0 +1,56 @@ +checks is 0 +passed is 0 + +# T1: normal range — [start, end) +a is list_slice of [[1, 2, 3, 4, 5], 1, 4] +checks += 1 +if (len of a) == 3 and a[0] == 2 and a[1] == 3 and a[2] == 4: + passed += 1 + +# T2: full range +b is list_slice of [[1, 2, 3], 0, 3] +checks += 1 +if (len of b) == 3 and b[0] == 1 and b[2] == 3: + passed += 1 + +# T3: start >= end gives [] +c is list_slice of [[1, 2, 3], 2, 2] +checks += 1 +if (len of c) == 0: + passed += 1 + +# T4: out-of-bounds clamps, never raises — [] for an empty intersection +d is list_slice of [[1, 2], 5, 9] +checks += 1 +if (len of d) == 0: + passed += 1 + +# T5: end past length clamps to length +e is list_slice of [[1, 2, 3], 1, 99] +checks += 1 +if (len of e) == 2 and e[0] == 2 and e[1] == 3: + passed += 1 + +# T6: negative indices count from the end +f is list_slice of [[1, 2, 3, 4, 5], -3, -1] +checks += 1 +if (len of f) == 2 and f[0] == 3 and f[1] == 4: + passed += 1 + +# T7: returns a new list — the source is unchanged +g is [1, 2, 3, 4] +s is list_slice of [g, 1, 3] +checks += 1 +if (len of g) == 4 and (len of s) == 2 and s[0] == 2 and s[1] == 3: + passed += 1 + +# T8: slice of empty list is [] +h is list_slice of [[], 0, 4] +checks += 1 +if (len of h) == 0: + passed += 1 + +if passed == checks: + print of "All tests passed" +else: + print of f"FAIL: {passed}/{checks} passed"