Skip to content

Add List builtins: 26 new methods#9440

Open
imclerran wants to merge 9 commits into
mainfrom
imclerran/list-builtins
Open

Add List builtins: 26 new methods#9440
imclerran wants to merge 9 commits into
mainfrom
imclerran/list-builtins

Conversation

@imclerran
Copy link
Copy Markdown
Collaborator

@imclerran imclerran commented May 18, 2026

Summary

Adds new List builtins to the new (Zig) Roc compiler. Wires the methods through Builtin.roc, the low-level ident map, the interpreter, and the dev backend. WASM gets TODO trap 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 OOB
  • List.swap : List(a), U64, U64 -> List(a) — silent OOB or equal-index (returns original list)
  • List.subscript : List(item), U64 -> Try(item, [OutOfBounds, ..]) — alias for List.get, placeholder for a future list[index] operator syntax

Search:

  • 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) -> state
  • List.fold_until : List(item), state, (state, item -> [Continue(state), Break(state)]) -> state
  • List.fold_with_index_until : List(item), state, (state, item, U64 -> [Continue(state), Break(state)]) -> state

Prefix / 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_if preserve 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 aiming wrapListReplace's out_element directly at the record's value-field slot; the dev backend does the same.
  • list_swap — exposes the existing listSwap zig builtin to user code via a new roc_builtins_list_swap wrapper.

ABI fix

Surfaced by the 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 (see bc57db19f6use 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 / getBoxInfo crashed on list_of_zst / box_of_zst tags; return the .zst sentinel 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 — clean
  • All new REPL snapshot tests pass zig build snapshot -- --check-expected — covering basic + edge cases for every method added
  • Verified outputs agree across interpreter / dev / llvm backends for the methods that route through the new low-level ops
  • Existing zig unit tests in src/builtins/list.zig updated for the 3-arg CopyFn signature

Known limitations

  • WASM: list_replace_unsafe and list_swap are currently Op.@"unreachable" trap stubs. Compilation succeeds; only runtime invocation of List.replace / List.swap on 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-expanded subscript = |list, index| List.get(list, index). Worth filing a separate issue for the type-system bug.

Co-authored-by: Norbert Hajagos hajagosnorbi@gmail.com

imclerran and others added 2 commits May 18, 2026 13:59
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>
@imclerran imclerran changed the title Add List builtins: prepend, set, replace, subscript Add List builtins: prepend, set, replace, subscript, swap May 18, 2026
imclerran and others added 4 commits May 18, 2026 15:40
  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
@imclerran imclerran changed the title Add List builtins: prepend, set, replace, subscript, swap Add List builtins: 26 new methods May 19, 2026
@imclerran imclerran marked this pull request as ready for review May 19, 2026 02:02
@HajagosNorbert
Copy link
Copy Markdown
Collaborator

HajagosNorbert commented May 19, 2026

These conversations are relevant for the implementation @imclerran:
#ideas>List Builtin api (use a prev named field for the replace method's returned struct)
#ideas>List.drop_at_unordered and builtin philosophy (this is a new builtin's accepted "proposal", just a fyi, don't feel pressured to implement it)

And this message is the decision that we should change the public roc api so that we return a Try instead of silently doing a no-op, returning the same list. True for every method. The context for the decision is above this message, if you're interested:

Brendan Hansknecht: Separately, but related.... List.set and List.replace silently doing nothing on a wrong index feels like a mistake. Given how much roc focuses on correctness, I feel like this probably should defailult to crashing as an out of bounds access. Same as an integer overflow. They feel related to me.
...
Richard Feldman: I'd say let's make them all Try for now and see how it goes
Richard Feldman: if it's super annoying in practice then we can use that data point to justify the other design

I know it's impossible to know about these without being there in the conversation, so I'm linking them.

@imclerran
Copy link
Copy Markdown
Collaborator Author

@HajagosNorbert Thank you very much for linking these! Very helpful context.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Comment thread src/build/roc/Builtin.roc
# 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 }
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread src/build/roc/Builtin.roc
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, ..])
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread test/snapshots/repl/list_starts_with_empty_prefix.md Outdated
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants