Add List builtins: 26 new methods#9440
Conversation
Wires four list builtins through Builtin.roc, the low-level ident map,
the interpreter, the dev backend, and the WASM backend (TODO stub for
list_replace_unsafe):
- List.prepend : List(a), a -> List(a)
- List.set : List(a), U64, a -> List(a) (silent OOB)
- List.replace : List(a), U64, a -> { list, value } (silent OOB)
- List.subscript : List(item), U64 -> Try(item, [OutOfBounds, ..])
(alias to get)
Signatures match the alpha4 (Rust compiler) conventions for set and
replace.
A new low-level op `list_replace_unsafe` returns the new list paired
with the displaced element. The interpreter handler builds the record
in place by aiming wrapListReplace's out_element directly at the
record's value-field slot; the dev backend does the same.
Also fixes a latent ABI bug surfaced by these new code paths.
listPrepend / listSwap / listReplace took a 2-arg CopyFn, but the dev
wrappers cast a 3-arg copy_fallback to that signature. The width
argument was read from an unpopulated register, silently corrupting
elements that don't have a specialized copy helper (Dec, Frac, records,
tag unions). Switched these functions to CopyFallbackFn so the width is
threaded through correctly, and dropped the @ptrCast workaround in the
dev wrappers. Norbert independently identified and fixed the same bug
on listReplace in 9306; this commit applies the fix to all three
functions.
Additional fixes pulled in from 9306:
- interpreter_layout/store.zig: getListInfo / getBoxInfo crashed on
list_of_zst / box_of_zst tags; return the .zst sentinel for those.
Side fix: LSP completion-handler test writer buffer bumped from 16K to
64K so the larger List module response (with the new builtins) fits.
Co-authored-by: Norbert Hajagos <hajagosnorbi@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires List.swap through Builtin.roc, the low-level ident map, the interpreter, the dev backend, and a WASM trap stub. swap : List(a), U64, U64 -> List(a) Returns the original list unmodified if either index is out of bounds or if both indices are equal. Matches the alpha4 (Rust compiler) signature. A new low-level op `list_swap` is added, with a `roc_builtins_list_swap` wrapper in dev_wrappers.zig (exported from static_lib.zig). The interpreter dispatches to the existing `listSwap` zig builtin (whose ABI we already corrected to CopyFallbackFn in the prior commit). WASM: emits an Op.@"unreachable" trap stub, same pattern as list_replace_unsafe. Real WASM codegen TODO in a follow-up. Tests: - test/snapshots/repl/list_swap.md (basic swap) - test/snapshots/repl/list_swap_same_index.md (no-op when i == j) - test/snapshots/repl/list_swap_oob.md (silent OOB returns input) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
update : List(a), U64, (a -> a) -> List(a)
Applies a function to the element at the given index. Returns the
original list unmodified if the index is out of bounds. Matches the
alpha4 (Rust compiler) signature.
Pure-roc implementation in Builtin.roc using the existing
list_replace_unsafe low-level op:
update = |list, index, func| if index < List.len(list) {
list_replace_unsafe(list, index, func(list_get_unsafe(list, index))).list
} else {
list
}
Tests:
- test/snapshots/repl/list_update.md (basic update via lambda)
- test/snapshots/repl/list_update_oob.md (silent OOB)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…dler The list_replace_unsafe interpreter handler declared a value_field_layout local that was never used — only its `_ = value_field_layout;` suppressions referenced it. The value-field bytes are written directly into the result record at value_field_off by listReplace's out_element parameter; no layout-aware helper is needed for that slot. Drop the unused binding and the two suppression lines. The project convention is to delete unused locals rather than mask them with `_ =`.
All pure-roc implementations in Builtin.roc; no compiler / backend changes
beyond exposing them via the existing List module block.
Signatures (matching alpha4 where applicable):
min : List(a) -> Try(a, [ListWasEmpty])
where [a.min : a, a -> a]
max : List(a) -> Try(a, [ListWasEmpty])
where [a.max : a, a -> a]
find_first : List(a), (a -> Bool) -> Try(a, [NotFound])
find_last : List(a), (a -> Bool) -> Try(a, [NotFound])
find_first_index : List(a), (a -> Bool) -> Try(U64, [NotFound])
find_last_index : List(a), (a -> Bool) -> Try(U64, [NotFound])
split_at : List(a), U64 -> { before : List(a), others : List(a) }
split_first : List(a), a -> Try({ before, after }, [NotFound])
where [a.is_eq : a, a -> Bool]
split_last : List(a), a -> Try({ before, after }, [NotFound])
where [a.is_eq : a, a -> Bool]
split_on : List(a), a -> List(List(a))
where [a.is_eq : a, a -> Bool]
split_if : List(a), (a -> Bool) -> List(List(a))
split_on_list : List(a), List(a) -> List(List(a))
where [a.is_eq : a, a -> Bool]
map2 : List(a), List(b), (a, b -> c) -> List(c)
split_on / split_if preserve empty sublists between consecutive
delimiters and at list boundaries, matching alpha4 semantics.
map2's result length is the length of the shorter input list.
Tests cover basic + edge cases for each:
- test/snapshots/repl/list_min{,_empty}.md
- test/snapshots/repl/list_max{,_empty}.md
- test/snapshots/repl/list_find_first{,_not_found}.md
- test/snapshots/repl/list_find_last{,_not_found}.md
- test/snapshots/repl/list_find_first_index{,_not_found}.md
- test/snapshots/repl/list_find_last_index{,_not_found}.md
- test/snapshots/repl/list_split_at{,_zero}.md
- test/snapshots/repl/list_split_first{,_not_found}.md
- test/snapshots/repl/list_split_last{,_not_found}.md
- test/snapshots/repl/list_split_on{,_no_match,_consecutive}.md
- test/snapshots/repl/list_split_on_list{,_empty_delim,_no_match}.md
- test/snapshots/repl/list_map2{,_uneven}.md
…with_index_until, starts_with, ends_with
All pure-roc implementations in Builtin.roc.
Signatures:
map_with_index : List(a), (a, U64 -> b) -> List(b)
fold_with_index : List(item), state, (state, item, U64 -> state) -> state
fold_until : List(item), state,
(state, item -> [Continue(state), Break(state)]) -> state
fold_with_index_until : List(item), state,
(state, item, U64 -> [Continue(state), Break(state)]) -> state
starts_with : List(a), List(a) -> Bool
where [a.is_eq : a, a -> Bool]
ends_with : List(a), List(a) -> Bool
where [a.is_eq : a, a -> Bool]
starts_with and ends_with require the element type to implement is_eq
(same convention as List.contains) because their implementations compare
list slices via `==`.
Also inlines the min_help / max_help helpers into List.min / List.max,
removing the separate helper bindings.
Tests cover basic + edge cases for each:
- test/snapshots/repl/list_map_with_index{,_empty}.md
- test/snapshots/repl/list_fold_with_index{,_empty}.md
- test/snapshots/repl/list_fold_until{,_break_early,_break_first,_empty}.md
- test/snapshots/repl/list_fold_with_index_until{,_break}.md
- test/snapshots/repl/list_starts_with{,_no_match,_empty_prefix}.md
- test/snapshots/repl/list_ends_with{,_no_match,_empty_suffix}.md
|
These conversations are relevant for the implementation @imclerran: And this message is the decision that we should change the public roc api so that we return a
I know it's impossible to know about these without being there in the conversation, so I'm linking them. |
|
@HajagosNorbert Thank you very much for linking these! Very helpful context. |
There was a problem hiding this comment.
The diff in this file fixes #9252
(helping with the administration is the most I can do right now)
Per discussion on the PR (and prior Zulip context), index-taking List functions should not silently no-op on an out-of-bounds index. set, update, and swap now return Try(List(a), [OutOfBounds, ..]). set and update are pure roc, so the change is local to Builtin.roc. For swap, expose it as `list_swap_unsafe` and define `swap` in roc as a bounds-checking wrapper, matching the existing list_set_unsafe / list_replace_unsafe pattern. Also refreshes the stale `replace` snapshots and doc examples. Co-Authored-By: HajagosNorbert <hajagosnorbi@gmail.com>
| # Implemented by the compiler, does not perform bounds checks. | ||
| # Returns the new list paired with the value that was replaced. | ||
| list_replace_unsafe : List(item), U64, item -> { list : List(item), value : item } | ||
| list_replace_unsafe : List(item), U64, item -> { list : List(item), prev : item } |
There was a problem hiding this comment.
Conform to previously accepted design:
https://roc.zulipchat.com/#narrow/channel/304641-ideas/topic/List.20Builtin.20api/with/579573051
| replace : List(a), U64, a -> { list : List(a), value : a } | ||
| replace = |list, index, new_value| if index < List.len(list) { | ||
| list_replace_unsafe(list, index, new_value) | ||
| replace : List(a), U64, a -> Try({ list : List(a), prev : a }, [OutOfBounds, ..]) |
There was a problem hiding this comment.
Conform to previously accepted designs:
#ideas > subscript operator @ 💬
#ideas > List Builtin api @ 💬
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The starts_with / ends_with empty-prefix / empty-suffix snapshots were using `List.drop_first([1], 1)` as a workaround for what was assumed to be an empty-list inference issue. Verified that bare `[]` works for these call sites today, so revert the workaround.
Summary
Adds new
Listbuiltins to the new (Zig) Roc compiler. Wires the methods throughBuiltin.roc, the low-level ident map, the interpreter, and the dev backend. WASM getsTODOtrap stubs for two new low-level ops.Function signatures match alpha4 (the Rust compiler) for operations that existed there.
Index / mutation:
List.prepend : List(a), a -> List(a)List.set : List(a), U64, a -> List(a)— silent OOB (returns original list unmodified)List.replace : List(a), U64, a -> { list : List(a), value : a }— silent OOB (returns original list paired with the input value)List.update : List(a), U64, (a -> a) -> List(a)— silent OOBList.swap : List(a), U64, U64 -> List(a)— silent OOB or equal-index (returns original list)List.subscript : List(item), U64 -> Try(item, [OutOfBounds, ..])— alias forList.get, placeholder for a futurelist[index]operator syntaxSearch:
List.find_first : List(a), (a -> Bool) -> Try(a, [NotFound])List.find_last : List(a), (a -> Bool) -> Try(a, [NotFound])List.find_first_index : List(a), (a -> Bool) -> Try(U64, [NotFound])List.find_last_index : List(a), (a -> Bool) -> Try(U64, [NotFound])Map / fold variants:
List.map2 : List(a), List(b), (a, b -> c) -> List(c)List.map_with_index : List(a), (a, U64 -> b) -> List(b)List.fold_with_index : List(item), state, (state, item, U64 -> state) -> stateList.fold_until : List(item), state, (state, item -> [Continue(state), Break(state)]) -> stateList.fold_with_index_until : List(item), state, (state, item, U64 -> [Continue(state), Break(state)]) -> statePrefix / suffix tests:
List.starts_with : List(a), List(a) -> Bool where [a.is_eq : a, a -> Bool]List.ends_with : List(a), List(a) -> Bool where [a.is_eq : a, a -> Bool]Splitting:
List.split_at : List(a), U64 -> { before : List(a), others : List(a) }List.split_first : List(a), a -> Try({ before, after }, [NotFound]) where [a.is_eq : a, a -> Bool]List.split_last : List(a), a -> Try({ before, after }, [NotFound]) where [a.is_eq : a, a -> Bool]List.split_on : List(a), a -> List(List(a)) where [a.is_eq : a, a -> Bool]List.split_if : List(a), (a -> Bool) -> List(List(a))List.split_on_list : List(a), List(a) -> List(List(a)) where [a.is_eq : a, a -> Bool]split_on/split_ifpreserve empty sublists between consecutive delimiters and at list boundaries, matching alpha4 semantics.Min / max:
List.min : List(a) -> Try(a, [ListWasEmpty]) where [a.min : a, a -> a]List.max : List(a) -> Try(a, [ListWasEmpty]) where [a.max : a, a -> a]New low-level ops
list_replace_unsafe— returns the new list paired with the displaced element. The interpreter handler builds the record in place by aimingwrapListReplace'sout_elementdirectly at the record's value-field slot; the dev backend does the same.list_swap— exposes the existinglistSwapzig builtin to user code via a newroc_builtins_list_swapwrapper.ABI fix
Surfaced by the new code paths:
listPrepend/listSwap/listReplacetook a 2-argCopyFn, but the dev wrappers cast a 3-argcopy_fallbackto that signature. Thewidthargument was read from an unpopulated register, silently corrupting elements that don't have a specialized copy helper (Dec, Frac, records, tag unions). Switched these functions toCopyFallbackFnso the width is threaded through correctly, and dropped the@ptrCastworkaround in the dev wrappers.Norbert independently identified and fixed the same bug on
listReplacein #9306 (seebc57db19f6— use copyFnFallback in listReplace). This PR applies the fix to all three affected functions; he's co-authored on the first commit.Other fixes pulled in
src/interpreter_layout/store.zig:getListInfo/getBoxInfocrashed onlist_of_zst/box_of_zsttags; return the.zstsentinel for those (also from add builtin List.{set, replace, subscript} #9306).src/lsp/test/handler_tests.zig: completion-handler test writer buffer bumped from 16K to 64K so the larger List module completion response (with the new builtins) fits.Test plan
zig build roc— cleanzig build snapshot -- --check-expected— covering basic + edge cases for every method addedsrc/builtins/list.zigupdated for the 3-arg CopyFn signatureKnown limitations
list_replace_unsafeandlist_swapare currentlyOp.@"unreachable"trap stubs. Compilation succeeds; only runtime invocation ofList.replace/List.swapon WASM will trap. Full WASM codegen TODO in a follow-up (the existing test pipeline for WASM is manual-only, so improving WASM coverage is a separate infrastructure concern).subscript = get(pure alias form): hits a compiler invariant ("concrete dependency callable instance requested function type has no checked payload"). Workaround in this PR is the eta-expandedsubscript = |list, index| List.get(list, index). Worth filing a separate issue for the type-system bug.Co-authored-by: Norbert Hajagos hajagosnorbi@gmail.com