diff --git a/.github/workflows/bleeding-check.yml b/.github/workflows/bleeding-check.yml index df42d9ff39..4b3b3368c0 100644 --- a/.github/workflows/bleeding-check.yml +++ b/.github/workflows/bleeding-check.yml @@ -26,7 +26,7 @@ jobs: version: nightly - name: run tests - timeout-minutes: 10 + timeout-minutes: 20 uses: ./.github/actions/test - name: Test new allocator diff --git a/.github/workflows/pre-release-check.yml b/.github/workflows/pre-release-check.yml index 8bbc8cec7a..9d6b64e27c 100644 --- a/.github/workflows/pre-release-check.yml +++ b/.github/workflows/pre-release-check.yml @@ -148,7 +148,7 @@ jobs: run: moon check --deny-warn - name: run tests - timeout-minutes: 10 + timeout-minutes: 20 uses: ./.github/actions/test - name: moon bundle diff --git a/.github/workflows/stable-check.yml b/.github/workflows/stable-check.yml index a976fecbdb..4c5750e7c7 100644 --- a/.github/workflows/stable-check.yml +++ b/.github/workflows/stable-check.yml @@ -41,7 +41,7 @@ jobs: git diff --exit-code - name: run tests - timeout-minutes: 10 + timeout-minutes: 20 uses: ./.github/actions/test - name: moon bundle diff --git a/CHANGELOG.md b/CHANGELOG.md index 290ec431f3..6848211ce2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,14 @@ changelog should follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) - Core commit: `bd827dc85` - Added `Debug` trait with `derive(Debug)` support, including `ignore=[..]` configuration for non-debuggable nested types - Added new `moonbitlang/async` APIs including `@process.spawn`, advisory file locking, `@fs.tmpdir`, `@async.all`, and `@async.any` +- Added `Array::release_unused(placeholder~)`, which overwrites the unused capacity of an array in place with a placeholder, releasing the elements that earlier removals left there +- Added `Deque::release_unused(placeholder~)`, which overwrites the unused capacity of a deque in place with a placeholder, releasing the elements that earlier removals left there #### Changed +- `Array` shrinking operations no longer null out the slots they vacate, so an `ArrayView` created before the mutation can no longer read uninitialized memory (previously a segfault on native and `null` on wasm-gc; the JavaScript backend is unchanged, and such a view still observes `undefined` past the array's current length there). Mutating an array while a view of it is alive remains a program error, but it now yields unspecified *valid* values rather than undefined behavior. The removed elements stay reachable from the buffer until later pushes reuse those slots, the buffer grows, or the array is dropped -- uniformly, `clear` included, which no longer releases them. `Array::release_unused(placeholder~)` overwrites the unused capacity in place with a placeholder to release them on demand, while `shrink_to_fit`, which already reallocated, lets them go with the old buffer. No existing signature changes +- `Deque` shrinking operations no longer null out the slots they vacate, so an `ArrayView` obtained from `Deque::as_views` before the mutation can no longer read uninitialized memory (previously a segfault on native, `null`/`undefined` elsewhere). Mutating a deque while a view of it is alive remains a program error, but it now yields unspecified *valid* values rather than undefined behavior. The removed elements stay reachable from the buffer until later pushes reuse those slots, the buffer grows, or the deque is dropped -- uniformly, `clear` included, which no longer releases them. `Deque::release_unused(placeholder~)` overwrites the unused capacity in place with a placeholder to release them on demand, while `shrink_to_fit`, which already reallocated, lets them go with the old buffer. No existing signature changes +- **BREAKING**: `@json.parse` now rejects unpaired `\uXXXX` surrogate escapes in strings, raising `ParseError::InvalidChar` at the backslash that opens the offending escape; an escaped leading surrogate must be followed immediately by an escaped trailing surrogate, and the pair decodes to the character it denotes. Previously such escapes were decoded unchecked and produced a `String` that was not well-formed Unicode. `@json.valid` reports the same documents as invalid. JSON emitted by `JSON.stringify` in JavaScript can contain these escapes, so input that JavaScript and Python accept may now be rejected — as it is by Rust's serde_json - `@json.inspect` has been migrated to `json_inspect` - `String::sub` and `StringView::sub` now panic on invalid indices instead of raising `CreatingViewError`. The `CreatingViewError` type has been removed. diff --git a/NOTICE b/NOTICE index 064f7ad888..1abf8372f7 100644 --- a/NOTICE +++ b/NOTICE @@ -42,6 +42,15 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. File random/random.mbt is adapted from Golang's [`math/rand/v2`](https://pkg.go.dev/math/rand/v2) package. +Files `internal/strconv/strconv_eisel_lemire.mbt` and +`internal/strconv/strconv_eisel_lemire_table.mbt` are adapted from Go 1.26.2's +`internal/strconv/atofeisel.go` and generated `internal/strconv/pow10tab.go`. + +Files `bigint/arith_wide.mbt` and `bigint/bigint_wide.mbt` are adapted from +Go's `math/big` package, specifically `src/math/big/arith.go`, +`src/math/big/nat.go`, `src/math/big/natmul.go`, and +`src/math/big/natdiv.go`. + License from Golang: Copyright 2009 The Go Authors. diff --git a/README.md b/README.md index 9bbc1df0ad..d3cfe0e110 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ graph BT prelude["prelude — auto-opened re-exports
from builtin, debug, test, bigint, …"] --> builtin values["value types
bool · byte · char · unit · option · result
error · ref · tuple · cmp · range · lazy"] --> builtin numbers["numerics
int · uint · int16 · uint16 · int64 · uint64
double · float · bigint · v128 · math"] --> builtin - text["text & binary
string · bytes · buffer · encoding/*
strconv · lexbuf"] --> builtin + text["text & binary
string · bytes · buffer · encoding/*
lexbuf"] --> builtin mut["mutable collections
array · hashmap · hashset · deque · queue
priority_queue · set · sorted_map · sorted_set"] --> builtin imm["immutable collections
list · lazy_list · immut/vector
immut/hashmap · immut/sorted_map · …"] --> builtin tools["algorithms & tooling
json · diff · random · quickcheck · argparse
bench · test · debug · env"] --> builtin diff --git a/argparse/arg_spec.mbt b/argparse/arg_spec.mbt index ecf567bcc5..f5688de447 100644 --- a/argparse/arg_spec.mbt +++ b/argparse/arg_spec.mbt @@ -29,7 +29,7 @@ pub(all) enum FlagAction { ///| /// Behavior for option args. /// -/// - `Set` keeps the last provided value. +/// - `Set` accepts the value once; a repeated occurrence is an error. /// - `Append` keeps all provided values in order. pub(all) enum OptionAction { Set @@ -89,8 +89,10 @@ pub struct FlagArg { /// - At least one of `short`, `long`, or `env` must be available. /// - `global=true` makes the flag available in subcommands. /// - `negatable=true` accepts `--no-` for long flags. -/// - If `env` is set, accepted boolean values are: -/// `1`, `0`, `true`, `false`, `yes`, `no`, `on`, `off`. +/// - If `env` is set on a `SetTrue` / `SetFalse` flag, accepted boolean +/// values are: `1`, `0`, `true`, `false`, `yes`, `no`, `on`, `off`. +/// - If `env` is set on a `Count` flag, the value must be a non-negative +/// integer. #alias(new, deprecated="Use `FlagArg()` instead") pub fn FlagArg::FlagArg( name : StringView, @@ -240,11 +242,6 @@ pub fn PositionArg::PositionArg( } } -///| -fn arg_name(arg : Arg) -> String { - arg.name -} - ///| fn range_allows_multiple(range : ValueRange?) -> Bool { range is Some(r) && diff --git a/argparse/argparse_blackbox_test.mbt b/argparse/argparse_blackbox_test.mbt deleted file mode 100644 index 6d818b91b8..0000000000 --- a/argparse/argparse_blackbox_test.mbt +++ /dev/null @@ -1,3467 +0,0 @@ -// Copyright 2026 International Digital Economy Academy -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -///| -test "render help snapshot with groups and hidden entries" { - let cmd = @argparse.Command( - "render", - groups=[ - ArgGroup("mode", required=true, multiple=false, args=[ - "fast", "slow", "path", - ]), - ], - subcommands=[ - Command("run", about="run"), - Command("hidden", about="hidden", hidden=true), - ], - flags=[ - FlagArg("fast", short='f', long="fast"), - FlagArg("slow", long="slow", hidden=true), - FlagArg("cache", long="cache", negatable=true, about="cache"), - ], - options=[ - OptionArg( - "path", - short='p', - long="path", - env="PATH_ENV", - default_values=["a", "b"], - required=true, - ), - ], - positionals=[ - PositionArg("target", num_args=@argparse.ValueRange::single()), - PositionArg("rest", num_args=ValueRange(lower=0)), - PositionArg("secret", hidden=true), - ], - ) - inspect( - cmd.render_help(), - content=( - #|Usage: render --path [options] [rest...] [command] - #| - #|Commands: - #| run run - #| help Print help for the subcommand(s). - #| - #|Arguments: - #| target - #| rest... - #| - #|Options: - #| -h, --help Show help information. - #| -f, --fast - #| --[no-]cache cache - #| -p, --path [env: PATH_ENV] [default: a, b] - #| - #|Groups: - #| mode [required] [exclusive] -f, --fast, -p, --path - #| - ), - ) -} - -///| -test "render help conversion coverage snapshot" { - let cmd = @argparse.Command( - "shape", - groups=[ArgGroup("grp", args=["f", "opt", "pos"])], - flags=[ - FlagArg( - "f", - short='f', - about="f", - env="F_ENV", - requires=["opt"], - global=true, - hidden=true, - ), - ], - options=[ - OptionArg( - "opt", - short='o', - about="opt", - default_values=["x", "y"], - env="OPT_ENV", - allow_hyphen_values=true, - required=true, - global=true, - hidden=true, - conflicts_with=["pos"], - ), - ], - positionals=[ - PositionArg( - "pos", - about="pos", - env="POS_ENV", - default_values=["p1", "p2"], - num_args=ValueRange(lower=0, upper=2), - allow_hyphen_values=true, - requires=["opt"], - conflicts_with=["f"], - global=true, - hidden=true, - ), - ], - ) - inspect( - cmd.render_help(), - content=( - #|Usage: shape - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) -} - -///| -test "count flags and sources with pattern matching" { - let cmd = @argparse.Command("demo", flags=[ - FlagArg("verbose", short='v', long="verbose", action=Count), - ]) - let matches = cmd.parse(argv=["-v", "-v", "-v"], env=empty_env()) catch { - _ => panic() - } - assert_true(matches.flags is { "verbose": true, .. }) - assert_true(matches.flag_counts is { "verbose": 3, .. }) - assert_true(matches.sources is { "verbose": Argv, .. }) -} - -///| -test "global option merges parent and child values" { - let child = @argparse.Command("run") - let cmd = @argparse.Command( - "demo", - options=[ - OptionArg( - "profile", - short='p', - long="profile", - action=Append, - global=true, - ), - ], - subcommands=[child], - ) - - let matches = cmd.parse( - argv=["--profile", "parent", "run", "--profile", "child"], - env=empty_env(), - ) catch { - _ => panic() - } - assert_true(matches.values is { "profile": ["parent", "child"], .. }) - assert_true(matches.sources is { "profile": Argv, .. }) - assert_true( - matches.subcommand is Some(("run", sub)) && - sub.values is { "profile": ["parent", "child"], .. }, - ) -} - -///| -test "global requires is validated after parent-child merge" { - let cmd = @argparse.Command( - "demo", - options=[ - OptionArg("mode", long="mode", requires=["config"], global=true), - OptionArg("config", long="config", global=true), - ], - subcommands=[Command("run")], - ) - - let parsed = cmd.parse( - argv=["--config", "a.toml", "run", "--mode", "fast"], - env=empty_env(), - ) catch { - _ => panic() - } - assert_true( - parsed.values is { "config": ["a.toml"], "mode": ["fast"], .. } && - parsed.subcommand is Some(("run", sub)) && - sub.values is { "config": ["a.toml"], "mode": ["fast"], .. }, - ) -} - -///| -test "global append keeps parent argv over child env/default" { - let child = @argparse.Command("run") - let cmd = @argparse.Command( - "demo", - options=[ - OptionArg( - "profile", - long="profile", - action=Append, - env="PROFILE", - default_values=["def"], - global=true, - ), - ], - subcommands=[child], - ) - - let matches = cmd.parse(argv=["--profile", "parent", "run"], env={ - "PROFILE": "env", - }) catch { - _ => panic() - } - assert_true(matches.values is { "profile": ["parent"], .. }) - assert_true(matches.sources is { "profile": Argv, .. }) - assert_true( - matches.subcommand is Some(("run", sub)) && - sub.values is { "profile": ["parent"], .. } && - sub.sources is { "profile": Argv, .. }, - ) -} - -///| -test "global scalar keeps parent argv over child env/default" { - let child = @argparse.Command("run") - let cmd = @argparse.Command( - "demo", - options=[ - OptionArg( - "profile", - long="profile", - env="PROFILE", - default_values=["def"], - global=true, - ), - ], - subcommands=[child], - ) - - let matches = cmd.parse(argv=["--profile", "parent", "run"], env={ - "PROFILE": "env", - }) catch { - _ => panic() - } - assert_true(matches.values is { "profile": ["parent"], .. }) - assert_true(matches.sources is { "profile": Argv, .. }) - assert_true( - matches.subcommand is Some(("run", sub)) && - sub.values is { "profile": ["parent"], .. } && - sub.sources is { "profile": Argv, .. }, - ) -} - -///| -test "global count merges parent and child occurrences" { - let child = @argparse.Command("run") - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("verbose", short='v', action=Count, global=true)], - subcommands=[child], - ) - - let matches = cmd.parse(argv=["-v", "run", "-v", "-v"], env=empty_env()) catch { - _ => panic() - } - assert_true(matches.flag_counts is { "verbose": 3, .. }) - assert_true( - matches.subcommand is Some(("run", sub)) && - sub.flag_counts is { "verbose": 3, .. }, - ) -} - -///| -test "global count keeps parent argv over child env fallback" { - let child = @argparse.Command("run") - let cmd = @argparse.Command( - "demo", - flags=[ - FlagArg( - "verbose", - short='v', - long="verbose", - action=Count, - env="VERBOSE", - global=true, - ), - ], - subcommands=[child], - ) - - let matches = cmd.parse(argv=["-v", "run"], env={ "VERBOSE": "1" }) catch { - _ => panic() - } - assert_true(matches.flag_counts is { "verbose": 1, .. }) - assert_true(matches.sources is { "verbose": Argv, .. }) - assert_true( - matches.subcommand is Some(("run", sub)) && - sub.flag_counts is { "verbose": 1, .. } && - sub.sources is { "verbose": Argv, .. }, - ) -} - -///| -test "global flag keeps parent argv over child env fallback" { - let child = @argparse.Command("run") - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("verbose", long="verbose", env="VERBOSE", global=true)], - subcommands=[child], - ) - - let matches = cmd.parse(argv=["--verbose", "run"], env={ "VERBOSE": "0" }) catch { - _ => panic() - } - assert_true(matches.flags is { "verbose": true, .. }) - assert_true(matches.sources is { "verbose": Argv, .. }) - assert_true( - matches.subcommand is Some(("run", sub)) && - sub.flags is { "verbose": true, .. } && - sub.sources is { "verbose": Argv, .. }, - ) -} - -///| -test "default subcommand dispatches through normal child parsing" { - let tui = @argparse.Command( - "tui", - about="Start interactive UI", - flags=[FlagArg("trace", short='t')], - options=[OptionArg("theme", long="theme")], - positionals=[PositionArg("workspace")], - ) - let mcp = @argparse.Command("mcp", about="Run MCP server", options=[ - OptionArg("port", long="port"), - ]) - let cmd = @argparse.Command( - "openseek", - options=[OptionArg("config", long="config", global=true)], - flags=[FlagArg("verbose", short='v', action=Count, global=true)], - subcommands=[tui, mcp], - default_subcommand="tui", - ) - - let bare = cmd.parse(argv=[], env=empty_env()) catch { _ => panic() } - assert_true(bare.subcommand is Some(("tui", _))) - - let defaulted = cmd.parse( - argv=["--config", "config.toml", "--theme", "dark", "workspace"], - env=empty_env(), - ) catch { - _ => panic() - } - assert_true(defaulted.values is { "config": ["config.toml"], .. }) - assert_true( - defaulted.subcommand is Some(("tui", sub)) && - sub.values - is { - "config": ["config.toml"], - "theme": ["dark"], - "workspace": ["workspace"], - .. - }, - ) - - let short_global = cmd.parse(argv=["-v", "--theme", "dark"], env=empty_env()) catch { - _ => panic() - } - assert_true(short_global.flag_counts is { "verbose": 1, .. }) - assert_true( - short_global.subcommand is Some(("tui", sub)) && - sub.values is { "theme": ["dark"], .. } && - sub.flag_counts is { "verbose": 1, .. }, - ) - - let mixed_short = cmd.parse(argv=["-vt"], env=empty_env()) catch { - _ => panic() - } - assert_true(mixed_short.flag_counts is { "verbose": 1, .. }) - assert_true( - mixed_short.subcommand is Some(("tui", sub)) && - sub.flags is { "trace": true, .. } && - sub.flag_counts is { "verbose": 1, .. }, - ) - - let explicit = cmd.parse(argv=["mcp", "--port", "9000"], env=empty_env()) catch { - _ => panic() - } - assert_true( - explicit.subcommand is Some(("mcp", sub)) && - sub.values is { "port": ["9000"], .. }, - ) -} - -///| -test "default subcommand gives exact subcommands precedence over positionals" { - let cmd = @argparse.Command( - "openseek", - subcommands=[ - Command("tui", positionals=[PositionArg("workspace")]), - Command("mcp"), - ], - default_subcommand="tui", - ) - - let explicit = cmd.parse(argv=["mcp"], env=empty_env()) catch { _ => panic() } - assert_true(explicit.subcommand is Some(("mcp", _))) - - let positional = cmd.parse(argv=["project"], env=empty_env()) catch { - _ => panic() - } - assert_true( - positional.subcommand is Some(("tui", sub)) && - sub.values is { "workspace": ["project"], .. }, - ) - - let explicit_default = cmd.parse(argv=["tui", "mcp"], env=empty_env()) catch { - _ => panic() - } - assert_true( - explicit_default.subcommand is Some(("tui", sub)) && - sub.values is { "workspace": ["mcp"], .. }, - ) - - let after_dash_dash = cmd.parse(argv=["--", "mcp"], env=empty_env()) catch { - _ => panic() - } - assert_true( - after_dash_dash.subcommand is Some(("tui", sub)) && - sub.values is { "workspace": ["mcp"], .. }, - ) -} - -///| -test "default subcommand help annotation and child error context" { - let cmd = @argparse.Command( - "openseek", - options=[OptionArg("config", long="config", about="config", global=true)], - subcommands=[ - Command("tui", about="Start interactive UI", options=[ - OptionArg("theme", long="theme", about="theme"), - ]), - Command("mcp", about="Run MCP server"), - ], - default_subcommand="tui", - ) - - inspect( - cmd.render_help(), - content=( - #|Usage: openseek [options] [command] - #| - #|Commands: - #| tui Start interactive UI (default) - #| mcp Run MCP server - #| help Print help for the subcommand(s). - #| - #|Options: - #| -h, --help Show help information. - #| --config config - #| - ), - ) - - try cmd.parse(argv=["--unknown"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--unknown' found - #| - #|Usage: openseek tui [options] - #| - #|Start interactive UI - #| - #|Options: - #| -h, --help Show help information. - #| --config config - #| --theme theme - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "default subcommand validation rejects ambiguous root configuration" { - try - @argparse.Command( - "demo", - subcommands=[Command("run")], - default_subcommand="missing", - ).parse(argv=[], env=empty_env()) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: default_subcommand must name a visible subcommand: missing - ), - ) - } noraise { - _ => panic() - } - - try - @argparse.Command( - "demo", - subcommands=[Command("run")], - subcommand_required=true, - default_subcommand="run", - ).parse(argv=[], env=empty_env()) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: default_subcommand cannot be used with subcommand_required - ), - ) - } noraise { - _ => panic() - } - - try - @argparse.Command( - "demo", - subcommands=[Command("run")], - arg_required_else_help=true, - default_subcommand="run", - ).parse(argv=[], env=empty_env()) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: default_subcommand cannot be used with arg_required_else_help - ), - ) - } noraise { - _ => panic() - } - - try - @argparse.Command( - "demo", - options=[OptionArg("mode", long="mode")], - subcommands=[Command("run")], - default_subcommand="run", - ).parse(argv=[], env=empty_env()) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: default_subcommand only supports global root flags/options - ), - ) - } noraise { - _ => panic() - } - - try - @argparse.Command( - "demo", - flags=[FlagArg("verbose", long="verbose", global=true)], - groups=[ArgGroup("verbosity", args=["verbose"])], - subcommands=[Command("run")], - default_subcommand="run", - ).parse(argv=[], env=empty_env()) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: default_subcommand does not support root groups - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "subcommand cannot follow positional arguments" { - let cmd = @argparse.Command("demo", positionals=[PositionArg("input")], subcommands=[ - Command("run"), - ]) - try cmd.parse(argv=["raw", "run"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: subcommand 'run' cannot be used with positional arguments - #| - #|Usage: demo [input] [command] - #| - #|Commands: - #| run - #| help Print help for the subcommand(s). - #| - #|Arguments: - #| input - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "global count source keeps env across subcommand merge" { - let child = @argparse.Command("run") - let cmd = @argparse.Command( - "demo", - flags=[ - FlagArg( - "verbose", - short='v', - long="verbose", - action=Count, - env="VERBOSE", - global=true, - ), - ], - subcommands=[child], - ) - - let matches = cmd.parse(argv=["run"], env={ "VERBOSE": "1" }) catch { - _ => panic() - } - assert_true(matches.flags is { "verbose": true, .. }) - assert_true(matches.flag_counts is { "verbose": 1, .. }) - assert_true(matches.sources is { "verbose": Env, .. }) - assert_true( - matches.subcommand is Some(("run", sub)) && - sub.flag_counts is { "verbose": 1, .. } && - sub.sources is { "verbose": Env, .. }, - ) -} - -///| -test "help subcommand styles and errors" { - let leaf = @argparse.Command("echo", about="echo") - let cmd = @argparse.Command("demo", subcommands=[leaf]) - - inspect( - leaf.render_help(), - content=( - #|Usage: echo - #| - #|echo - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - inspect( - cmd.render_help(), - content=( - #|Usage: demo [command] - #| - #|Commands: - #| echo echo - #| help Print help for the subcommand(s). - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - - try cmd.parse(argv=["help", "--bad"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected help argument: --bad - #| - #|Usage: demo [command] - #| - #|Commands: - #| echo echo - #| help Print help for the subcommand(s). - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=["help", "missing"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unknown subcommand: missing - #| - #|Usage: demo [command] - #| - #|Commands: - #| echo echo - #| help Print help for the subcommand(s). - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "subcommand help includes inherited global options" { - let leaf = @argparse.Command("echo", about="echo") - let cmd = @argparse.Command( - "demo", - flags=[ - FlagArg( - "verbose", - short='v', - long="verbose", - about="Enable verbose mode", - global=true, - ), - ], - subcommands=[leaf], - ) - - try cmd.parse(argv=["echo", "--bad"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--bad' found - #| - #|Usage: demo echo [options] - #| - #|echo - #| - #|Options: - #| -h, --help Show help information. - #| -v, --verbose Enable verbose mode - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "subcommand suggestions are only reported for parse failures" { - let cmd = @argparse.Command( - "demo", - positionals=[PositionArg("input", about="input file")], - subcommands=[Command("serve", about="serve")], - ) - let positional = cmd.parse(argv=["serv"], env=empty_env()) catch { - _ => panic() - } - assert_true(positional.values is { "input": ["serv"], .. }) - assert_true(positional.subcommand is None) - - try cmd.parse(argv=["input.txt", "serv"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected value 'serv' for '' found; no more were expected - #| - #| tip: a similar subcommand exists: 'serve' - #| - #|Usage: demo [input] [command] - #| - #|Commands: - #| serve serve - #| help Print help for the subcommand(s). - #| - #|Arguments: - #| input input file - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } - - let no_positionals = @argparse.Command("demo", subcommands=[ - Command("serve", about="serve"), - ]) - try no_positionals.parse(argv=["hel"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected value 'hel' found; no more were expected - #| - #| tip: a similar subcommand exists: 'help' - #| - #|Usage: demo [command] - #| - #|Commands: - #| serve serve - #| help Print help for the subcommand(s). - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=["help", "serv"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unknown subcommand: serv - #| - #| tip: a similar subcommand exists: 'serve' - #| - #|Usage: demo [input] [command] - #| - #|Commands: - #| serve serve - #| help Print help for the subcommand(s). - #| - #|Arguments: - #| input input file - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "help subcommand suggestions exclude hidden commands" { - let cmd = @argparse.Command("demo", subcommands=[ - Command("serve", about="serve"), - Command("secret", about="secret", hidden=true), - ]) - try cmd.parse(argv=["help", "secrt"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unknown subcommand: secrt - #| - #|Usage: demo [command] - #| - #|Commands: - #| serve serve - #| help Print help for the subcommand(s). - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "unknown argument suggestions are exposed" { - let cmd = @argparse.Command("demo", flags=[ - FlagArg("verbose", short='v', long="verbose"), - ]) - - try cmd.parse(argv=["--verbse"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--verbse' found - #| - #| tip: a similar argument exists: '--verbose' - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| -v, --verbose - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=["-x"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '-x' found - #| - #| tip: a similar argument exists: '-v' - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| -v, --verbose - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=["--zzzzzzzzzz"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--zzzzzzzzzz' found - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| -v, --verbose - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "long and short value parsing branches" { - let cmd = @argparse.Command("demo", options=[ - OptionArg("count", short='c', long="count"), - ]) - - let long_inline = cmd.parse(argv=["--count=2"], env=empty_env()) catch { - _ => panic() - } - assert_true(long_inline.values is { "count": ["2"], .. }) - - let short_inline = cmd.parse(argv=["-c=3"], env=empty_env()) catch { - _ => panic() - } - assert_true(short_inline.values is { "count": ["3"], .. }) - - let short_attached = cmd.parse(argv=["-c4"], env=empty_env()) catch { - _ => panic() - } - assert_true(short_attached.values is { "count": ["4"], .. }) - - try cmd.parse(argv=["--count"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: a value is required for '--count' but none was supplied - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| -c, --count - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=["-c"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: a value is required for '-c' but none was supplied - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| -c, --count - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "append option action is publicly selectable" { - let cmd = @argparse.Command("demo", options=[ - OptionArg("tag", long="tag", action=Append), - ]) - let appended = cmd.parse(argv=["--tag", "a", "--tag", "b"], env=empty_env()) catch { - _ => panic() - } - assert_true(appended.values is { "tag": ["a", "b"], .. }) - assert_true(appended.sources is { "tag": Argv, .. }) -} - -///| -test "negation parsing and invalid negation forms" { - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("cache", long="cache", negatable=true)], - options=[OptionArg("path", long="path")], - ) - - let off = cmd.parse(argv=["--no-cache"], env=empty_env()) catch { - _ => panic() - } - assert_true(off.flags is { "cache": false, .. }) - assert_true(off.sources is { "cache": Argv, .. }) - - try cmd.parse(argv=["--no-path"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--no-path' found - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --[no-]cache - #| --path - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=["--no-missing"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--no-missing' found - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --[no-]cache - #| --path - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=["--no-cache=1"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--no-cache=1' found - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --[no-]cache - #| --path - #| - ), - ) - } noraise { - _ => panic() - } - - let count_cmd = @argparse.Command("demo", flags=[ - FlagArg("verbose", long="verbose", action=Count, negatable=true), - ]) - let reset = count_cmd.parse( - argv=["--verbose", "--no-verbose"], - env=empty_env(), - ) catch { - _ => panic() - } - assert_true(reset.flags is { "verbose": false, .. }) - assert_true(reset.flag_counts is { "verbose"? : None, .. }) - assert_true(reset.sources is { "verbose": Argv, .. }) -} - -///| -test "positionals dash handling and separator" { - let force_cmd = @argparse.Command("demo", positionals=[ - PositionArg("tail", num_args=ValueRange(lower=0), allow_hyphen_values=true), - ]) - let forced = force_cmd.parse(argv=["a", "--x", "-y"], env=empty_env()) catch { - _ => panic() - } - assert_true(forced.values is { "tail": ["a", "--x", "-y"], .. }) - - let dashed = force_cmd.parse(argv=["--", "p", "q"], env=empty_env()) catch { - _ => panic() - } - assert_true(dashed.values is { "tail": ["p", "q"], .. }) - - let negative_cmd = @argparse.Command("demo", positionals=[PositionArg("n")]) - let negative = negative_cmd.parse(argv=["-9"], env=empty_env()) catch { - _ => panic() - } - assert_true(negative.values is { "n": ["-9"], .. }) - - try negative_cmd.parse(argv=["x", "y"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected value 'y' for '' found; no more were expected - #| - #|Usage: demo [n] - #| - #|Arguments: - #| n - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "variadic positional keeps accepting hyphen values after first token" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("tail", num_args=ValueRange(lower=0), allow_hyphen_values=true), - ]) - let parsed = cmd.parse(argv=["a", "-b", "--mystery"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.values is { "tail": ["a", "-b", "--mystery"], .. }) -} - -///| -test "bounded positional does not greedily consume later required values" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("first", num_args=ValueRange(lower=1, upper=2)), - PositionArg("second", num_args=@argparse.ValueRange::single()), - ]) - - let two = cmd.parse(argv=["a", "b"], env=empty_env()) catch { _ => panic() } - assert_true(two.values is { "first": ["a"], "second": ["b"], .. }) - - let three = cmd.parse(argv=["a", "b", "c"], env=empty_env()) catch { - _ => panic() - } - assert_true(three.values is { "first": ["a", "b"], "second": ["c"], .. }) -} - -///| -test "indexed non-last positional allows explicit single num_args" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("first", num_args=@argparse.ValueRange::single()), - PositionArg("second", num_args=@argparse.ValueRange::single()), - ]) - - let parsed = cmd.parse(argv=["a", "b"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.values is { "first": ["a"], "second": ["b"], .. }) -} - -///| -test "empty positional value range is rejected at build time" { - try - @argparse.Command("demo", positionals=[ - PositionArg("skip", num_args=ValueRange(lower=0, upper=0)), - PositionArg("name", num_args=@argparse.ValueRange::single()), - ]).parse(argv=["alice"], env=empty_env()) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: empty value range (0..0) is unsupported - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "env parsing for settrue setfalse count and invalid values" { - let cmd = @argparse.Command("demo", flags=[ - FlagArg("on", long="on", action=SetTrue, env="ON"), - FlagArg("off", long="off", action=SetFalse, env="OFF"), - FlagArg("v", long="v", action=Count, env="V"), - ]) - - let parsed = cmd.parse(argv=[], env={ "ON": "true", "OFF": "true", "V": "3" }) catch { - _ => panic() - } - assert_true(parsed.flags is { "on": true, "off": false, "v": true, .. }) - assert_true(parsed.flag_counts is { "v": 3, .. }) - assert_true(parsed.sources is { "on": Env, "off": Env, "v": Env, .. }) - - try cmd.parse(argv=[], env={ "ON": "bad" }) catch { - err => - inspect( - err, - content=( - #|error: invalid value 'bad' for boolean flag; expected one of: 1, 0, true, false, yes, no, on, off - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --on [env: ON] - #| --off [env: OFF] - #| --v [env: V] - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=[], env={ "OFF": "bad" }) catch { - err => - inspect( - err, - content=( - #|error: invalid value 'bad' for boolean flag; expected one of: 1, 0, true, false, yes, no, on, off - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --on [env: ON] - #| --off [env: OFF] - #| --v [env: V] - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=[], env={ "V": "bad" }) catch { - err => - inspect( - err, - content=( - #|error: invalid value 'bad' for count; expected a non-negative integer - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --on [env: ON] - #| --off [env: OFF] - #| --v [env: V] - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=[], env={ "V": "-1" }) catch { - err => - inspect( - err, - content=( - #|error: invalid value '-1' for count; expected a non-negative integer - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --on [env: ON] - #| --off [env: OFF] - #| --v [env: V] - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "defaults and value range helpers through public API" { - let defaults = @argparse.Command("demo", options=[ - OptionArg("mode", long="mode", action=Append, default_values=["a", "b"]), - OptionArg("one", long="one", default_values=["x"]), - ]) - let by_default = defaults.parse(argv=[], env=empty_env()) catch { - _ => panic() - } - assert_true(by_default.values is { "mode": ["a", "b"], "one": ["x"], .. }) - assert_true(by_default.sources is { "mode": Default, "one": Default, .. }) - - let upper_only = @argparse.Command("demo", options=[ - OptionArg("tag", long="tag", action=Append), - ]) - let upper_parsed = upper_only.parse( - argv=["--tag", "a", "--tag", "b", "--tag", "c"], - env=empty_env(), - ) catch { - _ => panic() - } - assert_true(upper_parsed.values is { "tag": ["a", "b", "c"], .. }) - - let lower_only = @argparse.Command("demo", options=[ - OptionArg("tag", long="tag"), - ]) - let lower_absent = lower_only.parse(argv=[], env=empty_env()) catch { - _ => panic() - } - assert_true(lower_absent.values is { "tag"? : None, .. }) - - try lower_only.parse(argv=["--tag"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: a value is required for '--tag' but none was supplied - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --tag - #| - ), - ) - } noraise { - _ => panic() - } - - let single_range = @argparse.ValueRange::single() - inspect( - single_range, - content=( - #|{lower: 1, upper: Some(1)} - ), - ) -} - -///| -test "options consume exactly one value per occurrence" { - let cmd = @argparse.Command("demo", options=[OptionArg("tag", long="tag")]) - let parsed = cmd.parse(argv=["--tag", "a"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.values is { "tag": ["a"], .. }) - assert_true(parsed.sources is { "tag": Argv, .. }) - - try cmd.parse(argv=["--tag", "a", "b"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected value 'b' found; no more were expected - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --tag - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "set options reject duplicate occurrences" { - let cmd = @argparse.Command("demo", options=[OptionArg("mode", long="mode")]) - try cmd.parse(argv=["--mode", "a", "--mode", "b"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: argument '--mode' cannot be used multiple times - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --mode - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "flag and option args require short or long names" { - try - @argparse.Command("demo", options=[OptionArg("input", long="")]).parse( - argv=[], - env=empty_env(), - ) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: flag/option args require short/long/env - ), - ) - } noraise { - _ => panic() - } - - try - @argparse.Command("demo", flags=[FlagArg("verbose", long="")]).parse( - argv=[], - env=empty_env(), - ) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: flag/option args require short/long/env - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "append options collect values across repeated occurrences" { - let cmd = @argparse.Command("demo", options=[ - OptionArg("arg", long="arg", action=Append), - ]) - let parsed = cmd.parse(argv=["--arg", "x", "--arg", "y"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.values is { "arg": ["x", "y"], .. }) - assert_true(parsed.sources is { "arg": Argv, .. }) -} - -///| -test "option parsing stops at the next option token" { - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("verbose", long="verbose")], - options=[OptionArg("arg", short='a', long="arg")], - ) - - let stopped = cmd.parse(argv=["--arg", "x", "--verbose"], env=empty_env()) catch { - _ => panic() - } - assert_true(stopped.values is { "arg": ["x"], .. }) - assert_true(stopped.flags is { "verbose": true, .. }) - - try cmd.parse(argv=["--arg=x", "y", "--verbose"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected value 'y' found; no more were expected - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --verbose - #| -a, --arg - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=["-ax", "y", "--verbose"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected value 'y' found; no more were expected - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --verbose - #| -a, --arg - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "options always require a value" { - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("verbose", long="verbose")], - options=[OptionArg("opt", long="opt")], - ) - try cmd.parse(argv=["--opt", "--verbose"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: a value is required for '--opt' but none was supplied - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --verbose - #| --opt - #| - ), - ) - } noraise { - _ => panic() - } - - let zero_value_required = @argparse.Command("demo", options=[ - OptionArg("opt", long="opt", required=true), - ]).parse(argv=["--opt", "x"], env=empty_env()) catch { - _ => panic() - } - assert_true(zero_value_required.values is { "opt": ["x"], .. }) -} - -///| -test "option values reject hyphen tokens unless allow_hyphen_values is enabled" { - let strict = @argparse.Command("demo", options=[ - OptionArg("pattern", long="pattern"), - ]) - let mut rejected = false - try strict.parse(argv=["--pattern", "-file"], env=empty_env()) catch { - err => { - inspect( - err, - content=( - #|error: a value is required for '--pattern' but none was supplied - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --pattern - #| - ), - ) - rejected = true - } - } noraise { - _ => rejected = true - } - assert_true(rejected) - - let permissive = @argparse.Command("demo", options=[ - OptionArg("pattern", long="pattern", allow_hyphen_values=true), - ]) - let parsed = permissive.parse(argv=["--pattern", "-file"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.values is { "pattern": ["-file"], .. }) - assert_true(parsed.sources is { "pattern": Argv, .. }) -} - -///| - -///| -test "default argv path is reachable" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("rest", num_args=ValueRange(lower=0), allow_hyphen_values=true), - ]) - let _ = cmd.parse(env=empty_env()) catch { _ => panic() } -} - -///| -test "validation branches exposed through parse" { - try - @argparse.Command("demo", flags=[FlagArg("f", long="", action=Help)]).parse( - argv=[], - env=empty_env(), - ) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: flag/option args require short/long/env - ), - ) - } noraise { - _ => panic() - } - - try - @argparse.Command("demo", flags=[ - FlagArg("f", long="f", action=Help, negatable=true), - ]).parse(argv=[], env=empty_env()) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: help/version actions do not support negatable - ), - ) - } noraise { - _ => panic() - } - - try - @argparse.Command("demo", flags=[ - FlagArg("f", long="f", action=Help, env="F"), - ]).parse(argv=[], env=empty_env()) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: help/version actions do not support env/defaults - ), - ) - } noraise { - _ => panic() - } - - try - @argparse.Command("demo", options=[OptionArg("x", long="x")]).parse( - argv=["--x", "a", "b"], - env=empty_env(), - ) - catch { - err => - inspect( - err, - content=( - #|error: unexpected value 'b' found; no more were expected - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --x - #| - ), - ) - } noraise { - _ => panic() - } - - try - @argparse.Command("demo", options=[ - OptionArg("x", long="x", default_values=["a", "b"]), - ]).parse(argv=[], env=empty_env()) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: default_values with multiple entries require action=Append - ), - ) - } noraise { - _ => panic() - } - - try - @argparse.Command("demo", positionals=[ - PositionArg("x", num_args=ValueRange(lower=3, upper=2)), - ]).parse(argv=[], env=empty_env()) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: max values must be >= min values - ), - ) - } noraise { - _ => panic() - } - - try - @argparse.Command("demo", positionals=[ - PositionArg("x", num_args=ValueRange(lower=-1, upper=2)), - ]).parse(argv=[], env=empty_env()) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: min values must be >= 0 - ), - ) - } noraise { - _ => panic() - } - - try - @argparse.Command("demo", positionals=[ - PositionArg("x", num_args=ValueRange(lower=0, upper=-1)), - ]).parse(argv=[], env=empty_env()) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: max values must be >= 0 - ), - ) - } noraise { - _ => panic() - } - - let positional_ok = @argparse.Command("demo", positionals=[ - PositionArg("x", num_args=ValueRange(lower=0, upper=2)), - PositionArg("y"), - ]).parse(argv=["a"], env=empty_env()) catch { - _ => panic() - } - assert_true(positional_ok.values is { "x": ["a"], "y"? : None, .. }) - - try - @argparse.Command("demo", groups=[ArgGroup("g"), ArgGroup("g")]).parse( - argv=[], - env=empty_env(), - ) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: duplicate group: g - ), - ) - } noraise { - _ => panic() - } - - try - @argparse.Command("demo", groups=[ArgGroup("g", requires=["g"])]).parse( - argv=[], - env=empty_env(), - ) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: group cannot require itself: g - ), - ) - } noraise { - _ => panic() - } - - try - @argparse.Command("demo", groups=[ArgGroup("g", conflicts_with=["g"])]).parse( - argv=[], - env=empty_env(), - ) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: group cannot conflict with itself: g - ), - ) - } noraise { - _ => panic() - } - - try - @argparse.Command("demo", groups=[ArgGroup("g", args=["missing"])]).parse( - argv=[], - env=empty_env(), - ) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: unknown group arg: g -> missing - ), - ) - } noraise { - _ => panic() - } - - try - @argparse.Command("demo", options=[ - OptionArg("x", long="x"), - OptionArg("x", long="y"), - ]).parse(argv=[], env=empty_env()) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: duplicate arg name: x - ), - ) - } noraise { - _ => panic() - } - - try - @argparse.Command("demo", options=[ - OptionArg("x", long="same"), - OptionArg("y", long="same"), - ]).parse(argv=[], env=empty_env()) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: duplicate long option: --same - ), - ) - } noraise { - _ => panic() - } - - try - @argparse.Command("demo", flags=[ - FlagArg("hello", long="hello", negatable=true), - FlagArg("x", long="no-hello"), - ]).parse(argv=[], env=empty_env()) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: duplicate long option: --no-hello - ), - ) - } noraise { - _ => panic() - } - - try - @argparse.Command("demo", options=[ - OptionArg("x", short='s'), - OptionArg("y", short='s'), - ]).parse(argv=[], env=empty_env()) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: duplicate short option: -s - ), - ) - } noraise { - _ => panic() - } - - try - @argparse.Command("demo", flags=[FlagArg("x", long="x", requires=["x"])]).parse( - argv=[], - env=empty_env(), - ) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: arg cannot require itself: x - ), - ) - } noraise { - _ => panic() - } - - try - @argparse.Command("demo", flags=[ - FlagArg("x", long="x", conflicts_with=["x"]), - ]).parse(argv=[], env=empty_env()) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: arg cannot conflict with itself: x - ), - ) - } noraise { - _ => panic() - } - - try - @argparse.Command("demo", subcommands=[Command("x"), Command("x")]).parse( - argv=[], - env=empty_env(), - ) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: duplicate subcommand: x - ), - ) - } noraise { - _ => panic() - } - - try - @argparse.Command("demo", subcommand_required=true).parse( - argv=[], - env=empty_env(), - ) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: subcommand_required requires at least one subcommand - ), - ) - } noraise { - _ => panic() - } - - try - @argparse.Command("demo", subcommands=[Command("help")]).parse( - argv=[], - env=empty_env(), - ) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: subcommand name reserved for built-in help: help (disable with disable_help_subcommand) - ), - ) - } noraise { - _ => panic() - } - - let custom_help = @argparse.Command("demo", flags=[ - FlagArg("custom_help", short='h', long="help", about="custom help"), - ]) - let help_short = custom_help.parse(argv=["-h"], env=empty_env()) catch { - _ => panic() - } - let help_long = custom_help.parse(argv=["--help"], env=empty_env()) catch { - _ => panic() - } - assert_true(help_short.flags is { "custom_help": true, .. }) - assert_true(help_long.flags is { "custom_help": true, .. }) - inspect( - custom_help.render_help(), - content=( - #|Usage: demo [options] - #| - #|Options: - #| -h, --help custom help - #| - ), - ) - - let custom_version = @argparse.Command("demo", version="1.0", flags=[ - FlagArg("custom_version", short='V', long="version", about="custom version"), - ]) - let version_short = custom_version.parse(argv=["-V"], env=empty_env()) catch { - _ => panic() - } - let version_long = custom_version.parse(argv=["--version"], env=empty_env()) catch { - _ => panic() - } - assert_true(version_short.flags is { "custom_version": true, .. }) - assert_true(version_long.flags is { "custom_version": true, .. }) - inspect( - custom_version.render_help(), - content=( - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| -V, --version custom version - #| - ), - ) - - try - @argparse.Command("demo", flags=[FlagArg("v", long="v", action=Version)]).parse( - argv=[], - env=empty_env(), - ) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: version action requires command version text - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "builtin and custom help/version dispatch edge paths" { - let versioned = @argparse.Command("demo", version="1.2.3") - inspect( - versioned.render_help(), - content=( - #|Usage: demo - #| - #|Options: - #| -h, --help Show help information. - #| -V, --version Show version information. - #| - ), - ) - - try versioned.parse(argv=["--oops"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--oops' found - #| - #|Usage: demo - #| - #|Options: - #| -h, --help Show help information. - #| -V, --version Show version information. - #| - ), - ) - } noraise { - _ => panic() - } - - let long_help = @argparse.Command("demo", flags=[ - FlagArg("assist", long="assist", action=Help), - ]) - inspect( - long_help.render_help(), - content=( - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --assist - #| - ), - ) - - let short_help = @argparse.Command("demo", flags=[ - FlagArg("assist", short='?', action=Help), - ]) - inspect( - short_help.render_help(), - content=( - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| -?, --assist - #| - ), - ) -} - -///| -test "subcommand lookup falls back to positional value" { - let cmd = @argparse.Command("demo", positionals=[PositionArg("input")], subcommands=[ - Command("run"), - ]) - let parsed = cmd.parse(argv=["raw"], env=empty_env()) catch { _ => panic() } - assert_true(parsed.values is { "input": ["raw"], .. }) - assert_true(parsed.subcommand is None) -} - -///| -test "group validation catches unknown requires target" { - try - @argparse.Command("demo", groups=[ArgGroup("g", requires=["missing"])]).parse( - argv=[], - env=empty_env(), - ) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: unknown group requires target: g -> missing - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "group validation catches unknown conflicts_with target" { - try - @argparse.Command("demo", groups=[ArgGroup("g", conflicts_with=["missing"])]).parse( - argv=[], - env=empty_env(), - ) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: unknown group conflicts_with target: g -> missing - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "group requires/conflicts can target argument names" { - let requires_cmd = @argparse.Command( - "demo", - groups=[ArgGroup("mode", args=["fast"], requires=["config"])], - flags=[FlagArg("fast", long="fast")], - options=[OptionArg("config", long="config")], - ) - - let ok = requires_cmd.parse( - argv=["--fast", "--config", "cfg.toml"], - env=empty_env(), - ) catch { - _ => panic() - } - assert_true(ok.flags is { "fast": true, .. }) - assert_true(ok.values is { "config": ["cfg.toml"], .. }) - - try requires_cmd.parse(argv=["--fast"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: the following required argument was not provided: 'config' - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --fast - #| --config - #| - #|Groups: - #| mode --fast - #| - ), - ) - } noraise { - _ => panic() - } - - let conflicts_cmd = @argparse.Command( - "demo", - groups=[ArgGroup("mode", args=["fast"], conflicts_with=["config"])], - flags=[FlagArg("fast", long="fast")], - options=[OptionArg("config", long="config")], - ) - - try - conflicts_cmd.parse( - argv=["--fast", "--config", "cfg.toml"], - env=empty_env(), - ) - catch { - err => - inspect( - err, - content=( - #|error: group conflict mode conflicts with config - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --fast - #| --config - #| - #|Groups: - #| mode --fast - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "group without members has no parse effect" { - let cmd = @argparse.Command("demo", groups=[ArgGroup("known")], flags=[ - FlagArg("x", long="x"), - ]) - let parsed = cmd.parse(argv=["--x"], env=empty_env()) catch { _ => panic() } - assert_true(parsed.flags is { "x": true, .. }) - let help = cmd.render_help() - assert_true(help.has_prefix("Usage: demo [options]")) -} - -///| -test "arg validation catches unknown requires target" { - try - @argparse.Command("demo", options=[ - OptionArg("mode", long="mode", requires=["missing"]), - ]).parse(argv=["--mode", "fast"], env=empty_env()) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: unknown requires target: mode -> missing - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "arg validation catches unknown conflicts_with target" { - try - @argparse.Command("demo", options=[ - OptionArg("mode", long="mode", conflicts_with=["missing"]), - ]).parse(argv=["--mode", "fast"], env=empty_env()) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: unknown conflicts_with target: mode -> missing - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "empty groups without presence do not fail" { - let grouped_ok = @argparse.Command( - "demo", - groups=[ArgGroup("left", args=["l"]), ArgGroup("right", args=["r"])], - flags=[FlagArg("l", long="left"), FlagArg("r", long="right")], - ) - let parsed = grouped_ok.parse(argv=["--left"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.flags is { "l": true, .. }) -} - -///| -test "help rendering edge paths stay stable" { - let required_many = @argparse.Command("demo", positionals=[ - PositionArg("files", num_args=ValueRange(lower=1)), - ]) - let required_help = required_many.render_help() - assert_true(required_help.has_prefix("Usage: demo ")) - - let short_only_builtin = @argparse.Command("demo", options=[ - OptionArg("helpopt", long="help"), - ]) - let short_only_text = short_only_builtin.render_help() - assert_true(short_only_text.has_prefix("Usage: demo")) - try short_only_builtin.parse(argv=["--help"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: a value is required for '--help' but none was supplied - #| - #|Usage: demo [options] - #| - #|Options: - #| -h Show help information. - #| --help - #| - ), - ) - } noraise { - _ => panic() - } - - let long_only_builtin = @argparse.Command("demo", flags=[ - FlagArg("custom_h", short='h'), - ]) - let long_only_text = long_only_builtin.render_help() - assert_true(long_only_text.has_prefix("Usage: demo")) - let custom_h = long_only_builtin.parse(argv=["-h"], env=empty_env()) catch { - _ => panic() - } - assert_true(custom_h.flags is { "custom_h": true, .. }) - - let empty_options = @argparse.Command( - "demo", - disable_help_flag=true, - disable_version_flag=true, - ) - let empty_options_help = empty_options.render_help() - assert_true(empty_options_help.has_prefix("Usage: demo")) - - let implicit_group = @argparse.Command("demo", positionals=[ - PositionArg("item"), - ]) - let implicit_group_help = implicit_group.render_help() - assert_true(implicit_group_help.has_prefix("Usage: demo [item]")) - - let sub_visible = @argparse.Command("demo", disable_help_subcommand=true, subcommands=[ - Command("run"), - ]) - let sub_help = sub_visible.render_help() - assert_true(sub_help.has_prefix("Usage: demo [command]")) -} - -///| -test "unified error message formatting remains stable" { - let cmd = @argparse.Command("demo", options=[OptionArg("tag", long="tag")]) - - try cmd.parse(argv=["--oops"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--oops' found - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --tag - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=["--tag"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: a value is required for '--tag' but none was supplied - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --tag - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "options require one value per occurrence" { - let with_value = @argparse.Command("demo", options=[ - OptionArg("tag", long="tag"), - ]).parse(argv=["--tag", "x"], env=empty_env()) catch { - _ => panic() - } - assert_true(with_value.values is { "tag": ["x"], .. }) - - try - @argparse.Command("demo", options=[OptionArg("tag", long="tag")]).parse( - argv=["--tag"], - env=empty_env(), - ) - catch { - err => - inspect( - err, - content=( - #|error: a value is required for '--tag' but none was supplied - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --tag - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "short options require one value before next option token" { - let cmd = @argparse.Command("demo", flags=[FlagArg("verbose", short='v')], options=[ - OptionArg("x", short='x'), - ]) - let ok = cmd.parse(argv=["-x", "a", "-v"], env=empty_env()) catch { - _ => panic() - } - assert_true(ok.values is { "x": ["a"], .. }) - assert_true(ok.flags is { "verbose": true, .. }) - - try cmd.parse(argv=["-x", "-v"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: a value is required for '-x' but none was supplied - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| -v, --verbose - #| -x, --x - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "version action dispatches on custom long and short flags" { - let cmd = @argparse.Command("demo", version="2.0.0", flags=[ - FlagArg("show_long", long="show-version", action=Version), - FlagArg("show_short", short='S', action=Version), - ]) - - inspect( - cmd.render_help(), - content=( - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| -V, --version Show version information. - #| --show-version - #| -S, --show_short - #| - ), - ) - - try cmd.parse(argv=["--oops"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--oops' found - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| -V, --version Show version information. - #| --show-version - #| -S, --show_short - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "global version action keeps parent version text in subcommand context" { - let cmd = @argparse.Command( - "demo", - version="1.0.0", - flags=[ - FlagArg( - "show_version", - short='S', - long="show-version", - action=Version, - global=true, - ), - ], - subcommands=[Command("run")], - ) - - try cmd.parse(argv=["--oops"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--oops' found - #| - #|Usage: demo [options] [command] - #| - #|Commands: - #| run - #| help Print help for the subcommand(s). - #| - #|Options: - #| -h, --help Show help information. - #| -V, --version Show version information. - #| -S, --show-version - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=["run", "--oops"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--oops' found - #| - #|Usage: demo run [options] - #| - #|Options: - #| -h, --help Show help information. - #| -S, --show-version - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "subcommand help puts required options in usage" { - let cmd = @argparse.Command("demo", subcommands=[ - Command( - "run", - about="Run a file", - options=[OptionArg("mode", short='m', required=true)], - positionals=[PositionArg("file", num_args=@argparse.ValueRange::single())], - ), - ]) - - try cmd.parse(argv=["run", "--oops"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--oops' found - #| - #|Usage: demo run --mode - #| - #|Run a file - #| - #|Arguments: - #| file - #| - #|Options: - #| -h, --help Show help information. - #| -m, --mode - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "required_option_usage covers option/flag/hidden/short-only" { - let cmd = @argparse.Command( - "demo", - flags=[ - FlagArg("verbose", short='v', long="verbose", required=true), - FlagArg("secret", short='s', long="secret", required=true, hidden=true), - ], - options=[ - OptionArg("mode", long="mode", required=true), - OptionArg("tag", short='t', long="", required=true), - OptionArg("optional", long="optional"), - ], - positionals=[ - PositionArg("required_pos", num_args=@argparse.ValueRange::single()), - ], - ) - let help = cmd.render_help() - assert_true( - help.has_prefix( - "Usage: demo --verbose --mode -t [options] ", - ), - ) -} - -///| -test "required_option_usage returns empty when nothing required" { - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("verbose", short='v', long="verbose")], - options=[OptionArg("mode", long="mode")], - ) - let help = cmd.render_help() - assert_true(help.has_prefix("Usage: demo [options]")) -} - -///| -test "required and env-fed ranged values validate after parsing" { - let required_cmd = @argparse.Command("demo", options=[ - OptionArg("input", long="input", required=true), - ]) - try required_cmd.parse(argv=[], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: the following required argument was not provided: 'input' - #| - #|Usage: demo --input - #| - #|Options: - #| -h, --help Show help information. - #| --input - #| - ), - ) - } noraise { - _ => panic() - } - - let env_min_cmd = @argparse.Command("demo", options=[ - OptionArg("pair", long="pair", env="PAIR"), - ]) - let env_value = env_min_cmd.parse(argv=[], env={ "PAIR": "one" }) catch { - _ => panic() - } - assert_true(env_value.values is { "pair": ["one"], .. }) - assert_true(env_value.sources is { "pair": Env, .. }) -} - -///| -test "positionals keep declaration order with ranged positional" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("late", num_args=ValueRange(lower=2, upper=2)), - PositionArg("first"), - PositionArg("mid"), - ]) - - let parsed = cmd.parse(argv=["a", "b", "c", "d"], env=empty_env()) catch { - _ => panic() - } - assert_true( - parsed.values is { "late": ["a", "b"], "first": ["c"], "mid": ["d"], .. }, - ) -} - -///| -test "mixed indexed and unindexed positionals keep inferred order" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("first"), - PositionArg("second"), - ]) - - let parsed = cmd.parse(argv=["a", "b"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.values is { "first": ["a"], "second": ["b"], .. }) -} - -///| -test "single positional parses without explicit index metadata" { - let parsed = @argparse.Command("demo", positionals=[PositionArg("late")]).parse( - argv=["x"], - env=empty_env(), - ) catch { - _ => panic() - } - assert_true(parsed.values is { "late": ["x"], .. }) -} - -///| -test "positional num_args lower bound rejects missing argv values" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("first", num_args=ValueRange(lower=2, upper=3)), - ]) - - try cmd.parse(argv=[], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: 'first' requires at least 2 values but only 0 were provided - #| - #|Usage: demo - #| - #|Arguments: - #| first... - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "positional max clamp leaves trailing value for next positional" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("items", num_args=ValueRange(lower=0, upper=2)), - PositionArg("tail"), - ]) - - let parsed = cmd.parse(argv=["a", "b", "c"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.values is { "items": ["a", "b"], "tail": ["c"], .. }) -} - -///| -test "options with allow_hyphen_values accept option-like single values" { - let cmd = @argparse.Command( - "demo", - flags=[ - FlagArg("verbose", long="verbose"), - FlagArg("cache", long="cache", negatable=true), - FlagArg("quiet", short='q'), - ], - options=[OptionArg("arg", long="arg", allow_hyphen_values=true)], - ) - - let known_long = cmd.parse(argv=["--arg", "--verbose"], env=empty_env()) catch { - _ => panic() - } - assert_true(known_long.values is { "arg": ["--verbose"], .. }) - assert_true(known_long.flags is { "verbose"? : None, .. }) - - let negated = cmd.parse(argv=["--arg", "--no-cache"], env=empty_env()) catch { - _ => panic() - } - assert_true(negated.values is { "arg": ["--no-cache"], .. }) - assert_true(negated.flags is { "cache"? : None, .. }) - - let unknown_long_value = cmd.parse( - argv=["--arg", "--mystery"], - env=empty_env(), - ) catch { - _ => panic() - } - assert_true(unknown_long_value.values is { "arg": ["--mystery"], .. }) - - let known_short = cmd.parse(argv=["--arg", "-q"], env=empty_env()) catch { - _ => panic() - } - assert_true(known_short.values is { "arg": ["-q"], .. }) - assert_true(known_short.flags is { "quiet"? : None, .. }) - - let cmd_with_rest = @argparse.Command( - "demo", - options=[OptionArg("arg", long="arg", allow_hyphen_values=true)], - positionals=[ - PositionArg( - "rest", - num_args=ValueRange(lower=0), - allow_hyphen_values=true, - ), - ], - ) - let sentinel_stop = cmd_with_rest.parse( - argv=["--arg", "x", "--", "tail"], - env=empty_env(), - ) catch { - _ => panic() - } - assert_true(sentinel_stop.values is { "arg": ["x"], "rest": ["tail"], .. }) -} - -///| -test "single-value options avoid consuming additional option values" { - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("verbose", long="verbose")], - options=[OptionArg("one", long="one")], - ) - - let parsed = cmd.parse(argv=["--one", "x", "--verbose"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.values is { "one": ["x"], .. }) - assert_true(parsed.flags is { "verbose": true, .. }) -} - -///| -test "missing option values are reported when next token is another option" { - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("verbose", long="verbose")], - options=[OptionArg("arg", long="arg")], - ) - - let ok = cmd.parse(argv=["--arg", "x", "--verbose"], env=empty_env()) catch { - _ => panic() - } - assert_true(ok.values is { "arg": ["x"], .. }) - assert_true(ok.flags is { "verbose": true, .. }) - - try cmd.parse(argv=["--arg", "--verbose"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: a value is required for '--arg' but none was supplied - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --verbose - #| --arg - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "short-only set options use short label in duplicate errors" { - let cmd = @argparse.Command("demo", options=[OptionArg("mode", short='m')]) - try cmd.parse(argv=["-m", "a", "-m", "b"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: argument '--mode' cannot be used multiple times - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| -m, --mode - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "unknown short suggestion can be absent" { - let cmd = @argparse.Command("demo", disable_help_flag=true, options=[ - OptionArg("name", long="name"), - ]) - - try cmd.parse(argv=["-x"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '-x' found - #| - #|Usage: demo [options] - #| - #|Options: - #| --name - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "setfalse flags apply false when present" { - let cmd = @argparse.Command("demo", flags=[ - FlagArg("failfast", long="failfast", action=SetFalse), - ]) - let parsed = cmd.parse(argv=["--failfast"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.flags is { "failfast": false, .. }) - assert_true(parsed.sources is { "failfast": Argv, .. }) -} - -///| -test "allow_hyphen positional treats unknown long token as value" { - let cmd = @argparse.Command("demo", flags=[FlagArg("known", long="known")], positionals=[ - PositionArg("input", allow_hyphen_values=true), - ]) - let parsed = cmd.parse(argv=["--mystery"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.values is { "input": ["--mystery"], .. }) -} - -///| -test "global value from child default is merged back to parent" { - let cmd = @argparse.Command( - "demo", - options=[ - OptionArg("mode", long="mode", default_values=["safe"], global=true), - OptionArg("unused", long="unused", global=true), - ], - subcommands=[Command("run")], - ) - - let parsed = cmd.parse(argv=["run"], env=empty_env()) catch { _ => panic() } - assert_true(parsed.values is { "mode": ["safe"], "unused"? : None, .. }) - assert_true(parsed.sources is { "mode": Default, .. }) - assert_true( - parsed.subcommand is Some(("run", sub)) && - sub.values is { "mode": ["safe"], .. } && - sub.sources is { "mode": Default, .. }, - ) -} - -///| -test "env-only global is propagated to nested subcommand matches" { - let cmd = @argparse.Command( - "demo", - options=[OptionArg("level", long="", env="LEVEL", global=true)], - subcommands=[Command("run", subcommands=[Command("leaf")])], - ) - - let parsed = cmd.parse(argv=["run", "leaf"], env={ "LEVEL": "5" }) catch { - _ => panic() - } - assert_true(parsed.values is { "level": ["5"], .. }) - assert_true(parsed.sources is { "level": Env, .. }) - assert_true( - parsed.subcommand is Some(("run", sub_run)) && - sub_run.values is { "level": ["5"], .. } && - sub_run.sources is { "level": Env, .. } && - sub_run.subcommand is Some(("leaf", sub_leaf)) && - sub_leaf.values is { "level": ["5"], .. } && - sub_leaf.sources is { "level": Env, .. }, - ) -} - -///| -test "child global arg with inherited global name updates parent global" { - let cmd = @argparse.Command( - "demo", - options=[ - OptionArg("mode", long="mode", default_values=["safe"], global=true), - ], - subcommands=[ - Command("run", options=[OptionArg("mode", long="mode", global=true)]), - ], - ) - - let parsed = cmd.parse(argv=["run", "--mode", "fast"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.values is { "mode": ["fast"], .. }) - assert_true(parsed.sources is { "mode": Argv, .. }) - assert_true( - parsed.subcommand is Some(("run", sub)) && - sub.values is { "mode": ["fast"], .. } && - sub.sources is { "mode": Argv, .. }, - ) -} - -///| -test "child global override env/default win over inherited definition" { - let cmd = @argparse.Command( - "demo", - options=[ - OptionArg( - "mode", - long="mode", - env="ROOT_MODE", - default_values=["safe"], - global=true, - ), - ], - subcommands=[ - Command("run", options=[ - OptionArg( - "mode", - long="mode", - env="RUN_MODE", - default_values=["fast"], - global=true, - ), - ]), - ], - ) - - let from_env = cmd.parse(argv=["run"], env={ - "ROOT_MODE": "root-env", - "RUN_MODE": "run-env", - }) catch { - _ => panic() - } - assert_true(from_env.values is { "mode": ["run-env"], .. }) - assert_true(from_env.sources is { "mode": Env, .. }) - assert_true( - from_env.subcommand is Some(("run", sub)) && - sub.values is { "mode": ["run-env"], .. } && - sub.sources is { "mode": Env, .. }, - ) - - let from_default = cmd.parse(argv=["run"], env=Map([])) catch { _ => panic() } - assert_true(from_default.values is { "mode": ["fast"], .. }) - assert_true(from_default.sources is { "mode": Default, .. }) - assert_true( - from_default.subcommand is Some(("run", sub)) && - sub.values is { "mode": ["fast"], .. } && - sub.sources is { "mode": Default, .. }, - ) -} - -///| -test "inherited argv global satisfies child required global override" { - let cmd = @argparse.Command( - "demo", - options=[OptionArg("mode", long="mode", global=true)], - subcommands=[ - Command("run", options=[ - OptionArg("mode", long="mode", required=true, global=true), - ]), - ], - ) - - let parsed = cmd.parse(argv=["--mode", "fast", "run"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.values is { "mode": ["fast"], .. }) - assert_true(parsed.sources is { "mode": Argv, .. }) - assert_true( - parsed.subcommand is Some(("run", sub)) && - sub.values is { "mode": ["fast"], .. } && - sub.sources is { "mode": Argv, .. }, - ) -} - -///| -test "child local arg shadowing inherited global is rejected at build time" { - try - @argparse.Command( - "demo", - options=[ - OptionArg( - "mode", - long="mode", - env="MODE", - default_values=["safe"], - global=true, - ), - ], - subcommands=[Command("run", options=[OptionArg("mode", long="mode")])], - ).parse(argv=["run"], env=empty_env()) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: arg 'mode' shadows an inherited global; rename the arg or mark it global - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "global append env value from child is merged back to parent" { - let cmd = @argparse.Command( - "demo", - options=[ - OptionArg("tag", long="tag", action=Append, env="TAG", global=true), - ], - subcommands=[Command("run")], - ) - - let parsed = cmd.parse(argv=["run"], env={ "TAG": "env-tag" }) catch { - _ => panic() - } - assert_true(parsed.values is { "tag": ["env-tag"], .. }) - assert_true(parsed.sources is { "tag": Env, .. }) - assert_true( - parsed.subcommand is Some(("run", sub)) && - sub.values is { "tag": ["env-tag"], .. } && - sub.sources is { "tag": Env, .. }, - ) -} - -///| -test "global flag set in child argv is merged back to parent" { - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("verbose", long="verbose", global=true)], - subcommands=[Command("run")], - ) - - let parsed = cmd.parse(argv=["run", "--verbose"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.flags is { "verbose": true, .. }) - assert_true(parsed.sources is { "verbose": Argv, .. }) - assert_true( - parsed.subcommand is Some(("run", sub)) && - sub.flags is { "verbose": true, .. } && - sub.sources is { "verbose": Argv, .. }, - ) -} - -///| -test "global count negation after subcommand resets merged state" { - let cmd = @argparse.Command( - "demo", - flags=[ - FlagArg( - "verbose", - long="verbose", - action=Count, - negatable=true, - global=true, - ), - ], - subcommands=[Command("run")], - ) - - let parsed = cmd.parse( - argv=["--verbose", "run", "--no-verbose"], - env=empty_env(), - ) catch { - _ => panic() - } - assert_true(parsed.flags is { "verbose": false, .. }) - assert_true(parsed.flag_counts.get("verbose") is None) - assert_true(parsed.sources is { "verbose": Argv, .. }) - assert_true( - parsed.subcommand is Some(("run", sub)) && - sub.flags is { "verbose": false, .. } && - sub.flag_counts.get("verbose") is None && - sub.sources is { "verbose": Argv, .. }, - ) -} - -///| -test "global set option rejects duplicate occurrences across subcommands" { - let cmd = @argparse.Command( - "demo", - options=[OptionArg("mode", long="mode", global=true)], - subcommands=[Command("run")], - ) - try - cmd.parse(argv=["--mode", "a", "run", "--mode", "b"], env=empty_env()) - catch { - err => - inspect( - err, - content=( - #|error: argument '--mode' cannot be used multiple times - #| - #|Usage: demo [options] [command] - #| - #|Commands: - #| run - #| help Print help for the subcommand(s). - #| - #|Options: - #| -h, --help Show help information. - #| --mode - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "global override with incompatible inherited type is rejected" { - try - @argparse.Command( - "demo", - options=[OptionArg("mode", long="mode", required=true, global=true)], - subcommands=[ - Command("run", flags=[FlagArg("mode", long="mode", global=true)]), - ], - ).parse(argv=["run", "--mode"], env=empty_env()) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: global arg 'mode' is incompatible with inherited global definition - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "child local long alias collision with inherited global is rejected" { - try - @argparse.Command( - "demo", - flags=[FlagArg("verbose", long="verbose", global=true)], - subcommands=[Command("run", options=[OptionArg("local", long="verbose")])], - ).parse(argv=["run", "--verbose"], env=empty_env()) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: arg 'local' long option --verbose conflicts with inherited global 'verbose' - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "child local short alias collision with inherited global is rejected" { - try - @argparse.Command( - "demo", - flags=[FlagArg("verbose", short='v', global=true)], - subcommands=[Command("run", options=[OptionArg("local", short='v')])], - ).parse(argv=["run", "-v"], env=empty_env()) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: arg 'local' short option -v conflicts with inherited global 'verbose' - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "nested subcommands inherit finalized globals from ancestors" { - let leaf = @argparse.Command("leaf") - let mid = @argparse.Command("mid", subcommands=[leaf]) - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("verbose", long="verbose", global=true)], - subcommands=[mid], - ) - - let parsed = cmd.parse(argv=["--verbose", "mid", "leaf"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.flags is { "verbose": true, .. }) - assert_true( - parsed.subcommand is Some(("mid", mid_matches)) && - mid_matches.flags is { "verbose": true, .. } && - mid_matches.subcommand is Some(("leaf", leaf_matches)) && - leaf_matches.flags is { "verbose": true, .. } && - leaf_matches.sources is { "verbose": Argv, .. }, - ) -} - -///| -test "non-bmp short option token does not panic" { - let cmd = @argparse.Command("demo", flags=[FlagArg("party", short='🎉')]) - let parsed = cmd.parse(argv=["-🎉"], env=empty_env()) catch { _ => panic() } - assert_true(parsed.flags is { "party": true, .. }) -} - -///| -test "non-bmp hyphen token reports unknown argument without panic" { - let cmd = @argparse.Command("demo", positionals=[PositionArg("value")]) - try cmd.parse(argv=["-🎉"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '-🎉' found - #| - #|Usage: demo [value] - #| - #|Arguments: - #| value - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "option env values remain string values instead of flags" { - let cmd = @argparse.Command("demo", options=[ - OptionArg("mode", long="mode", env="MODE"), - ]) - let parsed = cmd.parse(argv=[], env={ "MODE": "fast" }) catch { _ => panic() } - assert_true(parsed.values is { "mode": ["fast"], .. }) - assert_true(parsed.flags.get("mode") is None) - assert_true(parsed.sources is { "mode": Env, .. }) -} - -///| -test "nested global override deduplicates count merge by name" { - let leaf = @argparse.Command("leaf") - let mid = @argparse.Command( - "mid", - flags=[FlagArg("verbose", long="verbose", action=Count, global=true)], - subcommands=[leaf], - ) - let root = @argparse.Command( - "root", - flags=[FlagArg("verbose", long="verbose", action=Count, global=true)], - subcommands=[mid], - ) - - let parsed = root.parse(argv=["mid", "leaf", "--verbose"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.flag_counts is { "verbose": 1, .. }) - assert_true( - parsed.subcommand is Some(("mid", sub_mid)) && - sub_mid.flag_counts is { "verbose": 1, .. } && - sub_mid.subcommand is Some(("leaf", sub_leaf)) && - sub_leaf.flag_counts is { "verbose": 1, .. }, - ) -} - -///| -test "nested global override keeps single set value without false duplicate error" { - let leaf = @argparse.Command("leaf") - let mid = @argparse.Command( - "mid", - options=[OptionArg("mode", long="mode", global=true)], - subcommands=[leaf], - ) - let root = @argparse.Command( - "root", - options=[OptionArg("mode", long="mode", global=true)], - subcommands=[mid], - ) - - let parsed = root.parse( - argv=["mid", "leaf", "--mode", "fast"], - env=empty_env(), - ) catch { - _ => panic() - } - assert_true(parsed.values is { "mode": ["fast"], .. }) - assert_true( - parsed.subcommand is Some(("mid", sub_mid)) && - sub_mid.values is { "mode": ["fast"], .. } && - sub_mid.subcommand is Some(("leaf", sub_leaf)) && - sub_leaf.values is { "mode": ["fast"], .. }, - ) -} - -///| -test "global override with different negatable setting is rejected" { - try - @argparse.Command( - "demo", - flags=[FlagArg("verbose", long="verbose", negatable=true, global=true)], - subcommands=[ - Command("run", flags=[ - FlagArg("verbose", long="verbose", negatable=false, global=true), - ]), - ], - ).parse(argv=["run"], env=empty_env()) - catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: global arg 'verbose' is incompatible with inherited global definition - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "positional range 0..1 renders as single optional value" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("x", num_args=ValueRange(lower=0, upper=1)), - ]) - inspect( - cmd.render_help(), - content=( - #|Usage: demo [x] - #| - #|Arguments: - #| x - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) -} - -///| -test "Debug for argparse enums" { - @debug.debug_inspect(@argparse.FlagAction::SetTrue, content="SetTrue") - @debug.debug_inspect(@argparse.FlagAction::Count, content="Count") - @debug.debug_inspect(@argparse.OptionAction::Set, content="Set") - @debug.debug_inspect(@argparse.OptionAction::Append, content="Append") -} diff --git a/argparse/argparse_coverage_test.mbt b/argparse/argparse_coverage_test.mbt deleted file mode 100644 index 236e1e79e3..0000000000 --- a/argparse/argparse_coverage_test.mbt +++ /dev/null @@ -1,479 +0,0 @@ -// Copyright 2026 International Digital Economy Academy -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// Black-box tests that exercise argparse behaviors not covered by the existing -// suites: required-group usage rendering, value-count validation, global -// argument merging across subcommands, and assorted error-message edge paths. - -///| -test "required group with only hidden members reports a plain message" { - let cmd = @argparse.Command( - "demo", - groups=[ArgGroup("mode", required=true, args=["fast"])], - flags=[FlagArg("fast", long="fast", hidden=true)], - ) - try cmd.parse(argv=[], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: the following required argument group was not provided: 'mode' - #| - #|Usage: demo - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "required group usage lists every member shape" { - let cmd = @argparse.Command( - "demo", - groups=[ - ArgGroup("g", required=true, args=["opt", "posm", "poss", "sf", "ef"]), - ], - flags=[FlagArg("sf", short='s', long=""), FlagArg("ef", long="", env="EF")], - options=[OptionArg("opt", long="opt")], - positionals=[ - PositionArg("posm", num_args=ValueRange(lower=0)), - PositionArg("poss", num_args=ValueRange(lower=0, upper=1)), - ], - ) - try cmd.parse(argv=[], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: the following required arguments were not provided: - #| <--opt |||-s|ef> - #| - #|Usage: demo [options] [posm...] [poss] - #| - #|Arguments: - #| posm... - #| poss - #| - #|Options: - #| -h, --help Show help information. - #| -s - #| --opt - #| - #|Groups: - #| g [required] -s, ef, --opt , posm, poss - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "positional default values exceeding num_args upper bound are rejected" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("tags", num_args=ValueRange(lower=0, upper=2), default_values=[ - "a", "b", "c", - ]), - ]) - try cmd.parse(argv=[], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: 'tags' allows at most 2 values but 3 were provided - #| - #|Usage: demo [tags...] - #| - #|Arguments: - #| tags... [default: a, b, c] - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "earlier variadic positional reserves values for a later required one" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("head", num_args=ValueRange(lower=0)), - PositionArg("tail", num_args=ValueRange(lower=2)), - ]) - try cmd.parse(argv=["only"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: 'tail' requires at least 2 values but only 1 were provided - #| - #|Usage: demo [head...] - #| - #|Arguments: - #| head... - #| tail... - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "extra values past a bounded variadic positional report the variadic label" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("items", num_args=ValueRange(lower=0, upper=2)), - ]) - let ok = cmd.parse(argv=["a", "b"], env=empty_env()) catch { _ => panic() } - assert_true(ok.values is { "items": ["a", "b"], .. }) - try cmd.parse(argv=["a", "b", "c"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected value 'c' for '' found; no more were expected - #| - #|Usage: demo [items...] - #| - #|Arguments: - #| items... - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "duplicate short-only set option reports the short flag label" { - let cmd = @argparse.Command("demo", options=[ - OptionArg("mode", short='m', long=""), - ]) - try cmd.parse(argv=["-m", "a", "-m", "b"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: argument '-m' cannot be used multiple times - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| -m - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "boolean env flags accept falsy values" { - let cmd = @argparse.Command("demo", flags=[ - FlagArg("on", long="on", action=SetTrue, env="ON"), - FlagArg("off", long="off", action=SetFalse, env="OFF"), - ]) - let parsed = cmd.parse(argv=[], env={ "ON": "0", "OFF": "no" }) catch { - _ => panic() - } - assert_true(parsed.flags is { "on": false, "off": true, .. }) - assert_true(parsed.sources is { "on": Env, "off": Env, .. }) -} - -///| -test "unknown long flag with an empty name has no suggestion" { - let cmd = @argparse.Command("demo", flags=[FlagArg("verbose", long="verbose")]) - try cmd.parse(argv=["--=value"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--' found - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --verbose - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "subcommand build errors surface as validation failures" { - let cmd = @argparse.Command("demo", subcommands=[ - Command("child", flags=[FlagArg("x", long="x", requires=["missing"])]), - ]) - try cmd.parse(argv=[], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: unknown requires target: x -> missing - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "global flag cannot be redefined as an incompatible global option" { - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("shared", long="shared", global=true)], - subcommands=[ - Command("child", options=[OptionArg("shared", long="shared", global=true)]), - ], - ) - try cmd.parse(argv=[], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: global arg 'shared' is incompatible with inherited global definition - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "subcommand long option colliding with an inherited global is rejected" { - let cmd = @argparse.Command( - "demo", - options=[OptionArg("opt", long="dup", global=true)], - subcommands=[Command("child", flags=[FlagArg("other", long="dup")])], - ) - try cmd.parse(argv=[], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: arg 'other' long option --dup conflicts with inherited global 'opt' - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "compatible global flag can be redeclared in a subcommand" { - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("v", short='v', long="", global=true)], - subcommands=[ - Command("child", flags=[FlagArg("v", short='v', long="", global=true)]), - ], - ) - let parsed = cmd.parse(argv=["-v", "child"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.flags is { "v": true, .. }) - assert_true( - parsed.subcommand is Some(("child", sub)) && sub.flags is { "v": true, .. }, - ) -} - -///| -test "help subcommand cannot follow positional arguments" { - let cmd = @argparse.Command("demo", positionals=[PositionArg("input")], subcommands=[ - Command("run"), - ]) - try cmd.parse(argv=["raw", "help"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: subcommand 'help' cannot be used with positional arguments - #| - #|Usage: demo [input] [command] - #| - #|Commands: - #| run - #| help Print help for the subcommand(s). - #| - #|Arguments: - #| input - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "render_help lists positional members inside argument groups" { - let cmd = @argparse.Command( - "demo", - groups=[ArgGroup("inputs", args=["src"])], - positionals=[PositionArg("src")], - ) - inspect( - cmd.render_help(), - content=( - #|Usage: demo [src] - #| - #|Arguments: - #| src - #| - #|Options: - #| -h, --help Show help information. - #| - #|Groups: - #| inputs src - #| - ), - ) -} - -///| -test "render_help tolerates a required env-only option" { - let cmd = @argparse.Command("demo", options=[ - OptionArg("token", long="", env="TOKEN", required=true), - ]) - inspect( - cmd.render_help(), - content=( - #|Usage: demo - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) -} - -///| -test "global set option used in both parent and child argv conflicts" { - let cmd = @argparse.Command( - "demo", - options=[OptionArg("mode", short='m', long="", global=true)], - subcommands=[Command("run")], - ) - try cmd.parse(argv=["-m", "a", "run", "-m", "b"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: argument '-m' cannot be used multiple times - #| - #|Usage: demo [options] [command] - #| - #|Commands: - #| run - #| help Print help for the subcommand(s). - #| - #|Options: - #| -h, --help Show help information. - #| -m - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "bounded variadic positional stops accepting hyphen tokens once full" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg( - "items", - num_args=ValueRange(lower=0, upper=2), - allow_hyphen_values=true, - ), - ]) - try cmd.parse(argv=["a", "b", "-c"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '-c' found - #| - #|Usage: demo [items...] - #| - #|Arguments: - #| items... - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -// A leading variadic positional must reserve enough trailing tokens for a later -// positional with a minimum count, so a hyphen token is not mistaken for one of -// its values when the trailing slot still needs filling. -test "hyphen token is rejected while a later positional still needs values" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("head", num_args=ValueRange(lower=0), allow_hyphen_values=true), - PositionArg("tail", num_args=ValueRange(lower=2)), - ]) - try cmd.parse(argv=["-x"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '-x' found - #| - #|Usage: demo [head...] - #| - #|Arguments: - #| head... - #| tail... - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} diff --git a/argparse/argparse_test.mbt b/argparse/argparse_test.mbt deleted file mode 100644 index dbd8d5837e..0000000000 --- a/argparse/argparse_test.mbt +++ /dev/null @@ -1,797 +0,0 @@ -// Copyright 2026 International Digital Economy Academy -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -///| -fn empty_env() -> Map[String, String] { - Map([]) -} - -///| -test "declarative parse basics" { - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("verbose", short='v', long="verbose")], - options=[OptionArg("count", long="count", env="COUNT")], - positionals=[PositionArg("name")], - ) - let matches = cmd.parse(argv=["-v", "--count", "3", "alice"], env=empty_env()) catch { - _ => panic() - } - assert_true(matches.flags is { "verbose": true, .. }) - assert_true(matches.values is { "count": ["3"], "name": ["alice"], .. }) - assert_true( - matches.sources is { "verbose": Argv, "count": Argv, "name": Argv, .. }, - ) -} - -///| -test "long defaults to name when omitted" { - let cmd = @argparse.Command("demo", flags=[FlagArg("verbose")], options=[ - OptionArg("count"), - ]) - let matches = cmd.parse(argv=["--verbose", "--count", "3"], env=empty_env()) catch { - _ => panic() - } - assert_true(matches.flags is { "verbose": true, .. }) - assert_true(matches.values is { "count": ["3"], .. }) -} - -///| -test "long empty string disables long alias" { - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("verbose", short='v', long="")], - options=[OptionArg("count", short='c', long="")], - ) - - let matches = cmd.parse(argv=["-v", "-c", "3"], env=empty_env()) catch { - _ => panic() - } - assert_true(matches.flags is { "verbose": true, .. }) - assert_true(matches.values is { "count": ["3"], .. }) - - try cmd.parse(argv=["--verbose"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--verbose' found - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| -v - #| -c - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=["--count", "3"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--count' found - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| -v - #| -c - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "declaration order controls positional parsing" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("first"), - PositionArg("second"), - ]) - - let parsed = cmd.parse(argv=["a", "b"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.values is { "first": ["a"], "second": ["b"], .. }) -} - -///| -test "bounded non-last positional remains supported" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("first", num_args=ValueRange(lower=1, upper=2)), - PositionArg("second", num_args=@argparse.ValueRange::single()), - ]) - - let two = cmd.parse(argv=["a", "b"], env=empty_env()) catch { _ => panic() } - assert_true(two.values is { "first": ["a"], "second": ["b"], .. }) - - let three = cmd.parse(argv=["a", "b", "c"], env=empty_env()) catch { - _ => panic() - } - assert_true(three.values is { "first": ["a", "b"], "second": ["c"], .. }) -} - -///| -test "negatable flag preserves false state" { - let cmd = @argparse.Command("demo", flags=[ - FlagArg("cache", long="cache", negatable=true), - ]) - - let no_cache = cmd.parse(argv=["--no-cache"], env=empty_env()) catch { - _ => panic() - } - assert_true(no_cache.flags is { "cache": false, .. }) - assert_true(no_cache.flag_counts.get("cache") is None) -} - -///| -test "parse failure message contains error and contextual help" { - let cmd = @argparse.Command("demo", options=[ - OptionArg("count", long="count", about="repeat count"), - ]) - - try cmd.parse(argv=["--bad"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--bad' found - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --count repeat count - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "subcommand parse errors include subcommand help" { - let cmd = @argparse.Command("demo", subcommands=[ - Command("echo", options=[OptionArg("times", long="times")]), - ]) - - try cmd.parse(argv=["echo", "--bad"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--bad' found - #| - #|Usage: demo echo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --times - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "build errors are surfaced as validation failure message" { - let cmd = @argparse.Command("demo", flags=[ - FlagArg("fast", long="fast", requires=["missing"]), - ]) - - try cmd.parse(argv=[], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: command definition validation failed: unknown requires target: fast -> missing - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "unknown argument keeps suggestion in final message" { - let cmd = @argparse.Command("demo", flags=[FlagArg("verbose", long="verbose")]) - - try cmd.parse(argv=["--verbse"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--verbse' found - #| - #| tip: a similar argument exists: '--verbose' - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --verbose - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "render_help remains available for pure formatting" { - let cmd = @argparse.Command( - "demo", - about="Demo command", - flags=[FlagArg("verbose", short='v', long="verbose")], - options=[OptionArg("count", long="count")], - positionals=[PositionArg("name")], - subcommands=[Command("echo")], - ) - - let help = cmd.render_help() - assert_true(help.contains("Usage: demo [options] [name] [command]")) - assert_true(help.contains("Commands:")) - assert_true(help.contains("Options:")) -} - -///| -test "display help and version" { - let cmd = @argparse.Command("demo", about="demo app", version="1.2.3") - - inspect( - cmd.render_help(), - content=( - #|Usage: demo - #| - #|demo app - #| - #|Options: - #| -h, --help Show help information. - #| -V, --version Show version information. - #| - ), - ) - - try cmd.parse(argv=["--oops"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--oops' found - #| - #|Usage: demo - #| - #|demo app - #| - #|Options: - #| -h, --help Show help information. - #| -V, --version Show version information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "parse error show is readable" { - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("verbose", long="verbose")], - positionals=[PositionArg("name")], - ) - - try cmd.parse(argv=["--verbse"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--verbse' found - #| - #| tip: a similar argument exists: '--verbose' - #| - #|Usage: demo [options] [name] - #| - #|Arguments: - #| name - #| - #|Options: - #| -h, --help Show help information. - #| --verbose - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=["alice", "bob"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected value 'bob' for '' found; no more were expected - #| - #|Usage: demo [options] [name] - #| - #|Arguments: - #| name - #| - #|Options: - #| -h, --help Show help information. - #| --verbose - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "relationships and num args" { - let requires_cmd = @argparse.Command("demo", options=[ - OptionArg("mode", long="mode", requires=["config"]), - OptionArg("config", long="config"), - ]) - - try requires_cmd.parse(argv=["--mode", "fast"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: the following required argument was not provided: 'config' (required by 'mode') - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --mode - #| --config - #| - ), - ) - } noraise { - _ => panic() - } - - let appended = @argparse.Command("demo", options=[ - OptionArg("tag", long="tag", action=Append), - ]).parse(argv=["--tag", "a", "--tag", "b", "--tag", "c"], env=empty_env()) catch { - _ => panic() - } - assert_true(appended.values is { "tag": ["a", "b", "c"], .. }) -} - -///| -test "arg groups required and multiple" { - let cmd = @argparse.Command( - "demo", - groups=[ - ArgGroup("mode", required=true, multiple=false, args=["fast", "slow"]), - ], - flags=[FlagArg("fast", long="fast"), FlagArg("slow", long="slow")], - ) - - try cmd.parse(argv=[], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: the following required arguments were not provided: - #| <--fast|--slow> - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --fast - #| --slow - #| - #|Groups: - #| mode [required] [exclusive] --fast, --slow - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=["--fast", "--slow"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: group conflict mode - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --fast - #| --slow - #| - #|Groups: - #| mode [required] [exclusive] --fast, --slow - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "arg groups requires and conflicts" { - let requires_cmd = @argparse.Command( - "demo", - groups=[ - ArgGroup("mode", args=["fast"], requires=["output"]), - ArgGroup("output", args=["json"]), - ], - flags=[FlagArg("fast", long="fast"), FlagArg("json", long="json")], - ) - - try requires_cmd.parse(argv=["--fast"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: the following required arguments were not provided: - #| <--json> - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --fast - #| --json - #| - #|Groups: - #| mode --fast - #| output --json - #| - ), - ) - } noraise { - _ => panic() - } - - let conflict_cmd = @argparse.Command( - "demo", - groups=[ - ArgGroup("mode", args=["fast"], conflicts_with=["output"]), - ArgGroup("output", args=["json"]), - ], - flags=[FlagArg("fast", long="fast"), FlagArg("json", long="json")], - ) - - try conflict_cmd.parse(argv=["--fast", "--json"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: group conflict mode conflicts with output - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --fast - #| --json - #| - #|Groups: - #| mode --fast - #| output --json - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "subcommand parsing" { - let echo = @argparse.Command("echo", positionals=[PositionArg("msg")]) - let root = @argparse.Command("root", subcommands=[echo]) - - let matches = root.parse(argv=["echo", "hi"], env=empty_env()) catch { - _ => panic() - } - assert_true( - matches.subcommand is Some(("echo", sub)) && - sub.values is { "msg": ["hi"], .. }, - ) -} - -///| -test "full help snapshot" { - let cmd = @argparse.Command( - "demo", - about="Demo command", - flags=[ - FlagArg("verbose", short='v', long="verbose", about="Enable verbose mode"), - ], - options=[ - OptionArg("count", long="count", about="Repeat count", default_values=[ - "1", - ]), - ], - positionals=[PositionArg("name", about="Target name")], - subcommands=[Command("echo", about="Echo a message")], - ) - inspect( - cmd.render_help(), - content=( - #|Usage: demo [options] [name] [command] - #| - #|Demo command - #| - #|Commands: - #| echo Echo a message - #| help Print help for the subcommand(s). - #| - #|Arguments: - #| name Target name - #| - #|Options: - #| -h, --help Show help information. - #| -v, --verbose Enable verbose mode - #| --count Repeat count [default: 1] - #| - ), - ) -} - -///| -test "value source precedence argv env default" { - let cmd = @argparse.Command("demo", options=[ - OptionArg("level", long="level", env="LEVEL", default_values=["1"]), - ]) - - let from_default = cmd.parse(argv=[], env=empty_env()) catch { _ => panic() } - assert_true(from_default.values is { "level": ["1"], .. }) - assert_true(from_default.sources is { "level": Default, .. }) - - let from_env = cmd.parse(argv=[], env={ "LEVEL": "2" }) catch { _ => panic() } - assert_true(from_env.values is { "level": ["2"], .. }) - assert_true(from_env.sources is { "level": Env, .. }) - - let from_argv = cmd.parse(argv=["--level", "3"], env={ "LEVEL": "2" }) catch { - _ => panic() - } - assert_true(from_argv.values is { "level": ["3"], .. }) - assert_true(from_argv.sources is { "level": Argv, .. }) -} - -///| -test "omitted env does not read process environment by default" { - let cmd = @argparse.Command("demo", options=[ - OptionArg("count", long="count", env="COUNT"), - ]) - let matches = cmd.parse(argv=[]) catch { _ => panic() } - assert_true(matches.values is { "count"? : None, .. }) - assert_true(matches.sources is { "count"? : None, .. }) -} - -///| -test "options and multiple values" { - let serve = @argparse.Command("serve") - let cmd = @argparse.Command( - "demo", - options=[ - OptionArg("count", short='c', long="count"), - OptionArg("tag", long="tag", action=Append), - ], - subcommands=[serve], - ) - - let long_count = cmd.parse(argv=["--count", "2"], env=empty_env()) catch { - _ => panic() - } - assert_true(long_count.values is { "count": ["2"], .. }) - - let short_count = cmd.parse(argv=["-c", "3"], env=empty_env()) catch { - _ => panic() - } - assert_true(short_count.values is { "count": ["3"], .. }) - - let multi = cmd.parse(argv=["--tag", "a", "--tag", "b"], env=empty_env()) catch { - _ => panic() - } - assert_true(multi.values is { "tag": ["a", "b"], .. }) - - let subcommand = cmd.parse(argv=["serve"], env=empty_env()) catch { - _ => panic() - } - assert_true(subcommand.subcommand is Some(("serve", _))) -} - -///| -test "negatable and conflicts" { - let cmd = @argparse.Command("demo", flags=[ - FlagArg("cache", long="cache", negatable=true), - FlagArg("failfast", long="failfast", action=SetFalse, negatable=true), - FlagArg("verbose", long="verbose", conflicts_with=["quiet"]), - FlagArg("quiet", long="quiet"), - ]) - - let no_cache = cmd.parse(argv=["--no-cache"], env=empty_env()) catch { - _ => panic() - } - assert_true(no_cache.flags is { "cache": false, .. }) - assert_true(no_cache.sources is { "cache": Argv, .. }) - - let no_failfast = cmd.parse(argv=["--no-failfast"], env=empty_env()) catch { - _ => panic() - } - assert_true(no_failfast.flags is { "failfast": true, .. }) - - try cmd.parse(argv=["--verbose", "--quiet"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: conflicting arguments: verbose and quiet - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --[no-]cache - #| --[no-]failfast - #| --verbose - #| --quiet - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "flag does not accept inline value" { - let cmd = @argparse.Command("demo", flags=[FlagArg("verbose", long="verbose")]) - try cmd.parse(argv=["--verbose=true"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--verbose=true' found - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --verbose - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "built-in long flags do not accept inline value" { - let cmd = @argparse.Command("demo", version="1.2.3") - - try cmd.parse(argv=["--help=1"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--help=1' found - #| - #|Usage: demo - #| - #|Options: - #| -h, --help Show help information. - #| -V, --version Show version information. - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=["--version=1"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--version=1' found - #| - #|Usage: demo - #| - #|Options: - #| -h, --help Show help information. - #| -V, --version Show version information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "command policies" { - let help_cmd = @argparse.Command("demo", arg_required_else_help=true) - inspect( - help_cmd.render_help(), - content=( - #|Usage: demo - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - - let sub_cmd = @argparse.Command("demo", subcommand_required=true, subcommands=[ - Command("echo"), - ]) - inspect( - sub_cmd.render_help(), - content=( - #|Usage: demo - #| - #|Commands: - #| echo - #| help Print help for the subcommand(s). - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - try sub_cmd.parse(argv=[], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: the following required argument was not provided: 'subcommand' - #| - #|Usage: demo - #| - #|Commands: - #| echo - #| help Print help for the subcommand(s). - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} diff --git a/argparse/command.mbt b/argparse/command.mbt index ab03488c44..dec9001e54 100644 --- a/argparse/command.mbt +++ b/argparse/command.mbt @@ -138,7 +138,7 @@ fn build_matches( let specs = inherited_globals + cmd.args for spec in specs { - let name = arg_name(spec) + let name = spec.name if raw.values.get(name) is Some(vs) { values[name] = vs.copy() } diff --git a/argparse/command_test.mbt b/argparse/command_test.mbt new file mode 100644 index 0000000000..6b45a0f414 --- /dev/null +++ b/argparse/command_test.mbt @@ -0,0 +1,85 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +fn empty_env() -> Map[String, String] { + Map([]) +} + +///| +test "declarative parse basics" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", short='v', long="verbose")], + options=[OptionArg("count", long="count", env="COUNT")], + positionals=[PositionArg("name")], + ) + let matches = cmd.parse(argv=["-v", "--count", "3", "alice"], env=empty_env()) catch { + _ => panic() + } + assert_true(matches.flags is { "verbose": true, .. }) + assert_true(matches.values is { "count": ["3"], "name": ["alice"], .. }) + assert_true( + matches.sources is { "verbose": Argv, "count": Argv, "name": Argv, .. }, + ) +} + +///| +test "command policies" { + let help_cmd = @argparse.Command("demo", arg_required_else_help=true) + inspect( + help_cmd.render_help(), + content=( + #|Usage: demo + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + + let sub_cmd = @argparse.Command("demo", subcommand_required=true, subcommands=[ + Command("echo"), + ]) + inspect( + sub_cmd.render_help(), + content=( + #|Usage: demo + #| + #|Commands: + #| echo + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + inspect( + @test.expect_error(() => sub_cmd.parse(argv=[], env=empty_env())), + content=( + #|error: the following required argument was not provided: 'subcommand' + #| + #|Usage: demo + #| + #|Commands: + #| echo + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) +} diff --git a/argparse/errors_test.mbt b/argparse/errors_test.mbt new file mode 100644 index 0000000000..e1f138f22e --- /dev/null +++ b/argparse/errors_test.mbt @@ -0,0 +1,193 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +test "unknown argument keeps suggestion in final message" { + let cmd = @argparse.Command("demo", flags=[FlagArg("verbose", long="verbose")]) + + inspect( + @test.expect_error(() => cmd.parse(argv=["--verbse"], env=empty_env())), + content=( + #|error: unexpected argument '--verbse' found + #| + #| tip: a similar argument exists: '--verbose' + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --verbose + #| + ), + ) +} + +///| +test "parse error show is readable" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose")], + positionals=[PositionArg("name")], + ) + + inspect( + @test.expect_error(() => cmd.parse(argv=["--verbse"], env=empty_env())), + content=( + #|error: unexpected argument '--verbse' found + #| + #| tip: a similar argument exists: '--verbose' + #| + #|Usage: demo [options] [name] + #| + #|Arguments: + #| name + #| + #|Options: + #| -h, --help Show help information. + #| --verbose + #| + ), + ) + + inspect( + @test.expect_error(() => cmd.parse(argv=["alice", "bob"], env=empty_env())), + content=( + #|error: unexpected value 'bob' for '' found; no more were expected + #| + #|Usage: demo [options] [name] + #| + #|Arguments: + #| name + #| + #|Options: + #| -h, --help Show help information. + #| --verbose + #| + ), + ) +} + +///| +test "unknown argument suggestions are exposed" { + let cmd = @argparse.Command("demo", flags=[ + FlagArg("verbose", short='v', long="verbose"), + ]) + + inspect( + @test.expect_error(() => cmd.parse(argv=["--verbse"], env=empty_env())), + content=( + #|error: unexpected argument '--verbse' found + #| + #| tip: a similar argument exists: '--verbose' + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -v, --verbose + #| + ), + ) + + inspect( + @test.expect_error(() => cmd.parse(argv=["-x"], env=empty_env())), + content=( + #|error: unexpected argument '-x' found + #| + #| tip: a similar argument exists: '-v' + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -v, --verbose + #| + ), + ) + + inspect( + @test.expect_error(() => cmd.parse(argv=["--zzzzzzzzzz"], env=empty_env())), + content=( + #|error: unexpected argument '--zzzzzzzzzz' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -v, --verbose + #| + ), + ) +} + +///| +test "unified error message formatting remains stable" { + let cmd = @argparse.Command("demo", options=[OptionArg("tag", long="tag")]) + + inspect( + @test.expect_error(() => cmd.parse(argv=["--oops"], env=empty_env())), + content=( + #|error: unexpected argument '--oops' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --tag + #| + ), + ) + + inspect( + @test.expect_error(() => cmd.parse(argv=["--tag"], env=empty_env())), + content=( + #|error: a value is required for '--tag' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --tag + #| + ), + ) +} + +///| +test "unknown short suggestion can be absent" { + let cmd = @argparse.Command("demo", disable_help_flag=true, options=[ + OptionArg("name", long="name"), + ]) + + inspect( + @test.expect_error(() => cmd.parse(argv=["-x"], env=empty_env())), + content=( + #|error: unexpected argument '-x' found + #| + #|Usage: demo [options] + #| + #|Options: + #| --name + #| + ), + ) +} + +///| +test "Debug for argparse enums" { + @debug.debug_inspect(@argparse.FlagAction::SetTrue, content="SetTrue") + @debug.debug_inspect(@argparse.FlagAction::Count, content="Count") + @debug.debug_inspect(@argparse.OptionAction::Set, content="Set") + @debug.debug_inspect(@argparse.OptionAction::Append, content="Append") +} diff --git a/argparse/globals_test.mbt b/argparse/globals_test.mbt new file mode 100644 index 0000000000..ae7e598333 --- /dev/null +++ b/argparse/globals_test.mbt @@ -0,0 +1,601 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +test "global option merges parent and child values" { + let child = @argparse.Command("run") + let cmd = @argparse.Command( + "demo", + options=[ + OptionArg( + "profile", + short='p', + long="profile", + action=Append, + global=true, + ), + ], + subcommands=[child], + ) + + let matches = cmd.parse( + argv=["--profile", "parent", "run", "--profile", "child"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(matches.values is { "profile": ["parent", "child"], .. }) + assert_true(matches.sources is { "profile": Argv, .. }) + assert_true( + matches.subcommand is Some(("run", sub)) && + sub.values is { "profile": ["parent", "child"], .. }, + ) +} + +///| +test "global requires is validated after parent-child merge" { + let cmd = @argparse.Command( + "demo", + options=[ + OptionArg("mode", long="mode", requires=["config"], global=true), + OptionArg("config", long="config", global=true), + ], + subcommands=[Command("run")], + ) + + let parsed = cmd.parse( + argv=["--config", "a.toml", "run", "--mode", "fast"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true( + parsed.values is { "config": ["a.toml"], "mode": ["fast"], .. } && + parsed.subcommand is Some(("run", sub)) && + sub.values is { "config": ["a.toml"], "mode": ["fast"], .. }, + ) +} + +///| +test "global append keeps parent argv over child env/default" { + let child = @argparse.Command("run") + let cmd = @argparse.Command( + "demo", + options=[ + OptionArg( + "profile", + long="profile", + action=Append, + env="PROFILE", + default_values=["def"], + global=true, + ), + ], + subcommands=[child], + ) + + let matches = cmd.parse(argv=["--profile", "parent", "run"], env={ + "PROFILE": "env", + }) catch { + _ => panic() + } + assert_true(matches.values is { "profile": ["parent"], .. }) + assert_true(matches.sources is { "profile": Argv, .. }) + assert_true( + matches.subcommand is Some(("run", sub)) && + sub.values is { "profile": ["parent"], .. } && + sub.sources is { "profile": Argv, .. }, + ) +} + +///| +test "global scalar keeps parent argv over child env/default" { + let child = @argparse.Command("run") + let cmd = @argparse.Command( + "demo", + options=[ + OptionArg( + "profile", + long="profile", + env="PROFILE", + default_values=["def"], + global=true, + ), + ], + subcommands=[child], + ) + + let matches = cmd.parse(argv=["--profile", "parent", "run"], env={ + "PROFILE": "env", + }) catch { + _ => panic() + } + assert_true(matches.values is { "profile": ["parent"], .. }) + assert_true(matches.sources is { "profile": Argv, .. }) + assert_true( + matches.subcommand is Some(("run", sub)) && + sub.values is { "profile": ["parent"], .. } && + sub.sources is { "profile": Argv, .. }, + ) +} + +///| +test "global count merges parent and child occurrences" { + let child = @argparse.Command("run") + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", short='v', action=Count, global=true)], + subcommands=[child], + ) + + let matches = cmd.parse(argv=["-v", "run", "-v", "-v"], env=empty_env()) catch { + _ => panic() + } + assert_true(matches.flag_counts is { "verbose": 3, .. }) + assert_true( + matches.subcommand is Some(("run", sub)) && + sub.flag_counts is { "verbose": 3, .. }, + ) +} + +///| +test "global count keeps parent argv over child env fallback" { + let child = @argparse.Command("run") + let cmd = @argparse.Command( + "demo", + flags=[ + FlagArg( + "verbose", + short='v', + long="verbose", + action=Count, + env="VERBOSE", + global=true, + ), + ], + subcommands=[child], + ) + + let matches = cmd.parse(argv=["-v", "run"], env={ "VERBOSE": "1" }) catch { + _ => panic() + } + assert_true(matches.flag_counts is { "verbose": 1, .. }) + assert_true(matches.sources is { "verbose": Argv, .. }) + assert_true( + matches.subcommand is Some(("run", sub)) && + sub.flag_counts is { "verbose": 1, .. } && + sub.sources is { "verbose": Argv, .. }, + ) +} + +///| +test "global flag keeps parent argv over child env fallback" { + let child = @argparse.Command("run") + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose", env="VERBOSE", global=true)], + subcommands=[child], + ) + + let matches = cmd.parse(argv=["--verbose", "run"], env={ "VERBOSE": "0" }) catch { + _ => panic() + } + assert_true(matches.flags is { "verbose": true, .. }) + assert_true(matches.sources is { "verbose": Argv, .. }) + assert_true( + matches.subcommand is Some(("run", sub)) && + sub.flags is { "verbose": true, .. } && + sub.sources is { "verbose": Argv, .. }, + ) +} + +///| +test "global count source keeps env across subcommand merge" { + let child = @argparse.Command("run") + let cmd = @argparse.Command( + "demo", + flags=[ + FlagArg( + "verbose", + short='v', + long="verbose", + action=Count, + env="VERBOSE", + global=true, + ), + ], + subcommands=[child], + ) + + let matches = cmd.parse(argv=["run"], env={ "VERBOSE": "1" }) catch { + _ => panic() + } + assert_true(matches.flags is { "verbose": true, .. }) + assert_true(matches.flag_counts is { "verbose": 1, .. }) + assert_true(matches.sources is { "verbose": Env, .. }) + assert_true( + matches.subcommand is Some(("run", sub)) && + sub.flag_counts is { "verbose": 1, .. } && + sub.sources is { "verbose": Env, .. }, + ) +} + +///| +test "global value from child default is merged back to parent" { + let cmd = @argparse.Command( + "demo", + options=[ + OptionArg("mode", long="mode", default_values=["safe"], global=true), + OptionArg("unused", long="unused", global=true), + ], + subcommands=[Command("run")], + ) + + let parsed = cmd.parse(argv=["run"], env=empty_env()) catch { _ => panic() } + assert_true(parsed.values is { "mode": ["safe"], "unused"? : None, .. }) + assert_true(parsed.sources is { "mode": Default, .. }) + assert_true( + parsed.subcommand is Some(("run", sub)) && + sub.values is { "mode": ["safe"], .. } && + sub.sources is { "mode": Default, .. }, + ) +} + +///| +test "env-only global is propagated to nested subcommand matches" { + let cmd = @argparse.Command( + "demo", + options=[OptionArg("level", long="", env="LEVEL", global=true)], + subcommands=[Command("run", subcommands=[Command("leaf")])], + ) + + let parsed = cmd.parse(argv=["run", "leaf"], env={ "LEVEL": "5" }) catch { + _ => panic() + } + assert_true(parsed.values is { "level": ["5"], .. }) + assert_true(parsed.sources is { "level": Env, .. }) + assert_true( + parsed.subcommand is Some(("run", sub_run)) && + sub_run.values is { "level": ["5"], .. } && + sub_run.sources is { "level": Env, .. } && + sub_run.subcommand is Some(("leaf", sub_leaf)) && + sub_leaf.values is { "level": ["5"], .. } && + sub_leaf.sources is { "level": Env, .. }, + ) +} + +///| +test "child global arg with inherited global name updates parent global" { + let cmd = @argparse.Command( + "demo", + options=[ + OptionArg("mode", long="mode", default_values=["safe"], global=true), + ], + subcommands=[ + Command("run", options=[OptionArg("mode", long="mode", global=true)]), + ], + ) + + let parsed = cmd.parse(argv=["run", "--mode", "fast"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "mode": ["fast"], .. }) + assert_true(parsed.sources is { "mode": Argv, .. }) + assert_true( + parsed.subcommand is Some(("run", sub)) && + sub.values is { "mode": ["fast"], .. } && + sub.sources is { "mode": Argv, .. }, + ) +} + +///| +test "child global override env/default win over inherited definition" { + let cmd = @argparse.Command( + "demo", + options=[ + OptionArg( + "mode", + long="mode", + env="ROOT_MODE", + default_values=["safe"], + global=true, + ), + ], + subcommands=[ + Command("run", options=[ + OptionArg( + "mode", + long="mode", + env="RUN_MODE", + default_values=["fast"], + global=true, + ), + ]), + ], + ) + + let from_env = cmd.parse(argv=["run"], env={ + "ROOT_MODE": "root-env", + "RUN_MODE": "run-env", + }) catch { + _ => panic() + } + assert_true(from_env.values is { "mode": ["run-env"], .. }) + assert_true(from_env.sources is { "mode": Env, .. }) + assert_true( + from_env.subcommand is Some(("run", sub)) && + sub.values is { "mode": ["run-env"], .. } && + sub.sources is { "mode": Env, .. }, + ) + + let from_default = cmd.parse(argv=["run"], env=Map([])) catch { _ => panic() } + assert_true(from_default.values is { "mode": ["fast"], .. }) + assert_true(from_default.sources is { "mode": Default, .. }) + assert_true( + from_default.subcommand is Some(("run", sub)) && + sub.values is { "mode": ["fast"], .. } && + sub.sources is { "mode": Default, .. }, + ) +} + +///| +test "inherited argv global satisfies child required global override" { + let cmd = @argparse.Command( + "demo", + options=[OptionArg("mode", long="mode", global=true)], + subcommands=[ + Command("run", options=[ + OptionArg("mode", long="mode", required=true, global=true), + ]), + ], + ) + + let parsed = cmd.parse(argv=["--mode", "fast", "run"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "mode": ["fast"], .. }) + assert_true(parsed.sources is { "mode": Argv, .. }) + assert_true( + parsed.subcommand is Some(("run", sub)) && + sub.values is { "mode": ["fast"], .. } && + sub.sources is { "mode": Argv, .. }, + ) +} + +///| +test "global append env value from child is merged back to parent" { + let cmd = @argparse.Command( + "demo", + options=[ + OptionArg("tag", long="tag", action=Append, env="TAG", global=true), + ], + subcommands=[Command("run")], + ) + + let parsed = cmd.parse(argv=["run"], env={ "TAG": "env-tag" }) catch { + _ => panic() + } + assert_true(parsed.values is { "tag": ["env-tag"], .. }) + assert_true(parsed.sources is { "tag": Env, .. }) + assert_true( + parsed.subcommand is Some(("run", sub)) && + sub.values is { "tag": ["env-tag"], .. } && + sub.sources is { "tag": Env, .. }, + ) +} + +///| +test "global flag set in child argv is merged back to parent" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose", global=true)], + subcommands=[Command("run")], + ) + + let parsed = cmd.parse(argv=["run", "--verbose"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.flags is { "verbose": true, .. }) + assert_true(parsed.sources is { "verbose": Argv, .. }) + assert_true( + parsed.subcommand is Some(("run", sub)) && + sub.flags is { "verbose": true, .. } && + sub.sources is { "verbose": Argv, .. }, + ) +} + +///| +test "global count negation after subcommand resets merged state" { + let cmd = @argparse.Command( + "demo", + flags=[ + FlagArg( + "verbose", + long="verbose", + action=Count, + negatable=true, + global=true, + ), + ], + subcommands=[Command("run")], + ) + + let parsed = cmd.parse( + argv=["--verbose", "run", "--no-verbose"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(parsed.flags is { "verbose": false, .. }) + assert_true(parsed.flag_counts.get("verbose") is None) + assert_true(parsed.sources is { "verbose": Argv, .. }) + assert_true( + parsed.subcommand is Some(("run", sub)) && + sub.flags is { "verbose": false, .. } && + sub.flag_counts.get("verbose") is None && + sub.sources is { "verbose": Argv, .. }, + ) +} + +///| +test "global set option rejects duplicate occurrences across subcommands" { + let cmd = @argparse.Command( + "demo", + options=[OptionArg("mode", long="mode", global=true)], + subcommands=[Command("run")], + ) + inspect( + @test.expect_error(() => { + cmd.parse(argv=["--mode", "a", "run", "--mode", "b"], env=empty_env()) + }), + content=( + #|error: argument '--mode' cannot be used multiple times + #| + #|Usage: demo [options] [command] + #| + #|Commands: + #| run + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| --mode + #| + ), + ) +} + +///| +test "nested subcommands inherit finalized globals from ancestors" { + let leaf = @argparse.Command("leaf") + let mid = @argparse.Command("mid", subcommands=[leaf]) + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose", global=true)], + subcommands=[mid], + ) + + let parsed = cmd.parse(argv=["--verbose", "mid", "leaf"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.flags is { "verbose": true, .. }) + assert_true( + parsed.subcommand is Some(("mid", mid_matches)) && + mid_matches.flags is { "verbose": true, .. } && + mid_matches.subcommand is Some(("leaf", leaf_matches)) && + leaf_matches.flags is { "verbose": true, .. } && + leaf_matches.sources is { "verbose": Argv, .. }, + ) +} + +///| +test "nested global override deduplicates count merge by name" { + let leaf = @argparse.Command("leaf") + let mid = @argparse.Command( + "mid", + flags=[FlagArg("verbose", long="verbose", action=Count, global=true)], + subcommands=[leaf], + ) + let root = @argparse.Command( + "root", + flags=[FlagArg("verbose", long="verbose", action=Count, global=true)], + subcommands=[mid], + ) + + let parsed = root.parse(argv=["mid", "leaf", "--verbose"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.flag_counts is { "verbose": 1, .. }) + assert_true( + parsed.subcommand is Some(("mid", sub_mid)) && + sub_mid.flag_counts is { "verbose": 1, .. } && + sub_mid.subcommand is Some(("leaf", sub_leaf)) && + sub_leaf.flag_counts is { "verbose": 1, .. }, + ) +} + +///| +test "nested global override keeps single set value without false duplicate error" { + let leaf = @argparse.Command("leaf") + let mid = @argparse.Command( + "mid", + options=[OptionArg("mode", long="mode", global=true)], + subcommands=[leaf], + ) + let root = @argparse.Command( + "root", + options=[OptionArg("mode", long="mode", global=true)], + subcommands=[mid], + ) + + let parsed = root.parse( + argv=["mid", "leaf", "--mode", "fast"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(parsed.values is { "mode": ["fast"], .. }) + assert_true( + parsed.subcommand is Some(("mid", sub_mid)) && + sub_mid.values is { "mode": ["fast"], .. } && + sub_mid.subcommand is Some(("leaf", sub_leaf)) && + sub_leaf.values is { "mode": ["fast"], .. }, + ) +} + +///| +test "compatible global flag can be redeclared in a subcommand" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("v", short='v', long="", global=true)], + subcommands=[ + Command("child", flags=[FlagArg("v", short='v', long="", global=true)]), + ], + ) + let parsed = cmd.parse(argv=["-v", "child"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.flags is { "v": true, .. }) + assert_true( + parsed.subcommand is Some(("child", sub)) && sub.flags is { "v": true, .. }, + ) +} + +///| +test "global set option used in both parent and child argv conflicts" { + let cmd = @argparse.Command( + "demo", + options=[OptionArg("mode", short='m', long="", global=true)], + subcommands=[Command("run")], + ) + inspect( + @test.expect_error(() => { + cmd.parse(argv=["-m", "a", "run", "-m", "b"], env=empty_env()) + }), + content=( + #|error: argument '-m' cannot be used multiple times + #| + #|Usage: demo [options] [command] + #| + #|Commands: + #| run + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| -m + #| + ), + ) +} diff --git a/argparse/help_render.mbt b/argparse/help_render.mbt index 69762275fa..ef0d2d2438 100644 --- a/argparse/help_render.mbt +++ b/argparse/help_render.mbt @@ -125,7 +125,7 @@ fn required_option_token(arg : Arg) -> String? { ///| fn positional_usage(cmd : Command) -> String { - let parts = Array::new(capacity=cmd.args.length()) + let parts = Array(capacity=cmd.args.length()) for arg in positional_args(cmd.args) { if arg.hidden { continue @@ -149,7 +149,7 @@ fn positional_usage(cmd : Command) -> String { ///| fn option_entries(cmd : Command) -> Array[String] { let args = cmd.args - let display = Array::new(capacity=args.length() + 2) + let display = Array(capacity=args.length() + 2) let builtin_help_short = help_flag_enabled(cmd) && !has_short_option(args, 'h') let builtin_help_long = help_flag_enabled(cmd) && @@ -261,7 +261,7 @@ fn subcommand_entries(cmd : Command) -> Array[String] { ///| fn group_entries(cmd : Command) -> Array[String] { - let display = Array::new(capacity=cmd.groups.length()) + let display = Array(capacity=cmd.groups.length()) for group in cmd.groups { let members = group_members(cmd, group) if members == "" { @@ -313,7 +313,7 @@ fn format_entries(display : Array[(String, String)]) -> Array[String] { ///| fn arg_display(arg : Arg) -> String { - let parts = Array::new(capacity=2) + let parts = Array(capacity=2) let (short, long) = match arg.info { OptionInfo(short~, long~, ..) | FlagInfo(short~, long~, ..) diff --git a/argparse/help_test.mbt b/argparse/help_test.mbt new file mode 100644 index 0000000000..f3dd3d3c08 --- /dev/null +++ b/argparse/help_test.mbt @@ -0,0 +1,886 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +test "parse failure message contains error and contextual help" { + let cmd = @argparse.Command("demo", options=[ + OptionArg("count", long="count", about="repeat count"), + ]) + + inspect( + @test.expect_error(() => cmd.parse(argv=["--bad"], env=empty_env())), + content=( + #|error: unexpected argument '--bad' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --count repeat count + #| + ), + ) +} + +///| +test "subcommand parse errors include subcommand help" { + let cmd = @argparse.Command("demo", subcommands=[ + Command("echo", options=[OptionArg("times", long="times")]), + ]) + + inspect( + @test.expect_error(() => cmd.parse(argv=["echo", "--bad"], env=empty_env())), + content=( + #|error: unexpected argument '--bad' found + #| + #|Usage: demo echo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --times + #| + ), + ) +} + +///| +test "render_help remains available for pure formatting" { + let cmd = @argparse.Command( + "demo", + about="Demo command", + flags=[FlagArg("verbose", short='v', long="verbose")], + options=[OptionArg("count", long="count")], + positionals=[PositionArg("name")], + subcommands=[Command("echo")], + ) + + let help = cmd.render_help() + assert_true(help.contains("Usage: demo [options] [name] [command]")) + assert_true(help.contains("Commands:")) + assert_true(help.contains("Options:")) +} + +///| +test "display help and version" { + let cmd = @argparse.Command("demo", about="demo app", version="1.2.3") + + inspect( + cmd.render_help(), + content=( + #|Usage: demo + #| + #|demo app + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| + ), + ) + + inspect( + @test.expect_error(() => cmd.parse(argv=["--oops"], env=empty_env())), + content=( + #|error: unexpected argument '--oops' found + #| + #|Usage: demo + #| + #|demo app + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| + ), + ) +} + +///| +test "full help snapshot" { + let cmd = @argparse.Command( + "demo", + about="Demo command", + flags=[ + FlagArg("verbose", short='v', long="verbose", about="Enable verbose mode"), + ], + options=[ + OptionArg("count", long="count", about="Repeat count", default_values=[ + "1", + ]), + ], + positionals=[PositionArg("name", about="Target name")], + subcommands=[Command("echo", about="Echo a message")], + ) + inspect( + cmd.render_help(), + content=( + #|Usage: demo [options] [name] [command] + #| + #|Demo command + #| + #|Commands: + #| echo Echo a message + #| help Print help for the subcommand(s). + #| + #|Arguments: + #| name Target name + #| + #|Options: + #| -h, --help Show help information. + #| -v, --verbose Enable verbose mode + #| --count Repeat count [default: 1] + #| + ), + ) +} + +///| +test "render help snapshot with groups and hidden entries" { + let cmd = @argparse.Command( + "render", + groups=[ + ArgGroup("mode", required=true, multiple=false, args=[ + "fast", "slow", "path", + ]), + ], + subcommands=[ + Command("run", about="run"), + Command("hidden", about="hidden", hidden=true), + ], + flags=[ + FlagArg("fast", short='f', long="fast"), + FlagArg("slow", long="slow", hidden=true), + FlagArg("cache", long="cache", negatable=true, about="cache"), + ], + options=[ + OptionArg( + "path", + short='p', + long="path", + env="PATH_ENV", + default_values=["a", "b"], + required=true, + ), + ], + positionals=[ + PositionArg("target", num_args=@argparse.ValueRange::single()), + PositionArg("rest", num_args=ValueRange(lower=0)), + PositionArg("secret", hidden=true), + ], + ) + inspect( + cmd.render_help(), + content=( + #|Usage: render --path [options] [rest...] [command] + #| + #|Commands: + #| run run + #| help Print help for the subcommand(s). + #| + #|Arguments: + #| target + #| rest... + #| + #|Options: + #| -h, --help Show help information. + #| -f, --fast + #| --[no-]cache cache + #| -p, --path [env: PATH_ENV] [default: a, b] + #| + #|Groups: + #| mode [required] [exclusive] -f, --fast, -p, --path + #| + ), + ) +} + +///| +test "render help conversion coverage snapshot" { + let cmd = @argparse.Command( + "shape", + groups=[ArgGroup("grp", args=["f", "opt", "pos"])], + flags=[ + FlagArg( + "f", + short='f', + about="f", + env="F_ENV", + requires=["opt"], + global=true, + hidden=true, + ), + ], + options=[ + OptionArg( + "opt", + short='o', + about="opt", + default_values=["x", "y"], + env="OPT_ENV", + allow_hyphen_values=true, + required=true, + global=true, + hidden=true, + conflicts_with=["pos"], + ), + ], + positionals=[ + PositionArg( + "pos", + about="pos", + env="POS_ENV", + default_values=["p1", "p2"], + num_args=ValueRange(lower=0, upper=2), + allow_hyphen_values=true, + requires=["opt"], + conflicts_with=["f"], + global=true, + hidden=true, + ), + ], + ) + inspect( + cmd.render_help(), + content=( + #|Usage: shape + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) +} + +///| +test "default subcommand help annotation and child error context" { + let cmd = @argparse.Command( + "openseek", + options=[OptionArg("config", long="config", about="config", global=true)], + subcommands=[ + Command("tui", about="Start interactive UI", options=[ + OptionArg("theme", long="theme", about="theme"), + ]), + Command("mcp", about="Run MCP server"), + ], + default_subcommand="tui", + ) + + inspect( + cmd.render_help(), + content=( + #|Usage: openseek [options] [command] + #| + #|Commands: + #| tui Start interactive UI (default) + #| mcp Run MCP server + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| --config config + #| + ), + ) + + inspect( + @test.expect_error(() => cmd.parse(argv=["--unknown"], env=empty_env())), + content=( + #|error: unexpected argument '--unknown' found + #| + #|Usage: openseek tui [options] + #| + #|Start interactive UI + #| + #|Options: + #| -h, --help Show help information. + #| --config config + #| --theme theme + #| + ), + ) +} + +///| +test "help subcommand styles and errors" { + let leaf = @argparse.Command("echo", about="echo") + let cmd = @argparse.Command("demo", subcommands=[leaf]) + + inspect( + leaf.render_help(), + content=( + #|Usage: echo + #| + #|echo + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + inspect( + cmd.render_help(), + content=( + #|Usage: demo [command] + #| + #|Commands: + #| echo echo + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + + inspect( + @test.expect_error(() => cmd.parse(argv=["help", "--bad"], env=empty_env())), + content=( + #|error: unexpected help argument: --bad + #| + #|Usage: demo [command] + #| + #|Commands: + #| echo echo + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + + inspect( + @test.expect_error(() => { + cmd.parse(argv=["help", "missing"], env=empty_env()) + }), + content=( + #|error: unknown subcommand: missing + #| + #|Usage: demo [command] + #| + #|Commands: + #| echo echo + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) +} + +///| +test "subcommand help includes inherited global options" { + let leaf = @argparse.Command("echo", about="echo") + let cmd = @argparse.Command( + "demo", + flags=[ + FlagArg( + "verbose", + short='v', + long="verbose", + about="Enable verbose mode", + global=true, + ), + ], + subcommands=[leaf], + ) + + inspect( + @test.expect_error(() => cmd.parse(argv=["echo", "--bad"], env=empty_env())), + content=( + #|error: unexpected argument '--bad' found + #| + #|Usage: demo echo [options] + #| + #|echo + #| + #|Options: + #| -h, --help Show help information. + #| -v, --verbose Enable verbose mode + #| + ), + ) +} + +///| +test "help subcommand suggestions exclude hidden commands" { + let cmd = @argparse.Command("demo", subcommands=[ + Command("serve", about="serve"), + Command("secret", about="secret", hidden=true), + ]) + inspect( + @test.expect_error(() => cmd.parse(argv=["help", "secrt"], env=empty_env())), + content=( + #|error: unknown subcommand: secrt + #| + #|Usage: demo [command] + #| + #|Commands: + #| serve serve + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) +} + +///| +test "builtin and custom help/version dispatch edge paths" { + let custom_help = @argparse.Command("demo", flags=[ + FlagArg("custom_help", short='h', long="help", about="custom help"), + ]) + let help_short = custom_help.parse(argv=["-h"], env=empty_env()) catch { + _ => panic() + } + let help_long = custom_help.parse(argv=["--help"], env=empty_env()) catch { + _ => panic() + } + assert_true(help_short.flags is { "custom_help": true, .. }) + assert_true(help_long.flags is { "custom_help": true, .. }) + inspect( + custom_help.render_help(), + content=( + #|Usage: demo [options] + #| + #|Options: + #| -h, --help custom help + #| + ), + ) + + let custom_version = @argparse.Command("demo", version="1.0", flags=[ + FlagArg("custom_version", short='V', long="version", about="custom version"), + ]) + let version_short = custom_version.parse(argv=["-V"], env=empty_env()) catch { + _ => panic() + } + let version_long = custom_version.parse(argv=["--version"], env=empty_env()) catch { + _ => panic() + } + assert_true(version_short.flags is { "custom_version": true, .. }) + assert_true(version_long.flags is { "custom_version": true, .. }) + inspect( + custom_version.render_help(), + content=( + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version custom version + #| + ), + ) + + let versioned = @argparse.Command("demo", version="1.2.3") + inspect( + versioned.render_help(), + content=( + #|Usage: demo + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| + ), + ) + + inspect( + @test.expect_error(() => versioned.parse(argv=["--oops"], env=empty_env())), + content=( + #|error: unexpected argument '--oops' found + #| + #|Usage: demo + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| + ), + ) + + let long_help = @argparse.Command("demo", flags=[ + FlagArg("assist", long="assist", action=Help), + ]) + inspect( + long_help.render_help(), + content=( + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --assist + #| + ), + ) + + let short_help = @argparse.Command("demo", flags=[ + FlagArg("assist", short='?', action=Help), + ]) + inspect( + short_help.render_help(), + content=( + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -?, --assist + #| + ), + ) +} + +///| +test "help rendering edge paths stay stable" { + let required_many = @argparse.Command("demo", positionals=[ + PositionArg("files", num_args=ValueRange(lower=1)), + ]) + let required_help = required_many.render_help() + assert_true(required_help.has_prefix("Usage: demo ")) + + let short_only_builtin = @argparse.Command("demo", options=[ + OptionArg("helpopt", long="help"), + ]) + let short_only_text = short_only_builtin.render_help() + assert_true(short_only_text.has_prefix("Usage: demo")) + inspect( + @test.expect_error(() => { + short_only_builtin.parse(argv=["--help"], env=empty_env()) + }), + content=( + #|error: a value is required for '--help' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h Show help information. + #| --help + #| + ), + ) + + let long_only_builtin = @argparse.Command("demo", flags=[ + FlagArg("custom_h", short='h'), + ]) + let long_only_text = long_only_builtin.render_help() + assert_true(long_only_text.has_prefix("Usage: demo")) + let custom_h = long_only_builtin.parse(argv=["-h"], env=empty_env()) catch { + _ => panic() + } + assert_true(custom_h.flags is { "custom_h": true, .. }) + + let empty_options = @argparse.Command( + "demo", + disable_help_flag=true, + disable_version_flag=true, + ) + let empty_options_help = empty_options.render_help() + assert_true(empty_options_help.has_prefix("Usage: demo")) + + let implicit_group = @argparse.Command("demo", positionals=[ + PositionArg("item"), + ]) + let implicit_group_help = implicit_group.render_help() + assert_true(implicit_group_help.has_prefix("Usage: demo [item]")) + + let sub_visible = @argparse.Command("demo", disable_help_subcommand=true, subcommands=[ + Command("run"), + ]) + let sub_help = sub_visible.render_help() + assert_true(sub_help.has_prefix("Usage: demo [command]")) +} + +///| +test "version action dispatches on custom long and short flags" { + let cmd = @argparse.Command("demo", version="2.0.0", flags=[ + FlagArg("show_long", long="show-version", action=Version), + FlagArg("show_short", short='S', action=Version), + ]) + + inspect( + cmd.render_help(), + content=( + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| --show-version + #| -S, --show_short + #| + ), + ) + + inspect( + @test.expect_error(() => cmd.parse(argv=["--oops"], env=empty_env())), + content=( + #|error: unexpected argument '--oops' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| --show-version + #| -S, --show_short + #| + ), + ) +} + +///| +test "global version action keeps parent version text in subcommand context" { + let cmd = @argparse.Command( + "demo", + version="1.0.0", + flags=[ + FlagArg( + "show_version", + short='S', + long="show-version", + action=Version, + global=true, + ), + ], + subcommands=[Command("run")], + ) + + inspect( + @test.expect_error(() => cmd.parse(argv=["--oops"], env=empty_env())), + content=( + #|error: unexpected argument '--oops' found + #| + #|Usage: demo [options] [command] + #| + #|Commands: + #| run + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| -S, --show-version + #| + ), + ) + + inspect( + @test.expect_error(() => cmd.parse(argv=["run", "--oops"], env=empty_env())), + content=( + #|error: unexpected argument '--oops' found + #| + #|Usage: demo run [options] + #| + #|Options: + #| -h, --help Show help information. + #| -S, --show-version + #| + ), + ) +} + +///| +test "subcommand help puts required options in usage" { + let cmd = @argparse.Command("demo", subcommands=[ + Command( + "run", + about="Run a file", + options=[OptionArg("mode", short='m', required=true)], + positionals=[PositionArg("file", num_args=@argparse.ValueRange::single())], + ), + ]) + + inspect( + @test.expect_error(() => cmd.parse(argv=["run", "--oops"], env=empty_env())), + content=( + #|error: unexpected argument '--oops' found + #| + #|Usage: demo run --mode + #| + #|Run a file + #| + #|Arguments: + #| file + #| + #|Options: + #| -h, --help Show help information. + #| -m, --mode + #| + ), + ) +} + +///| +test "required_option_usage covers option/flag/hidden/short-only" { + let cmd = @argparse.Command( + "demo", + flags=[ + FlagArg("verbose", short='v', long="verbose", required=true), + FlagArg("secret", short='s', long="secret", required=true, hidden=true), + ], + options=[ + OptionArg("mode", long="mode", required=true), + OptionArg("tag", short='t', long="", required=true), + OptionArg("optional", long="optional"), + ], + positionals=[ + PositionArg("required_pos", num_args=@argparse.ValueRange::single()), + ], + ) + let help = cmd.render_help() + assert_true( + help.has_prefix( + "Usage: demo --verbose --mode -t [options] ", + ), + ) +} + +///| +test "required_option_usage returns empty when nothing required" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", short='v', long="verbose")], + options=[OptionArg("mode", long="mode")], + ) + let help = cmd.render_help() + assert_true(help.has_prefix("Usage: demo [options]")) +} + +///| +test "positional range 0..1 renders as single optional value" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("x", num_args=ValueRange(lower=0, upper=1)), + ]) + inspect( + cmd.render_help(), + content=( + #|Usage: demo [x] + #| + #|Arguments: + #| x + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) +} + +///| +test "required group usage lists every member shape" { + let cmd = @argparse.Command( + "demo", + groups=[ + ArgGroup("g", required=true, args=["opt", "posm", "poss", "sf", "ef"]), + ], + flags=[FlagArg("sf", short='s', long=""), FlagArg("ef", long="", env="EF")], + options=[OptionArg("opt", long="opt")], + positionals=[ + PositionArg("posm", num_args=ValueRange(lower=0)), + PositionArg("poss", num_args=ValueRange(lower=0, upper=1)), + ], + ) + inspect( + @test.expect_error(() => cmd.parse(argv=[], env=empty_env())), + content=( + #|error: the following required arguments were not provided: + #| <--opt |||-s|ef> + #| + #|Usage: demo [options] [posm...] [poss] + #| + #|Arguments: + #| posm... + #| poss + #| + #|Options: + #| -h, --help Show help information. + #| -s + #| --opt + #| + #|Groups: + #| g [required] -s, ef, --opt , posm, poss + #| + ), + ) +} + +///| +test "help subcommand cannot follow positional arguments" { + let cmd = @argparse.Command("demo", positionals=[PositionArg("input")], subcommands=[ + Command("run"), + ]) + inspect( + @test.expect_error(() => cmd.parse(argv=["raw", "help"], env=empty_env())), + content=( + #|error: subcommand 'help' cannot be used with positional arguments + #| + #|Usage: demo [input] [command] + #| + #|Commands: + #| run + #| help Print help for the subcommand(s). + #| + #|Arguments: + #| input + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) +} + +///| +test "render_help lists positional members inside argument groups" { + let cmd = @argparse.Command( + "demo", + groups=[ArgGroup("inputs", args=["src"])], + positionals=[PositionArg("src")], + ) + inspect( + cmd.render_help(), + content=( + #|Usage: demo [src] + #| + #|Arguments: + #| src + #| + #|Options: + #| -h, --help Show help information. + #| + #|Groups: + #| inputs src + #| + ), + ) +} + +///| +test "render_help tolerates a required env-only option" { + let cmd = @argparse.Command("demo", options=[ + OptionArg("token", long="", env="TOKEN", required=true), + ]) + inspect( + cmd.render_help(), + content=( + #|Usage: demo + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) +} diff --git a/argparse/matches.mbt b/argparse/matches.mbt index c7bfde7680..cb60c7291e 100644 --- a/argparse/matches.mbt +++ b/argparse/matches.mbt @@ -14,6 +14,8 @@ ///| /// Where a value/flag came from. +// TODO(future): `derive(Show)` is deprecated syntax — switch to `derive(Debug)` +// or a manual `Show` implementation, then remove this annotation. #warnings("-deprecated_syntax") pub enum ValueSource { Argv diff --git a/argparse/moon.pkg b/argparse/moon.pkg index 39da120ec1..d7af106874 100644 --- a/argparse/moon.pkg +++ b/argparse/moon.pkg @@ -8,4 +8,8 @@ import { "moonbitlang/core/internal/edit_distance", } +import { + "moonbitlang/core/test", +} for "test" + warnings = "+unnecessary_annotation" diff --git a/argparse/options_test.mbt b/argparse/options_test.mbt new file mode 100644 index 0000000000..4482601389 --- /dev/null +++ b/argparse/options_test.mbt @@ -0,0 +1,922 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +test "long defaults to name when omitted" { + let cmd = @argparse.Command("demo", flags=[FlagArg("verbose")], options=[ + OptionArg("count"), + ]) + let matches = cmd.parse(argv=["--verbose", "--count", "3"], env=empty_env()) catch { + _ => panic() + } + assert_true(matches.flags is { "verbose": true, .. }) + assert_true(matches.values is { "count": ["3"], .. }) +} + +///| +test "negatable flag preserves false state" { + let cmd = @argparse.Command("demo", flags=[ + FlagArg("cache", long="cache", negatable=true), + ]) + + let no_cache = cmd.parse(argv=["--no-cache"], env=empty_env()) catch { + _ => panic() + } + assert_true(no_cache.flags is { "cache": false, .. }) + assert_true(no_cache.flag_counts.get("cache") is None) +} + +///| +test "value source precedence argv env default" { + let cmd = @argparse.Command("demo", options=[ + OptionArg("level", long="level", env="LEVEL", default_values=["1"]), + ]) + + let from_default = cmd.parse(argv=[], env=empty_env()) catch { _ => panic() } + assert_true(from_default.values is { "level": ["1"], .. }) + assert_true(from_default.sources is { "level": Default, .. }) + + let from_env = cmd.parse(argv=[], env={ "LEVEL": "2" }) catch { _ => panic() } + assert_true(from_env.values is { "level": ["2"], .. }) + assert_true(from_env.sources is { "level": Env, .. }) + + let from_argv = cmd.parse(argv=["--level", "3"], env={ "LEVEL": "2" }) catch { + _ => panic() + } + assert_true(from_argv.values is { "level": ["3"], .. }) + assert_true(from_argv.sources is { "level": Argv, .. }) +} + +///| +test "omitted env does not read process environment by default" { + let cmd = @argparse.Command("demo", options=[ + OptionArg("count", long="count", env="COUNT"), + ]) + let matches = cmd.parse(argv=[]) catch { _ => panic() } + assert_true(matches.values is { "count"? : None, .. }) + assert_true(matches.sources is { "count"? : None, .. }) +} + +///| +test "options and multiple values" { + let serve = @argparse.Command("serve") + let cmd = @argparse.Command( + "demo", + options=[ + OptionArg("count", short='c', long="count"), + OptionArg("tag", long="tag", action=Append), + ], + subcommands=[serve], + ) + + let long_count = cmd.parse(argv=["--count", "2"], env=empty_env()) catch { + _ => panic() + } + assert_true(long_count.values is { "count": ["2"], .. }) + + let short_count = cmd.parse(argv=["-c", "3"], env=empty_env()) catch { + _ => panic() + } + assert_true(short_count.values is { "count": ["3"], .. }) + + let multi = cmd.parse(argv=["--tag", "a", "--tag", "b"], env=empty_env()) catch { + _ => panic() + } + assert_true(multi.values is { "tag": ["a", "b"], .. }) + + let subcommand = cmd.parse(argv=["serve"], env=empty_env()) catch { + _ => panic() + } + assert_true(subcommand.subcommand is Some(("serve", _))) +} + +///| +test "flag does not accept inline value" { + let cmd = @argparse.Command("demo", flags=[FlagArg("verbose", long="verbose")]) + inspect( + @test.expect_error(() => cmd.parse(argv=["--verbose=true"], env=empty_env())), + content=( + #|error: unexpected argument '--verbose=true' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --verbose + #| + ), + ) +} + +///| +test "built-in long flags do not accept inline value" { + let cmd = @argparse.Command("demo", version="1.2.3") + + inspect( + @test.expect_error(() => cmd.parse(argv=["--help=1"], env=empty_env())), + content=( + #|error: unexpected argument '--help=1' found + #| + #|Usage: demo + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| + ), + ) + + inspect( + @test.expect_error(() => cmd.parse(argv=["--version=1"], env=empty_env())), + content=( + #|error: unexpected argument '--version=1' found + #| + #|Usage: demo + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| + ), + ) +} + +///| +test "count flags and sources with pattern matching" { + let cmd = @argparse.Command("demo", flags=[ + FlagArg("verbose", short='v', long="verbose", action=Count), + ]) + let matches = cmd.parse(argv=["-v", "-v", "-v"], env=empty_env()) catch { + _ => panic() + } + assert_true(matches.flags is { "verbose": true, .. }) + assert_true(matches.flag_counts is { "verbose": 3, .. }) + assert_true(matches.sources is { "verbose": Argv, .. }) +} + +///| +test "append option action is publicly selectable" { + let cmd = @argparse.Command("demo", options=[ + OptionArg("tag", long="tag", action=Append), + ]) + let appended = cmd.parse(argv=["--tag", "a", "--tag", "b"], env=empty_env()) catch { + _ => panic() + } + assert_true(appended.values is { "tag": ["a", "b"], .. }) + assert_true(appended.sources is { "tag": Argv, .. }) +} + +///| +test "negation parsing and invalid negation forms" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("cache", long="cache", negatable=true)], + options=[OptionArg("path", long="path")], + ) + + let off = cmd.parse(argv=["--no-cache"], env=empty_env()) catch { + _ => panic() + } + assert_true(off.flags is { "cache": false, .. }) + assert_true(off.sources is { "cache": Argv, .. }) + + inspect( + @test.expect_error(() => cmd.parse(argv=["--no-path"], env=empty_env())), + content=( + #|error: unexpected argument '--no-path' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --[no-]cache + #| --path + #| + ), + ) + + inspect( + @test.expect_error(() => cmd.parse(argv=["--no-missing"], env=empty_env())), + content=( + #|error: unexpected argument '--no-missing' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --[no-]cache + #| --path + #| + ), + ) + + inspect( + @test.expect_error(() => cmd.parse(argv=["--no-cache=1"], env=empty_env())), + content=( + #|error: unexpected argument '--no-cache=1' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --[no-]cache + #| --path + #| + ), + ) + + let count_cmd = @argparse.Command("demo", flags=[ + FlagArg("verbose", long="verbose", action=Count, negatable=true), + ]) + let reset = count_cmd.parse( + argv=["--verbose", "--no-verbose"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(reset.flags is { "verbose": false, .. }) + assert_true(reset.flag_counts is { "verbose"? : None, .. }) + assert_true(reset.sources is { "verbose": Argv, .. }) +} + +///| +test "env parsing for settrue setfalse count and invalid values" { + let cmd = @argparse.Command("demo", flags=[ + FlagArg("on", long="on", action=SetTrue, env="ON"), + FlagArg("off", long="off", action=SetFalse, env="OFF"), + FlagArg("v", long="v", action=Count, env="V"), + ]) + + let parsed = cmd.parse(argv=[], env={ "ON": "true", "OFF": "true", "V": "3" }) catch { + _ => panic() + } + assert_true(parsed.flags is { "on": true, "off": false, "v": true, .. }) + assert_true(parsed.flag_counts is { "v": 3, .. }) + assert_true(parsed.sources is { "on": Env, "off": Env, "v": Env, .. }) + + inspect( + @test.expect_error(() => cmd.parse(argv=[], env={ "ON": "bad" })), + content=( + #|error: invalid value 'bad' for boolean flag; expected one of: 1, 0, true, false, yes, no, on, off + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --on [env: ON] + #| --off [env: OFF] + #| --v [env: V] + #| + ), + ) + + inspect( + @test.expect_error(() => cmd.parse(argv=[], env={ "OFF": "bad" })), + content=( + #|error: invalid value 'bad' for boolean flag; expected one of: 1, 0, true, false, yes, no, on, off + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --on [env: ON] + #| --off [env: OFF] + #| --v [env: V] + #| + ), + ) + + inspect( + @test.expect_error(() => cmd.parse(argv=[], env={ "V": "bad" })), + content=( + #|error: invalid value 'bad' for count; expected a non-negative integer + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --on [env: ON] + #| --off [env: OFF] + #| --v [env: V] + #| + ), + ) + + inspect( + @test.expect_error(() => cmd.parse(argv=[], env={ "V": "-1" })), + content=( + #|error: invalid value '-1' for count; expected a non-negative integer + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --on [env: ON] + #| --off [env: OFF] + #| --v [env: V] + #| + ), + ) +} + +///| +test "options consume exactly one value per occurrence" { + let cmd = @argparse.Command("demo", options=[OptionArg("tag", long="tag")]) + let parsed = cmd.parse(argv=["--tag", "a"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "tag": ["a"], .. }) + assert_true(parsed.sources is { "tag": Argv, .. }) + + inspect( + @test.expect_error(() => { + cmd.parse(argv=["--tag", "a", "b"], env=empty_env()) + }), + content=( + #|error: unexpected value 'b' found; no more were expected + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --tag + #| + ), + ) +} + +///| +test "set options reject duplicate occurrences" { + let cmd = @argparse.Command("demo", options=[OptionArg("mode", long="mode")]) + inspect( + @test.expect_error(() => { + cmd.parse(argv=["--mode", "a", "--mode", "b"], env=empty_env()) + }), + content=( + #|error: argument '--mode' cannot be used multiple times + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --mode + #| + ), + ) +} + +///| +test "append options collect values across repeated occurrences" { + let cmd = @argparse.Command("demo", options=[ + OptionArg("arg", long="arg", action=Append), + ]) + let parsed = cmd.parse(argv=["--arg", "x", "--arg", "y"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "arg": ["x", "y"], .. }) + assert_true(parsed.sources is { "arg": Argv, .. }) +} + +///| +test "option parsing stops at the next option token" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose")], + options=[OptionArg("arg", short='a', long="arg")], + ) + + let stopped = cmd.parse(argv=["--arg", "x", "--verbose"], env=empty_env()) catch { + _ => panic() + } + assert_true(stopped.values is { "arg": ["x"], .. }) + assert_true(stopped.flags is { "verbose": true, .. }) + + inspect( + @test.expect_error(() => { + cmd.parse(argv=["--arg=x", "y", "--verbose"], env=empty_env()) + }), + content=( + #|error: unexpected value 'y' found; no more were expected + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --verbose + #| -a, --arg + #| + ), + ) + + inspect( + @test.expect_error(() => { + cmd.parse(argv=["-ax", "y", "--verbose"], env=empty_env()) + }), + content=( + #|error: unexpected value 'y' found; no more were expected + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --verbose + #| -a, --arg + #| + ), + ) +} + +///| +test "options always require a value" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose")], + options=[OptionArg("opt", long="opt")], + ) + inspect( + @test.expect_error(() => { + cmd.parse(argv=["--opt", "--verbose"], env=empty_env()) + }), + content=( + #|error: a value is required for '--opt' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --verbose + #| --opt + #| + ), + ) + + let zero_value_required = @argparse.Command("demo", options=[ + OptionArg("opt", long="opt", required=true), + ]).parse(argv=["--opt", "x"], env=empty_env()) catch { + _ => panic() + } + assert_true(zero_value_required.values is { "opt": ["x"], .. }) +} + +///| +test "default argv path is reachable" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("rest", num_args=ValueRange(lower=0), allow_hyphen_values=true), + ]) + let _ = cmd.parse(env=empty_env()) catch { _ => panic() } +} + +///| +test "options require one value per occurrence" { + let with_value = @argparse.Command("demo", options=[ + OptionArg("tag", long="tag"), + ]).parse(argv=["--tag", "x"], env=empty_env()) catch { + _ => panic() + } + assert_true(with_value.values is { "tag": ["x"], .. }) + + inspect( + @test.expect_error(() => { + @argparse.Command("demo", options=[OptionArg("tag", long="tag")]).parse( + argv=["--tag"], + env=empty_env(), + ) + }), + content=( + #|error: a value is required for '--tag' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --tag + #| + ), + ) +} + +///| +test "short options require one value before next option token" { + let cmd = @argparse.Command("demo", flags=[FlagArg("verbose", short='v')], options=[ + OptionArg("x", short='x'), + ]) + let ok = cmd.parse(argv=["-x", "a", "-v"], env=empty_env()) catch { + _ => panic() + } + assert_true(ok.values is { "x": ["a"], .. }) + assert_true(ok.flags is { "verbose": true, .. }) + + inspect( + @test.expect_error(() => cmd.parse(argv=["-x", "-v"], env=empty_env())), + content=( + #|error: a value is required for '-x' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -v, --verbose + #| -x, --x + #| + ), + ) +} + +///| +test "single-value options avoid consuming additional option values" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose")], + options=[OptionArg("one", long="one")], + ) + + let parsed = cmd.parse(argv=["--one", "x", "--verbose"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "one": ["x"], .. }) + assert_true(parsed.flags is { "verbose": true, .. }) +} + +///| +test "missing option values are reported when next token is another option" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose")], + options=[OptionArg("arg", long="arg")], + ) + + let ok = cmd.parse(argv=["--arg", "x", "--verbose"], env=empty_env()) catch { + _ => panic() + } + assert_true(ok.values is { "arg": ["x"], .. }) + assert_true(ok.flags is { "verbose": true, .. }) + + inspect( + @test.expect_error(() => { + cmd.parse(argv=["--arg", "--verbose"], env=empty_env()) + }), + content=( + #|error: a value is required for '--arg' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --verbose + #| --arg + #| + ), + ) +} + +///| +test "short-only set options use short label in duplicate errors" { + let cmd = @argparse.Command("demo", options=[OptionArg("mode", short='m')]) + inspect( + @test.expect_error(() => { + cmd.parse(argv=["-m", "a", "-m", "b"], env=empty_env()) + }), + content=( + #|error: argument '--mode' cannot be used multiple times + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -m, --mode + #| + ), + ) +} + +///| +test "setfalse flags apply false when present" { + let cmd = @argparse.Command("demo", flags=[ + FlagArg("failfast", long="failfast", action=SetFalse), + ]) + let parsed = cmd.parse(argv=["--failfast"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.flags is { "failfast": false, .. }) + assert_true(parsed.sources is { "failfast": Argv, .. }) +} + +///| +test "non-bmp short option token does not panic" { + let cmd = @argparse.Command("demo", flags=[FlagArg("party", short='🎉')]) + let parsed = cmd.parse(argv=["-🎉"], env=empty_env()) catch { _ => panic() } + assert_true(parsed.flags is { "party": true, .. }) +} + +///| +test "option env values remain string values instead of flags" { + let cmd = @argparse.Command("demo", options=[ + OptionArg("mode", long="mode", env="MODE"), + ]) + let parsed = cmd.parse(argv=[], env={ "MODE": "fast" }) catch { _ => panic() } + assert_true(parsed.values is { "mode": ["fast"], .. }) + assert_true(parsed.flags.get("mode") is None) + assert_true(parsed.sources is { "mode": Env, .. }) +} + +///| +test "duplicate short-only set option reports the short flag label" { + let cmd = @argparse.Command("demo", options=[ + OptionArg("mode", short='m', long=""), + ]) + inspect( + @test.expect_error(() => { + cmd.parse(argv=["-m", "a", "-m", "b"], env=empty_env()) + }), + content=( + #|error: argument '-m' cannot be used multiple times + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -m + #| + ), + ) +} + +///| +test "boolean env flags accept falsy values" { + let cmd = @argparse.Command("demo", flags=[ + FlagArg("on", long="on", action=SetTrue, env="ON"), + FlagArg("off", long="off", action=SetFalse, env="OFF"), + ]) + let parsed = cmd.parse(argv=[], env={ "ON": "0", "OFF": "no" }) catch { + _ => panic() + } + assert_true(parsed.flags is { "on": false, "off": true, .. }) + assert_true(parsed.sources is { "on": Env, "off": Env, .. }) +} + +///| +test "unknown long flag with an empty name has no suggestion" { + let cmd = @argparse.Command("demo", flags=[FlagArg("verbose", long="verbose")]) + inspect( + @test.expect_error(() => cmd.parse(argv=["--=value"], env=empty_env())), + content=( + #|error: unexpected argument '--' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --verbose + #| + ), + ) +} + +///| +test "long empty string disables long alias" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", short='v', long="")], + options=[OptionArg("count", short='c', long="")], + ) + + let matches = cmd.parse(argv=["-v", "-c", "3"], env=empty_env()) catch { + _ => panic() + } + assert_true(matches.flags is { "verbose": true, .. }) + assert_true(matches.values is { "count": ["3"], .. }) + + inspect( + @test.expect_error(() => cmd.parse(argv=["--verbose"], env=empty_env())), + content=( + #|error: unexpected argument '--verbose' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -v + #| -c + #| + ), + ) + + inspect( + @test.expect_error(() => cmd.parse(argv=["--count", "3"], env=empty_env())), + content=( + #|error: unexpected argument '--count' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -v + #| -c + #| + ), + ) +} + +///| +test "long and short value parsing branches" { + let cmd = @argparse.Command("demo", options=[ + OptionArg("count", short='c', long="count"), + ]) + + let long_inline = cmd.parse(argv=["--count=2"], env=empty_env()) catch { + _ => panic() + } + assert_true(long_inline.values is { "count": ["2"], .. }) + + let short_inline = cmd.parse(argv=["-c=3"], env=empty_env()) catch { + _ => panic() + } + assert_true(short_inline.values is { "count": ["3"], .. }) + + let short_attached = cmd.parse(argv=["-c4"], env=empty_env()) catch { + _ => panic() + } + assert_true(short_attached.values is { "count": ["4"], .. }) + + inspect( + @test.expect_error(() => cmd.parse(argv=["--count"], env=empty_env())), + content=( + #|error: a value is required for '--count' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -c, --count + #| + ), + ) + + inspect( + @test.expect_error(() => cmd.parse(argv=["-c"], env=empty_env())), + content=( + #|error: a value is required for '-c' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -c, --count + #| + ), + ) +} + +///| +test "option values reject hyphen tokens unless allow_hyphen_values is enabled" { + let strict = @argparse.Command("demo", options=[ + OptionArg("pattern", long="pattern"), + ]) + inspect( + @test.expect_error(() => { + strict.parse(argv=["--pattern", "-file"], env=empty_env()) + }), + content=( + #|error: a value is required for '--pattern' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --pattern + #| + ), + ) + + let permissive = @argparse.Command("demo", options=[ + OptionArg("pattern", long="pattern", allow_hyphen_values=true), + ]) + let parsed = permissive.parse(argv=["--pattern", "-file"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "pattern": ["-file"], .. }) + assert_true(parsed.sources is { "pattern": Argv, .. }) +} + +///| +test "options with allow_hyphen_values accept option-like single values" { + let cmd = @argparse.Command( + "demo", + flags=[ + FlagArg("verbose", long="verbose"), + FlagArg("cache", long="cache", negatable=true), + FlagArg("quiet", short='q'), + ], + options=[OptionArg("arg", long="arg", allow_hyphen_values=true)], + ) + + let known_long = cmd.parse(argv=["--arg", "--verbose"], env=empty_env()) catch { + _ => panic() + } + assert_true(known_long.values is { "arg": ["--verbose"], .. }) + assert_true(known_long.flags is { "verbose"? : None, .. }) + + let negated = cmd.parse(argv=["--arg", "--no-cache"], env=empty_env()) catch { + _ => panic() + } + assert_true(negated.values is { "arg": ["--no-cache"], .. }) + assert_true(negated.flags is { "cache"? : None, .. }) + + let unknown_long_value = cmd.parse( + argv=["--arg", "--mystery"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(unknown_long_value.values is { "arg": ["--mystery"], .. }) + + let known_short = cmd.parse(argv=["--arg", "-q"], env=empty_env()) catch { + _ => panic() + } + assert_true(known_short.values is { "arg": ["-q"], .. }) + assert_true(known_short.flags is { "quiet"? : None, .. }) + + let cmd_with_rest = @argparse.Command( + "demo", + options=[OptionArg("arg", long="arg", allow_hyphen_values=true)], + positionals=[ + PositionArg( + "rest", + num_args=ValueRange(lower=0), + allow_hyphen_values=true, + ), + ], + ) + let sentinel_stop = cmd_with_rest.parse( + argv=["--arg", "x", "--", "tail"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(sentinel_stop.values is { "arg": ["x"], "rest": ["tail"], .. }) +} + +///| +test "defaults and value range helpers through public API" { + let defaults = @argparse.Command("demo", options=[ + OptionArg("mode", long="mode", action=Append, default_values=["a", "b"]), + OptionArg("one", long="one", default_values=["x"]), + ]) + let by_default = defaults.parse(argv=[], env=empty_env()) catch { + _ => panic() + } + assert_true(by_default.values is { "mode": ["a", "b"], "one": ["x"], .. }) + assert_true(by_default.sources is { "mode": Default, "one": Default, .. }) + + let upper_only = @argparse.Command("demo", options=[ + OptionArg("tag", long="tag", action=Append), + ]) + let upper_parsed = upper_only.parse( + argv=["--tag", "a", "--tag", "b", "--tag", "c"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(upper_parsed.values is { "tag": ["a", "b", "c"], .. }) + + let lower_only = @argparse.Command("demo", options=[ + OptionArg("tag", long="tag"), + ]) + let lower_absent = lower_only.parse(argv=[], env=empty_env()) catch { + _ => panic() + } + assert_true(lower_absent.values is { "tag"? : None, .. }) + + inspect( + @test.expect_error(() => lower_only.parse(argv=["--tag"], env=empty_env())), + content=( + #|error: a value is required for '--tag' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --tag + #| + ), + ) + + let single_range = @argparse.ValueRange::single() + inspect( + single_range, + content=( + #|{lower: 1, upper: Some(1)} + ), + ) +} diff --git a/argparse/parser.mbt b/argparse/parser.mbt index 1fe56f74e3..f906c38209 100644 --- a/argparse/parser.mbt +++ b/argparse/parser.mbt @@ -54,7 +54,7 @@ fn help_context_command( command_path : String, ) -> Command { let help_name = if command_path == "" { cmd.name } else { command_path } - { ..cmd, args: inherited_globals + cmd.args, name: help_name } + { ..cmd, args: inherited_globals + cmd.args, name: help_name, } } ///| @@ -548,7 +548,7 @@ fn parse_command_impl( Some(v) => v None => if default_subcommand is Some(sub) { - let remaining = Array::new(capacity=argv.length() - i) + let remaining = Array(capacity=argv.length() - i) remaining.push("-\{short}\{String::from_iter(chars)}") for rest in argv[i + 1:] { remaining.push(rest) diff --git a/argparse/parser_lookup.mbt b/argparse/parser_lookup.mbt index 4c101cffda..ba0278266a 100644 --- a/argparse/parser_lookup.mbt +++ b/argparse/parser_lookup.mbt @@ -105,19 +105,9 @@ fn resolve_help_target( ///| fn split_long(arg : String) -> (StringView, String?) { - let parts = [ for part in arg.split("=") => part ] - if parts.length() <= 1 { - let name = match parts[0].strip_prefix("--") { - Some(view) => view - None => parts[0] - } - (name, None) - } else { - let name = match parts[0].strip_prefix("--") { - Some(view) => view - None => parts[0] - } - let value = parts[1:].join("=") - (name, Some(value)) + let body = arg.strip_prefix("--").unwrap_or(arg) + match body.split_once("=") { + Some((name, value)) => (name, Some(value.to_owned())) + None => (body, None) } } diff --git a/argparse/parser_positionals.mbt b/argparse/parser_positionals.mbt index 6309eb2a4b..372d8c45c4 100644 --- a/argparse/parser_positionals.mbt +++ b/argparse/parser_positionals.mbt @@ -71,9 +71,8 @@ fn should_parse_as_positional( if !arg.has_prefix("-") || arg == "-" { return false } - let next = match next_positional(positionals, collected) { - Some(v) => v - None => return false + guard next_positional(positionals, collected) is Some(next) else { + return false } let next_allow = match next.info { FlagInfo(_) => false @@ -88,26 +87,16 @@ fn should_parse_as_positional( let (name, _) = split_long(arg) return long_index.get_from_string(name) is None } - let short = arg.get_char(1) - match short { - Some(ch) => short_index.get(ch) is None - None => true + match arg { + ['-', second, ..] => short_index.get(second) is None + _ => true } } ///| fn is_negative_number(arg : String) -> Bool { - if arg.length() < 2 { - return false - } - guard arg is ['-', .. rest] else { return false } - for ch in rest { - if ch is ('0'..='9') { - continue - } else { - break false - } - } nobreak { - true + match arg { + ['-', '0'..='9', .. rest] => rest.all(c => c is ('0'..='9')) + _ => false } } diff --git a/argparse/parser_values.mbt b/argparse/parser_values.mbt index b7e1919ab4..98c4e9ca4c 100644 --- a/argparse/parser_values.mbt +++ b/argparse/parser_values.mbt @@ -206,14 +206,8 @@ fn apply_env( if matches_has_value_or_flag(matches, name) { continue } - let env_name = match arg.env { - Some(value) => value - None => continue - } - let value = match env.get(env_name) { - Some(v) => v - None => continue - } + guard arg.env is Some(env_name) else { continue } + guard env.get(env_name) is Some(value) else { continue } if arg.info is (OptionInfo(_) | PositionalInfo(_)) { assign_value(matches, arg, value, Env) continue diff --git a/argparse/positionals_test.mbt b/argparse/positionals_test.mbt new file mode 100644 index 0000000000..17d4240b9b --- /dev/null +++ b/argparse/positionals_test.mbt @@ -0,0 +1,379 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +test "declaration order controls positional parsing" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("first"), + PositionArg("second"), + ]) + + let parsed = cmd.parse(argv=["a", "b"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "first": ["a"], "second": ["b"], .. }) +} + +///| +test "bounded non-last positional remains supported" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("first", num_args=ValueRange(lower=1, upper=2)), + PositionArg("second", num_args=@argparse.ValueRange::single()), + ]) + + let two = cmd.parse(argv=["a", "b"], env=empty_env()) catch { _ => panic() } + assert_true(two.values is { "first": ["a"], "second": ["b"], .. }) + + let three = cmd.parse(argv=["a", "b", "c"], env=empty_env()) catch { + _ => panic() + } + assert_true(three.values is { "first": ["a", "b"], "second": ["c"], .. }) +} + +///| +test "positionals dash handling and separator" { + let force_cmd = @argparse.Command("demo", positionals=[ + PositionArg("tail", num_args=ValueRange(lower=0), allow_hyphen_values=true), + ]) + let forced = force_cmd.parse(argv=["a", "--x", "-y"], env=empty_env()) catch { + _ => panic() + } + assert_true(forced.values is { "tail": ["a", "--x", "-y"], .. }) + + let dashed = force_cmd.parse(argv=["--", "p", "q"], env=empty_env()) catch { + _ => panic() + } + assert_true(dashed.values is { "tail": ["p", "q"], .. }) + + let negative_cmd = @argparse.Command("demo", positionals=[PositionArg("n")]) + let negative = negative_cmd.parse(argv=["-9"], env=empty_env()) catch { + _ => panic() + } + assert_true(negative.values is { "n": ["-9"], .. }) + + inspect( + @test.expect_error(() => { + negative_cmd.parse(argv=["x", "y"], env=empty_env()) + }), + content=( + #|error: unexpected value 'y' for '' found; no more were expected + #| + #|Usage: demo [n] + #| + #|Arguments: + #| n + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) +} + +///| +test "variadic positional keeps accepting hyphen values after first token" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("tail", num_args=ValueRange(lower=0), allow_hyphen_values=true), + ]) + let parsed = cmd.parse(argv=["a", "-b", "--mystery"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "tail": ["a", "-b", "--mystery"], .. }) +} + +///| +test "allow_hyphen positional yields to a known short flag" { + let cmd = @argparse.Command("demo", flags=[FlagArg("quiet", short='q')], positionals=[ + PositionArg("input", allow_hyphen_values=true), + ]) + let flagged = cmd.parse(argv=["-q"], env=empty_env()) catch { _ => panic() } + assert_true(flagged.flags is { "quiet": true, .. }) + assert_true(flagged.values is { "input"? : None, .. }) + + let unknown = cmd.parse(argv=["-z"], env=empty_env()) catch { _ => panic() } + assert_true(unknown.values is { "input": ["-z"], .. }) + assert_true(unknown.flags is { "quiet"? : None, .. }) +} + +///| +test "bounded positional does not greedily consume later required values" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("first", num_args=ValueRange(lower=1, upper=2)), + PositionArg("second", num_args=@argparse.ValueRange::single()), + ]) + + let two = cmd.parse(argv=["a", "b"], env=empty_env()) catch { _ => panic() } + assert_true(two.values is { "first": ["a"], "second": ["b"], .. }) + + let three = cmd.parse(argv=["a", "b", "c"], env=empty_env()) catch { + _ => panic() + } + assert_true(three.values is { "first": ["a", "b"], "second": ["c"], .. }) +} + +///| +test "indexed non-last positional allows explicit single num_args" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("first", num_args=@argparse.ValueRange::single()), + PositionArg("second", num_args=@argparse.ValueRange::single()), + ]) + + let parsed = cmd.parse(argv=["a", "b"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "first": ["a"], "second": ["b"], .. }) +} + +///| +test "bounded positional can leave later optional positional empty" { + let parsed = @argparse.Command("demo", positionals=[ + PositionArg("x", num_args=ValueRange(lower=0, upper=2)), + PositionArg("y"), + ]).parse(argv=["a"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "x": ["a"], "y"? : None, .. }) +} + +///| +test "positionals keep declaration order with ranged positional" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("late", num_args=ValueRange(lower=2, upper=2)), + PositionArg("first"), + PositionArg("mid"), + ]) + + let parsed = cmd.parse(argv=["a", "b", "c", "d"], env=empty_env()) catch { + _ => panic() + } + assert_true( + parsed.values is { "late": ["a", "b"], "first": ["c"], "mid": ["d"], .. }, + ) +} + +///| +test "mixed indexed and unindexed positionals keep inferred order" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("first"), + PositionArg("second"), + ]) + + let parsed = cmd.parse(argv=["a", "b"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "first": ["a"], "second": ["b"], .. }) +} + +///| +test "single positional parses without explicit index metadata" { + let parsed = @argparse.Command("demo", positionals=[PositionArg("late")]).parse( + argv=["x"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(parsed.values is { "late": ["x"], .. }) +} + +///| +test "positional num_args lower bound rejects missing argv values" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("first", num_args=ValueRange(lower=2, upper=3)), + ]) + + inspect( + @test.expect_error(() => cmd.parse(argv=[], env=empty_env())), + content=( + #|error: 'first' requires at least 2 values but only 0 were provided + #| + #|Usage: demo + #| + #|Arguments: + #| first... + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) +} + +///| +test "positional max clamp leaves trailing value for next positional" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("items", num_args=ValueRange(lower=0, upper=2)), + PositionArg("tail"), + ]) + + let parsed = cmd.parse(argv=["a", "b", "c"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "items": ["a", "b"], "tail": ["c"], .. }) +} + +///| +test "allow_hyphen positional treats unknown long token as value" { + let cmd = @argparse.Command("demo", flags=[FlagArg("known", long="known")], positionals=[ + PositionArg("input", allow_hyphen_values=true), + ]) + let parsed = cmd.parse(argv=["--mystery"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "input": ["--mystery"], .. }) +} + +///| +test "non-bmp hyphen token reports unknown argument without panic" { + let cmd = @argparse.Command("demo", positionals=[PositionArg("value")]) + inspect( + @test.expect_error(() => cmd.parse(argv=["-🎉"], env=empty_env())), + content=( + #|error: unexpected argument '-🎉' found + #| + #|Usage: demo [value] + #| + #|Arguments: + #| value + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) +} + +///| +test "positional default values exceeding num_args upper bound are rejected" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("tags", num_args=ValueRange(lower=0, upper=2), default_values=[ + "a", "b", "c", + ]), + ]) + inspect( + @test.expect_error(() => cmd.parse(argv=[], env=empty_env())), + content=( + #|error: 'tags' allows at most 2 values but 3 were provided + #| + #|Usage: demo [tags...] + #| + #|Arguments: + #| tags... [default: a, b, c] + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) +} + +///| +test "earlier variadic positional reserves values for a later required one" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("head", num_args=ValueRange(lower=0)), + PositionArg("tail", num_args=ValueRange(lower=2)), + ]) + inspect( + @test.expect_error(() => cmd.parse(argv=["only"], env=empty_env())), + content=( + #|error: 'tail' requires at least 2 values but only 1 were provided + #| + #|Usage: demo [head...] + #| + #|Arguments: + #| head... + #| tail... + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) +} + +///| +test "extra values past a bounded variadic positional report the variadic label" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("items", num_args=ValueRange(lower=0, upper=2)), + ]) + let ok = cmd.parse(argv=["a", "b"], env=empty_env()) catch { _ => panic() } + assert_true(ok.values is { "items": ["a", "b"], .. }) + inspect( + @test.expect_error(() => cmd.parse(argv=["a", "b", "c"], env=empty_env())), + content=( + #|error: unexpected value 'c' for '' found; no more were expected + #| + #|Usage: demo [items...] + #| + #|Arguments: + #| items... + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) +} + +///| +test "bounded variadic positional stops accepting hyphen tokens once full" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg( + "items", + num_args=ValueRange(lower=0, upper=2), + allow_hyphen_values=true, + ), + ]) + inspect( + @test.expect_error(() => cmd.parse(argv=["a", "b", "-c"], env=empty_env())), + content=( + #|error: unexpected argument '-c' found + #| + #|Usage: demo [items...] + #| + #|Arguments: + #| items... + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) +} + +///| +// A leading variadic positional must reserve enough trailing tokens for a later +// positional with a minimum count, so a hyphen token is not mistaken for one of +// its values when the trailing slot still needs filling. +test "hyphen token is rejected while a later positional still needs values" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("head", num_args=ValueRange(lower=0), allow_hyphen_values=true), + PositionArg("tail", num_args=ValueRange(lower=2)), + ]) + inspect( + @test.expect_error(() => cmd.parse(argv=["-x"], env=empty_env())), + content=( + #|error: unexpected argument '-x' found + #| + #|Usage: demo [head...] + #| + #|Arguments: + #| head... + #| tail... + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) +} diff --git a/argparse/rejected_config_test.mbt b/argparse/rejected_config_test.mbt new file mode 100644 index 0000000000..c6ddbcd7d4 --- /dev/null +++ b/argparse/rejected_config_test.mbt @@ -0,0 +1,603 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +/// Rejected command definitions, not argv parse failures. +/// +/// Flags/options without an argv spelling or env source are dead config: they +/// can appear in relationships and help, but users can never set them. +test "flag and option config requires an argv or env spelling" { + inspect( + @test.expect_error(() => { + @argparse.Command("demo", options=[OptionArg("input", long="")]).parse( + argv=[], + env=empty_env(), + ) + }), + content=( + #|error: command definition validation failed: flag/option args require short/long/env + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command("demo", flags=[FlagArg("verbose", long="")]).parse( + argv=[], + env=empty_env(), + ) + }), + content=( + #|error: command definition validation failed: flag/option args require short/long/env + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command("demo", flags=[FlagArg("f", long="", action=Help)]).parse( + argv=[], + env=empty_env(), + ) + }), + content=( + #|error: command definition validation failed: flag/option args require short/long/env + ), + ) +} + +///| +/// Default dispatch must have one visible target and must not conflict with +/// root-level policies that also claim bare argv or leading tokens. +test "default subcommand config rejects ambiguous root dispatch" { + inspect( + @test.expect_error(() => { + @argparse.Command( + "demo", + subcommands=[Command("run")], + default_subcommand="missing", + ).parse(argv=[], env=empty_env()) + }), + content=( + #|error: command definition validation failed: default_subcommand must name a visible subcommand: missing + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command( + "demo", + subcommands=[Command("secret", hidden=true)], + default_subcommand="secret", + ).parse(argv=[], env=empty_env()) + }), + content=( + #|error: command definition validation failed: default_subcommand must name a visible subcommand: secret + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command( + "demo", + subcommands=[Command("run")], + subcommand_required=true, + default_subcommand="run", + ).parse(argv=[], env=empty_env()) + }), + content=( + #|error: command definition validation failed: default_subcommand cannot be used with subcommand_required + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command( + "demo", + subcommands=[Command("run")], + arg_required_else_help=true, + default_subcommand="run", + ).parse(argv=[], env=empty_env()) + }), + content=( + #|error: command definition validation failed: default_subcommand cannot be used with arg_required_else_help + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command( + "demo", + options=[OptionArg("mode", long="mode")], + subcommands=[Command("run")], + default_subcommand="run", + ).parse(argv=[], env=empty_env()) + }), + content=( + #|error: command definition validation failed: default_subcommand only supports global root flags/options + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose", global=true)], + groups=[ArgGroup("verbosity", args=["verbose"])], + subcommands=[Command("run")], + default_subcommand="run", + ).parse(argv=[], env=empty_env()) + }), + content=( + #|error: command definition validation failed: default_subcommand does not support root groups + ), + ) +} + +///| +/// Value ranges must describe a non-empty, non-negative interval. Otherwise +/// positional allocation and usage output would encode impossible counts. +test "value range config rejects impossible counts" { + inspect( + @test.expect_error(() => { + @argparse.Command("demo", positionals=[ + PositionArg("skip", num_args=ValueRange(lower=0, upper=0)), + PositionArg("name", num_args=@argparse.ValueRange::single()), + ]).parse(argv=["alice"], env=empty_env()) + }), + content=( + #|error: command definition validation failed: empty value range (0..0) is unsupported + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command("demo", positionals=[ + PositionArg("x", num_args=ValueRange(lower=3, upper=2)), + ]).parse(argv=[], env=empty_env()) + }), + content=( + #|error: command definition validation failed: max values must be >= min values + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command("demo", positionals=[ + PositionArg("x", num_args=ValueRange(lower=-1, upper=2)), + ]).parse(argv=[], env=empty_env()) + }), + content=( + #|error: command definition validation failed: min values must be >= 0 + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command("demo", positionals=[ + PositionArg("x", num_args=ValueRange(lower=0, upper=-1)), + ]).parse(argv=[], env=empty_env()) + }), + content=( + #|error: command definition validation failed: max values must be >= 0 + ), + ) +} + +///| +/// Terminal actions and defaults need unambiguous triggers and output: no +/// negated help/version, no env-triggered terminal action, no multi-default Set. +test "action config rejects unsupported implicit behavior" { + inspect( + @test.expect_error(() => { + @argparse.Command("demo", flags=[ + FlagArg("f", long="f", action=Help, negatable=true), + ]).parse(argv=[], env=empty_env()) + }), + content=( + #|error: command definition validation failed: help/version actions do not support negatable + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command("demo", flags=[ + FlagArg("f", long="f", action=Help, env="F"), + ]).parse(argv=[], env=empty_env()) + }), + content=( + #|error: command definition validation failed: help/version actions do not support env/defaults + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command("demo", options=[ + OptionArg("x", long="x", default_values=["a", "b"]), + ]).parse(argv=[], env=empty_env()) + }), + content=( + #|error: command definition validation failed: default_values with multiple entries require action=Append + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command("demo", flags=[FlagArg("v", long="v", action=Version)]).parse( + argv=[], + env=empty_env(), + ) + }), + content=( + #|error: command definition validation failed: version action requires command version text + ), + ) +} + +///| +/// Groups form a relationship graph. Duplicate names, self-edges, and missing +/// targets make that graph ambiguous, unsatisfiable, or impossible to report. +test "group config rejects circular or unknown relationships" { + inspect( + @test.expect_error(() => { + @argparse.Command("demo", groups=[ArgGroup("g"), ArgGroup("g")]).parse( + argv=[], + env=empty_env(), + ) + }), + content=( + #|error: command definition validation failed: duplicate group: g + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command("demo", groups=[ArgGroup("g", requires=["g"])]).parse( + argv=[], + env=empty_env(), + ) + }), + content=( + #|error: command definition validation failed: group cannot require itself: g + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command("demo", groups=[ArgGroup("g", conflicts_with=["g"])]).parse( + argv=[], + env=empty_env(), + ) + }), + content=( + #|error: command definition validation failed: group cannot conflict with itself: g + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command("demo", groups=[ArgGroup("g", args=["missing"])]).parse( + argv=[], + env=empty_env(), + ) + }), + content=( + #|error: command definition validation failed: unknown group arg: g -> missing + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command("demo", groups=[ArgGroup("g", requires=["missing"])]).parse( + argv=[], + env=empty_env(), + ) + }), + content=( + #|error: command definition validation failed: unknown group requires target: g -> missing + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command("demo", groups=[ + ArgGroup("g", conflicts_with=["missing"]), + ]).parse(argv=[], env=empty_env()) + }), + content=( + #|error: command definition validation failed: unknown group conflicts_with target: g -> missing + ), + ) +} + +///| +/// Argument names and option aliases are parser keys. Duplicates make result +/// slots or argv tokens ambiguous; negatable flags also reserve `--no-`. +test "argument config rejects duplicate names and aliases" { + inspect( + @test.expect_error(() => { + @argparse.Command("demo", options=[ + OptionArg("x", long="x"), + OptionArg("x", long="y"), + ]).parse(argv=[], env=empty_env()) + }), + content=( + #|error: command definition validation failed: duplicate arg name: x + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command("demo", options=[ + OptionArg("x", long="same"), + OptionArg("y", long="same"), + ]).parse(argv=[], env=empty_env()) + }), + content=( + #|error: command definition validation failed: duplicate long option: --same + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command("demo", flags=[ + FlagArg("hello", long="hello", negatable=true), + FlagArg("x", long="no-hello"), + ]).parse(argv=[], env=empty_env()) + }), + content=( + #|error: command definition validation failed: duplicate long option: --no-hello + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command("demo", options=[ + OptionArg("x", short='s'), + OptionArg("y", short='s'), + ]).parse(argv=[], env=empty_env()) + }), + content=( + #|error: command definition validation failed: duplicate short option: -s + ), + ) +} + +///| +/// Argument relationships also form a graph over known args/groups. Self edges +/// and missing nodes create unsatisfiable or unreportable requirements. +test "argument relationship config rejects circular or unknown targets" { + inspect( + @test.expect_error(() => { + @argparse.Command("demo", flags=[FlagArg("x", long="x", requires=["x"])]).parse( + argv=[], + env=empty_env(), + ) + }), + content=( + #|error: command definition validation failed: arg cannot require itself: x + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command("demo", flags=[ + FlagArg("x", long="x", conflicts_with=["x"]), + ]).parse(argv=[], env=empty_env()) + }), + content=( + #|error: command definition validation failed: arg cannot conflict with itself: x + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command("demo", options=[ + OptionArg("mode", long="mode", requires=["missing"]), + ]).parse(argv=["--mode", "fast"], env=empty_env()) + }), + content=( + #|error: command definition validation failed: unknown requires target: mode -> missing + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command("demo", flags=[ + FlagArg("fast", long="fast", requires=["missing"]), + ]).parse(argv=[], env=empty_env()) + }), + content=( + #|error: command definition validation failed: unknown requires target: fast -> missing + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command("demo", options=[ + OptionArg("mode", long="mode", conflicts_with=["missing"]), + ]).parse(argv=["--mode", "fast"], env=empty_env()) + }), + content=( + #|error: command definition validation failed: unknown conflicts_with target: mode -> missing + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command("demo", subcommands=[ + Command("child", flags=[FlagArg("x", long="x", requires=["missing"])]), + ]).parse(argv=[], env=empty_env()) + }), + content=( + #|error: command definition validation failed: unknown requires target: x -> missing + ), + ) +} + +///| +/// Subcommand names are dispatch keys, and built-in help owns its route. +/// Required-subcommand config must also provide at least one possible target. +test "subcommand config rejects ambiguous command surfaces" { + inspect( + @test.expect_error(() => { + @argparse.Command("demo", subcommands=[Command("x"), Command("x")]).parse( + argv=[], + env=empty_env(), + ) + }), + content=( + #|error: command definition validation failed: duplicate subcommand: x + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command("demo", subcommand_required=true).parse( + argv=[], + env=empty_env(), + ) + }), + content=( + #|error: command definition validation failed: subcommand_required requires at least one subcommand + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command("demo", subcommands=[Command("help")]).parse( + argv=[], + env=empty_env(), + ) + }), + content=( + #|error: command definition validation failed: subcommand name reserved for built-in help: help (disable with disable_help_subcommand) + ), + ) +} + +///| +/// Inherited globals share one Matches key across parent and child scopes. +/// Shadowing, alias reuse, or incompatible redeclarations would make merging +/// and dispatch depend on where the token appears. +test "global config rejects shadowing and incompatible redeclarations" { + inspect( + @test.expect_error(() => { + @argparse.Command( + "demo", + options=[ + OptionArg( + "mode", + long="mode", + env="MODE", + default_values=["safe"], + global=true, + ), + ], + subcommands=[Command("run", options=[OptionArg("mode", long="mode")])], + ).parse(argv=["run"], env=empty_env()) + }), + content=( + #|error: command definition validation failed: arg 'mode' shadows an inherited global; rename the arg or mark it global + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command( + "demo", + options=[OptionArg("mode", long="mode", required=true, global=true)], + subcommands=[ + Command("run", flags=[FlagArg("mode", long="mode", global=true)]), + ], + ).parse(argv=["run", "--mode"], env=empty_env()) + }), + content=( + #|error: command definition validation failed: global arg 'mode' is incompatible with inherited global definition + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose", global=true)], + subcommands=[ + Command("run", options=[OptionArg("local", long="verbose")]), + ], + ).parse(argv=["run", "--verbose"], env=empty_env()) + }), + content=( + #|error: command definition validation failed: arg 'local' long option --verbose conflicts with inherited global 'verbose' + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command( + "demo", + flags=[FlagArg("verbose", short='v', global=true)], + subcommands=[Command("run", options=[OptionArg("local", short='v')])], + ).parse(argv=["run", "-v"], env=empty_env()) + }), + content=( + #|error: command definition validation failed: arg 'local' short option -v conflicts with inherited global 'verbose' + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose", negatable=true, global=true)], + subcommands=[ + Command("run", flags=[ + FlagArg("verbose", long="verbose", negatable=false, global=true), + ]), + ], + ).parse(argv=["run"], env=empty_env()) + }), + content=( + #|error: command definition validation failed: global arg 'verbose' is incompatible with inherited global definition + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command( + "demo", + flags=[FlagArg("shared", long="shared", global=true)], + subcommands=[ + Command("child", options=[ + OptionArg("shared", long="shared", global=true), + ]), + ], + ).parse(argv=[], env=empty_env()) + }), + content=( + #|error: command definition validation failed: global arg 'shared' is incompatible with inherited global definition + ), + ) + + inspect( + @test.expect_error(() => { + @argparse.Command( + "demo", + options=[OptionArg("opt", long="dup", global=true)], + subcommands=[Command("child", flags=[FlagArg("other", long="dup")])], + ).parse(argv=[], env=empty_env()) + }), + content=( + #|error: command definition validation failed: arg 'other' long option --dup conflicts with inherited global 'opt' + ), + ) +} diff --git a/argparse/relationships_test.mbt b/argparse/relationships_test.mbt new file mode 100644 index 0000000000..cc196006d8 --- /dev/null +++ b/argparse/relationships_test.mbt @@ -0,0 +1,342 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +test "relationships and num args" { + let requires_cmd = @argparse.Command("demo", options=[ + OptionArg("mode", long="mode", requires=["config"]), + OptionArg("config", long="config"), + ]) + + inspect( + @test.expect_error(() => { + requires_cmd.parse(argv=["--mode", "fast"], env=empty_env()) + }), + content=( + #|error: the following required argument was not provided: 'config' (required by 'mode') + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --mode + #| --config + #| + ), + ) + + let appended = @argparse.Command("demo", options=[ + OptionArg("tag", long="tag", action=Append), + ]).parse(argv=["--tag", "a", "--tag", "b", "--tag", "c"], env=empty_env()) catch { + _ => panic() + } + assert_true(appended.values is { "tag": ["a", "b", "c"], .. }) +} + +///| +test "arg groups required and multiple" { + let cmd = @argparse.Command( + "demo", + groups=[ + ArgGroup("mode", required=true, multiple=false, args=["fast", "slow"]), + ], + flags=[FlagArg("fast", long="fast"), FlagArg("slow", long="slow")], + ) + + inspect( + @test.expect_error(() => cmd.parse(argv=[], env=empty_env())), + content=( + #|error: the following required arguments were not provided: + #| <--fast|--slow> + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --fast + #| --slow + #| + #|Groups: + #| mode [required] [exclusive] --fast, --slow + #| + ), + ) + + inspect( + @test.expect_error(() => { + cmd.parse(argv=["--fast", "--slow"], env=empty_env()) + }), + content=( + #|error: group conflict mode + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --fast + #| --slow + #| + #|Groups: + #| mode [required] [exclusive] --fast, --slow + #| + ), + ) +} + +///| +test "arg groups requires and conflicts" { + let requires_cmd = @argparse.Command( + "demo", + groups=[ + ArgGroup("mode", args=["fast"], requires=["output"]), + ArgGroup("output", args=["json"]), + ], + flags=[FlagArg("fast", long="fast"), FlagArg("json", long="json")], + ) + + inspect( + @test.expect_error(() => { + requires_cmd.parse(argv=["--fast"], env=empty_env()) + }), + content=( + #|error: the following required arguments were not provided: + #| <--json> + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --fast + #| --json + #| + #|Groups: + #| mode --fast + #| output --json + #| + ), + ) + + let conflict_cmd = @argparse.Command( + "demo", + groups=[ + ArgGroup("mode", args=["fast"], conflicts_with=["output"]), + ArgGroup("output", args=["json"]), + ], + flags=[FlagArg("fast", long="fast"), FlagArg("json", long="json")], + ) + + inspect( + @test.expect_error(() => { + conflict_cmd.parse(argv=["--fast", "--json"], env=empty_env()) + }), + content=( + #|error: group conflict mode conflicts with output + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --fast + #| --json + #| + #|Groups: + #| mode --fast + #| output --json + #| + ), + ) +} + +///| +test "negatable and conflicts" { + let cmd = @argparse.Command("demo", flags=[ + FlagArg("cache", long="cache", negatable=true), + FlagArg("failfast", long="failfast", action=SetFalse, negatable=true), + FlagArg("verbose", long="verbose", conflicts_with=["quiet"]), + FlagArg("quiet", long="quiet"), + ]) + + let no_cache = cmd.parse(argv=["--no-cache"], env=empty_env()) catch { + _ => panic() + } + assert_true(no_cache.flags is { "cache": false, .. }) + assert_true(no_cache.sources is { "cache": Argv, .. }) + + let no_failfast = cmd.parse(argv=["--no-failfast"], env=empty_env()) catch { + _ => panic() + } + assert_true(no_failfast.flags is { "failfast": true, .. }) + + inspect( + @test.expect_error(() => { + cmd.parse(argv=["--verbose", "--quiet"], env=empty_env()) + }), + content=( + #|error: conflicting arguments: verbose and quiet + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --[no-]cache + #| --[no-]failfast + #| --verbose + #| --quiet + #| + ), + ) +} + +///| +test "group requires/conflicts can target argument names" { + let requires_cmd = @argparse.Command( + "demo", + groups=[ArgGroup("mode", args=["fast"], requires=["config"])], + flags=[FlagArg("fast", long="fast")], + options=[OptionArg("config", long="config")], + ) + + let ok = requires_cmd.parse( + argv=["--fast", "--config", "cfg.toml"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(ok.flags is { "fast": true, .. }) + assert_true(ok.values is { "config": ["cfg.toml"], .. }) + + inspect( + @test.expect_error(() => { + requires_cmd.parse(argv=["--fast"], env=empty_env()) + }), + content=( + #|error: the following required argument was not provided: 'config' + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --fast + #| --config + #| + #|Groups: + #| mode --fast + #| + ), + ) + + let conflicts_cmd = @argparse.Command( + "demo", + groups=[ArgGroup("mode", args=["fast"], conflicts_with=["config"])], + flags=[FlagArg("fast", long="fast")], + options=[OptionArg("config", long="config")], + ) + + inspect( + @test.expect_error(() => { + conflicts_cmd.parse( + argv=["--fast", "--config", "cfg.toml"], + env=empty_env(), + ) + }), + content=( + #|error: group conflict mode conflicts with config + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --fast + #| --config + #| + #|Groups: + #| mode --fast + #| + ), + ) +} + +///| +test "group without members has no parse effect" { + let cmd = @argparse.Command("demo", groups=[ArgGroup("known")], flags=[ + FlagArg("x", long="x"), + ]) + let parsed = cmd.parse(argv=["--x"], env=empty_env()) catch { _ => panic() } + assert_true(parsed.flags is { "x": true, .. }) + let help = cmd.render_help() + assert_true(help.has_prefix("Usage: demo [options]")) +} + +///| +test "empty groups without presence do not fail" { + let grouped_ok = @argparse.Command( + "demo", + groups=[ArgGroup("left", args=["l"]), ArgGroup("right", args=["r"])], + flags=[FlagArg("l", long="left"), FlagArg("r", long="right")], + ) + let parsed = grouped_ok.parse(argv=["--left"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.flags is { "l": true, .. }) +} + +///| +test "required and env-fed ranged values validate after parsing" { + let required_cmd = @argparse.Command("demo", options=[ + OptionArg("input", long="input", required=true), + ]) + inspect( + @test.expect_error(() => required_cmd.parse(argv=[], env=empty_env())), + content=( + #|error: the following required argument was not provided: 'input' + #| + #|Usage: demo --input + #| + #|Options: + #| -h, --help Show help information. + #| --input + #| + ), + ) + + let env_min_cmd = @argparse.Command("demo", options=[ + OptionArg("pair", long="pair", env="PAIR"), + ]) + let env_value = env_min_cmd.parse(argv=[], env={ "PAIR": "one" }) catch { + _ => panic() + } + assert_true(env_value.values is { "pair": ["one"], .. }) + assert_true(env_value.sources is { "pair": Env, .. }) +} + +///| +test "required group with only hidden members reports a plain message" { + let cmd = @argparse.Command( + "demo", + groups=[ArgGroup("mode", required=true, args=["fast"])], + flags=[FlagArg("fast", long="fast", hidden=true)], + ) + inspect( + @test.expect_error(() => cmd.parse(argv=[], env=empty_env())), + content=( + #|error: the following required argument group was not provided: 'mode' + #| + #|Usage: demo + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) +} diff --git a/argparse/subcommands_test.mbt b/argparse/subcommands_test.mbt new file mode 100644 index 0000000000..df451ee3af --- /dev/null +++ b/argparse/subcommands_test.mbt @@ -0,0 +1,254 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +test "subcommand parsing" { + let echo = @argparse.Command("echo", positionals=[PositionArg("msg")]) + let root = @argparse.Command("root", subcommands=[echo]) + + let matches = root.parse(argv=["echo", "hi"], env=empty_env()) catch { + _ => panic() + } + assert_true( + matches.subcommand is Some(("echo", sub)) && + sub.values is { "msg": ["hi"], .. }, + ) +} + +///| +test "default subcommand dispatches through normal child parsing" { + let tui = @argparse.Command( + "tui", + about="Start interactive UI", + flags=[FlagArg("trace", short='t')], + options=[OptionArg("theme", long="theme")], + positionals=[PositionArg("workspace")], + ) + let mcp = @argparse.Command("mcp", about="Run MCP server", options=[ + OptionArg("port", long="port"), + ]) + let cmd = @argparse.Command( + "openseek", + options=[OptionArg("config", long="config", global=true)], + flags=[FlagArg("verbose", short='v', action=Count, global=true)], + subcommands=[tui, mcp], + default_subcommand="tui", + ) + + let bare = cmd.parse(argv=[], env=empty_env()) catch { _ => panic() } + assert_true(bare.subcommand is Some(("tui", _))) + + let defaulted = cmd.parse( + argv=["--config", "config.toml", "--theme", "dark", "workspace"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(defaulted.values is { "config": ["config.toml"], .. }) + assert_true( + defaulted.subcommand is Some(("tui", sub)) && + sub.values + is { + "config": ["config.toml"], + "theme": ["dark"], + "workspace": ["workspace"], + .. + }, + ) + + let short_global = cmd.parse(argv=["-v", "--theme", "dark"], env=empty_env()) catch { + _ => panic() + } + assert_true(short_global.flag_counts is { "verbose": 1, .. }) + assert_true( + short_global.subcommand is Some(("tui", sub)) && + sub.values is { "theme": ["dark"], .. } && + sub.flag_counts is { "verbose": 1, .. }, + ) + + let mixed_short = cmd.parse(argv=["-vt"], env=empty_env()) catch { + _ => panic() + } + assert_true(mixed_short.flag_counts is { "verbose": 1, .. }) + assert_true( + mixed_short.subcommand is Some(("tui", sub)) && + sub.flags is { "trace": true, .. } && + sub.flag_counts is { "verbose": 1, .. }, + ) + + let explicit = cmd.parse(argv=["mcp", "--port", "9000"], env=empty_env()) catch { + _ => panic() + } + assert_true( + explicit.subcommand is Some(("mcp", sub)) && + sub.values is { "port": ["9000"], .. }, + ) +} + +///| +test "default subcommand gives exact subcommands precedence over positionals" { + let cmd = @argparse.Command( + "openseek", + subcommands=[ + Command("tui", positionals=[PositionArg("workspace")]), + Command("mcp"), + ], + default_subcommand="tui", + ) + + let explicit = cmd.parse(argv=["mcp"], env=empty_env()) catch { _ => panic() } + assert_true(explicit.subcommand is Some(("mcp", _))) + + let positional = cmd.parse(argv=["project"], env=empty_env()) catch { + _ => panic() + } + assert_true( + positional.subcommand is Some(("tui", sub)) && + sub.values is { "workspace": ["project"], .. }, + ) + + let explicit_default = cmd.parse(argv=["tui", "mcp"], env=empty_env()) catch { + _ => panic() + } + assert_true( + explicit_default.subcommand is Some(("tui", sub)) && + sub.values is { "workspace": ["mcp"], .. }, + ) + + let after_dash_dash = cmd.parse(argv=["--", "mcp"], env=empty_env()) catch { + _ => panic() + } + assert_true( + after_dash_dash.subcommand is Some(("tui", sub)) && + sub.values is { "workspace": ["mcp"], .. }, + ) +} + +///| +test "subcommand cannot follow positional arguments" { + let cmd = @argparse.Command("demo", positionals=[PositionArg("input")], subcommands=[ + Command("run"), + ]) + inspect( + @test.expect_error(() => cmd.parse(argv=["raw", "run"], env=empty_env())), + content=( + #|error: subcommand 'run' cannot be used with positional arguments + #| + #|Usage: demo [input] [command] + #| + #|Commands: + #| run + #| help Print help for the subcommand(s). + #| + #|Arguments: + #| input + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) +} + +///| +test "subcommand suggestions are only reported for parse failures" { + let cmd = @argparse.Command( + "demo", + positionals=[PositionArg("input", about="input file")], + subcommands=[Command("serve", about="serve")], + ) + let positional = cmd.parse(argv=["serv"], env=empty_env()) catch { + _ => panic() + } + assert_true(positional.values is { "input": ["serv"], .. }) + assert_true(positional.subcommand is None) + + inspect( + @test.expect_error(() => { + cmd.parse(argv=["input.txt", "serv"], env=empty_env()) + }), + content=( + #|error: unexpected value 'serv' for '' found; no more were expected + #| + #| tip: a similar subcommand exists: 'serve' + #| + #|Usage: demo [input] [command] + #| + #|Commands: + #| serve serve + #| help Print help for the subcommand(s). + #| + #|Arguments: + #| input input file + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + + let no_positionals = @argparse.Command("demo", subcommands=[ + Command("serve", about="serve"), + ]) + inspect( + @test.expect_error(() => no_positionals.parse(argv=["hel"], env=empty_env())), + content=( + #|error: unexpected value 'hel' found; no more were expected + #| + #| tip: a similar subcommand exists: 'help' + #| + #|Usage: demo [command] + #| + #|Commands: + #| serve serve + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + + inspect( + @test.expect_error(() => cmd.parse(argv=["help", "serv"], env=empty_env())), + content=( + #|error: unknown subcommand: serv + #| + #| tip: a similar subcommand exists: 'serve' + #| + #|Usage: demo [input] [command] + #| + #|Commands: + #| serve serve + #| help Print help for the subcommand(s). + #| + #|Arguments: + #| input input file + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) +} + +///| +test "subcommand lookup falls back to positional value" { + let cmd = @argparse.Command("demo", positionals=[PositionArg("input")], subcommands=[ + Command("run"), + ]) + let parsed = cmd.parse(argv=["raw"], env=empty_env()) catch { _ => panic() } + assert_true(parsed.values is { "input": ["raw"], .. }) + assert_true(parsed.subcommand is None) +} diff --git a/argparse/value_range.mbt b/argparse/value_range.mbt index 5ff376bf81..a90b21e171 100644 --- a/argparse/value_range.mbt +++ b/argparse/value_range.mbt @@ -14,6 +14,8 @@ ///| /// Number-of-values constraint for an argument. +// TODO(future): `derive(Show)` is deprecated syntax — switch to `derive(Debug)` +// or a manual `Show` implementation, then remove `-deprecated_syntax` here. #warnings("-deprecated_syntax-deprecated") pub struct ValueRange { priv lower : Int @@ -30,7 +32,7 @@ pub struct ValueRange { /// - `ValueRange(lower=1, upper=3)` means `1..=3`. #alias(new, deprecated="Use `ValueRange()` instead") pub fn ValueRange::ValueRange(lower? : Int = 0, upper? : Int) -> ValueRange { - { lower, upper } + { lower, upper, } } ///| diff --git a/array/quickcheck_test.mbt b/array/quickcheck_test.mbt index 44858a2141..a67b921830 100644 --- a/array/quickcheck_test.mbt +++ b/array/quickcheck_test.mbt @@ -290,11 +290,11 @@ fn stable_sort_spec(keys : Array[Int]) -> Bool { // Stable-sorted output is unique, so sorting the same data through a view // with a non-zero start offset must reproduce `arr` exactly and leave the // sentinels on either side untouched. - let padded : Array[Tagged] = [{ key: 0, idx: -1 }] + let padded : Array[Tagged] = [{ key: 0, idx: -1, }] for i in 0.. Bench { let buffer = StringBuilder() let summaries = [] - { buffer, summaries, _storage: @ref.new(()) } + { buffer, summaries, _storage: @ref.new(()), } } diff --git a/bigint/arith_wide.mbt b/bigint/arith_wide.mbt new file mode 100644 index 0000000000..7caf8d134b --- /dev/null +++ b/bigint/arith_wide.mbt @@ -0,0 +1,619 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Word-level and vector-level arithmetic primitives. +// +// Every routine here operates on full 64-bit words (limbs) with base B = 2^64, +// so a "carry" is a whole word rather than the high half of a 32-bit product. +// +// The enabling primitive is `%u64.mul_wide`, which the native backend lowers to +// a single `unsigned __int128` multiply. Without it, a 64x64->128 product would +// cost four 32-bit multiplies plus the reassembly, which is exactly what the +// 32-bit-limb representation already pays. + +///| +/// Result of a 64x64 -> 128 unsigned multiplication. +/// +/// Must be `#valtype`: native writes the two halves into separate C locals, +/// while wasm1 can carry the pair as an unboxed value type. wasm-gc does not +/// compile this file because it cannot represent that result efficiently. +#valtype +priv struct UMul { + lo : UInt64 + hi : UInt64 +} + +///| +/// `hi * 2^64 + lo == a * b` +/// +/// The body is the portable 32x32 schoolbook fallback; on the native backend +/// `#intrinsic` replaces it with the backend's wide multiply when available. +/// Native uses `moonbit_umul_wide`; wasm1 currently executes this fallback. +#intrinsic("%u64.mul_wide") +fn umul_wide(a : UInt64, b : UInt64) -> UMul { + let mask = 0xffffffffUL + let alo = a & mask + let ahi = a >> 32 + let blo = b & mask + let bhi = b >> 32 + let ll = alo * blo + let lh = alo * bhi + let hl = ahi * blo + let mid = (ll >> 32) + (lh & mask) + (hl & mask) + let hi = ahi * bhi + (lh >> 32) + (hl >> 32) + (mid >> 32) + { lo: a * b, hi, } +} + +///| +/// A quotient/remainder pair from a 128-by-64 division. +#valtype +priv struct DivMod { + q : UInt64 + r : UInt64 +} + +// Word primitives + +///| +/// `hi * 2^64 + lo == x * y + c`. Cannot overflow: the maximum is +/// (2^64-1)^2 + (2^64-1) = 2^128 - 2^64. +fn mul_add_www(x : UInt64, y : UInt64, c : UInt64) -> UMul { + let m = umul_wide(x, y) + let lo = m.lo + c + { lo, hi: m.hi + (lo < m.lo).to_uint64(), } +} + +///| +/// Number of leading zero bits, as an `Int`. +fn nlz(x : UInt64) -> Int { + x.clz() +} + +///| +/// `q, r = (u1 * 2^64 + u0) / v`, requiring `u1 < v` and `v != 0`. +/// +/// Hacker's Delight `divlu`: a 128/64 division synthesized from four 64/64 +/// hardware divisions on 32-bit half-words. This is the slow path, used only to +/// build the reciprocal below (once per big division), never in an inner loop. +fn div_ww_slow(u1 : UInt64, u0 : UInt64, v : UInt64) -> DivMod { + let b = 1UL << 32 + let s = nlz(v) + let v = v << s + let vn1 = v >> 32 + let vn0 = v & 0xffffffffUL + // `u1 << 64 - s` is undefined for s == 0, so special-case it. + let un32 = if s == 0 { u1 } else { (u1 << s) | (u0 >> (64 - s)) } + let un10 = u0 << s + let un1 = un10 >> 32 + let un0 = un10 & 0xffffffffUL + let mut q1 = un32 / vn1 + let mut rhat = un32 - q1 * vn1 + while q1 >= b || q1 * vn0 > b * rhat + un1 { + q1 -= 1 + rhat += vn1 + if rhat >= b { + break + } + } + let un21 = un32 * b + un1 - q1 * v + let mut q0 = un21 / vn1 + rhat = un21 - q0 * vn1 + while q0 >= b || q0 * vn0 > b * rhat + un0 { + q0 -= 1 + rhat += vn1 + if rhat >= b { + break + } + } + { q: (q1 << 32) | q0, r: (un21 * b + un0 - q0 * v) >> s, } +} + +///| +/// `floor((2^128 - 1) / d) - 2^64` for a normalized `d` (top bit set). +/// +/// This is the Möller-Granlund 2/1 reciprocal. Computed once per division, so +/// the slow software 128/64 path is fine here. +fn reciprocal_word(d : UInt64) -> UInt64 { + div_ww_slow(d.lnot(), 0xffff_ffff_ffff_ffffUL, d).q +} + +///| +/// `q, r = (u1 * 2^64 + u0) / d` using a precomputed reciprocal `v`. +/// +/// Requires `d` normalized (top bit set) and `u1 < d`. Möller-Granlund +/// "Improved division by invariant integers", Algorithm 4. Two wide multiplies +/// and a couple of conditional fixups replace a hardware 128/64 divide. +fn div2by1(u1 : UInt64, u0 : UInt64, d : UInt64, v : UInt64) -> DivMod { + let m = umul_wide(v, u1) + // (q1, q0) = m + (u1, u0) + let q0 = m.lo + u0 + let q1 = m.hi + u1 + (q0 < m.lo).to_uint64() + let q1 = q1 + 1 + let mut r = u0 - q1 * d + let mut q1 = q1 + if r > q0 { + q1 -= 1 + r += d + } + if r >= d { + q1 += 1 + r -= d + } + { q: q1, r, } +} + +///| +/// True when `(x1, x2) > (y1, y2)` as 128-bit values. +fn greater_than(x1 : UInt64, x2 : UInt64, y1 : UInt64, y2 : UInt64) -> Bool { + x1 > y1 || (x1 == y1 && x2 > y2) +} + +// Vector primitives +// +// Each takes explicit offsets and a length so callers can operate on slices of +// a shared buffer without allocating views. + +///| +/// `z[zi..zi+n] = x[xi..xi+n] + y[yi..yi+n]`, returns the carry out (0 or 1). +fn add_vv( + z : FixedArray[UInt64], + zi : Int, + x : FixedArray[UInt64], + xi : Int, + y : FixedArray[UInt64], + yi : Int, + n : Int, +) -> UInt64 { + for i in 0.. UInt64 { + for i in 0.. UInt64 { + for i in 0.. UInt64 { + for i in 0.. UInt64 { + for i in 0.. UInt64 { + for i in 0.. Unit { + z.unsafe_set(zi + an, mul_add_vww(z, zi, x, xi, y.unsafe_get(yi), 0, an)) + for j in 1.. Int { + let mut n = n + let mut i = 0 + while n > threshold { + n = n >> 1 + i += 1 + } + n << i +} + +///| +/// `z[zi..zi+n) += x[xi..xi+n)`, with the carry absorbed by the following +/// `n/2` words. +fn karatsuba_add( + z : FixedArray[UInt64], + zi : Int, + x : FixedArray[UInt64], + xi : Int, + n : Int, +) -> Unit { + let c = add_vv(z, zi, z, zi, x, xi, n) + if c != 0 { + ignore(add_vw(z, zi + n, z, zi + n, c, n >> 1)) + } +} + +///| +/// `z[zi..zi+n) -= x[xi..xi+n)`, with the borrow absorbed by the following +/// `n/2` words. +fn karatsuba_sub( + z : FixedArray[UInt64], + zi : Int, + x : FixedArray[UInt64], + xi : Int, + n : Int, +) -> Unit { + let c = sub_vv(z, zi, z, zi, x, xi, n) + if c != 0 { + ignore(sub_vw(z, zi + n, z, zi + n, c, n >> 1)) + } +} + +///| +/// `z[zi..zi+2n) = x[xi..xi+n) * y[yi..yi+n)`. +/// +/// `z` must have `6*n` words available from `zi`; the low `2n` receive the +/// product and the rest is scratch. Callers get `n` from `karatsuba_len`, which +/// guarantees the halving stays exact. +/// +/// This identity needs one subtraction product rather than the textbook +/// `(xh+xl)*(yh+yl)`, so no intermediate can carry past `n` words: +/// +/// xd = x1 - x0, yd = y0 - y1 +/// x*y = z2*B^2 + (xd*yd + z2 + z0)*B + z0 with z0 = x0*y0, z2 = x1*y1 +fn karatsuba( + z : FixedArray[UInt64], + zi : Int, + x : FixedArray[UInt64], + xi : Int, + y : FixedArray[UInt64], + yi : Int, + n : Int, +) -> Unit { + if n % 2 != 0 || n < KARATSUBA_THRESHOLD || n < 2 { + basic_mul(z, zi, x, xi, n, y, yi, n) + return + } + let n2 = n >> 1 + // z = [ .. | .. | xd*yd | yd:xd | x1*y1 | x0*y0 ] (0, n, 2n, 3n, 4n, 6n) + karatsuba(z, zi, x, xi, y, yi, n2) // z0 = x0*y0 + karatsuba(z, zi + n, x, xi + n2, y, yi + n2, n2) // z2 = x1*y1 + + // |x1-x0| and |y0-y1|, carrying the sign of their product in `s`. + let mut s = 1 + let xd = zi + 2 * n + if sub_vv(z, xd, x, xi + n2, x, xi, n2) != 0 { + s = -s + ignore(sub_vv(z, xd, x, xi, x, xi + n2, n2)) + } + let yd = xd + n2 + if sub_vv(z, yd, y, yi, y, yi + n2, n2) != 0 { + s = -s + ignore(sub_vv(z, yd, y, yi + n2, y, yi, n2)) + } + let p = zi + 3 * n + karatsuba(z, p, z, xd, z, yd, n2) + + // Stash z2:z0 above p's result; the recursion is done, so z[4n..6n) is free. + let r = zi + 4 * n + z.unsafe_blit(r, z, zi, 2 * n) + karatsuba_add(z, zi + n2, z, r, n) // + z0 << n2 + karatsuba_add(z, zi + n2, z, r + n, n) // + z2 << n2 + if s > 0 { + karatsuba_add(z, zi + n2, z, p, n) + } else { + karatsuba_sub(z, zi + n2, z, p, n) + } +} + +///| +/// `z[i..zn) += x[0..xn)`. +fn add_at( + z : FixedArray[UInt64], + zn : Int, + x : FixedArray[UInt64], + xn : Int, + i : Int, +) -> Unit { + if xn == 0 { + return + } + let c = add_vv(z, i, z, i, x, 0, xn) + if c != 0 { + let j = i + xn + if j < zn { + ignore(add_vw(z, j, z, j, c, zn - j)) + } + } +} + +///| +/// `q[0..n] = x[0..n] / d`, returns `x[0..n] % d`. +/// +/// `s` must be `nlz(d)`, `dn` must be `d << s`, and `rec` +/// `reciprocal_word(dn)`; hoisting them out lets a caller amortize the +/// reciprocal across repeated divisions by the same `d` (decimal printing does +/// exactly this). +/// +/// `q` may alias `x`: step `i` reads `x[i]` and `x[i-1]` before writing `q[i]`, +/// and later steps only look further down. +fn div_w( + q : FixedArray[UInt64], + x : FixedArray[UInt64], + n : Int, + dn : UInt64, + rec : UInt64, + s : Int, +) -> UInt64 { + let mut r = 0UL + if s == 0 { + for i = n - 1; i >= 0; i = i - 1 { + let dm = div2by1(r, x.unsafe_get(i), dn, rec) + q.unsafe_set(i, dm.q) + r = dm.r + } + return r + } + // Divide `x << s` by `d << s` without materializing the shifted dividend: + // limb i of the shifted value is `(x[i] << s) | (x[i-1] >> (64-s))`, and the + // bits shifted off the top become the initial remainder. + let t = 64 - s + r = x.unsafe_get(n - 1) >> t + for i = n - 1; i >= 0; i = i - 1 { + let cur = x.unsafe_get(i) + let lower = if i > 0 { x.unsafe_get(i - 1) } else { 0UL } + let dm = div2by1(r, (cur << s) | (lower >> t), dn, rec) + q.unsafe_set(i, dm.q) + r = dm.r + } + r >> s +} + +// Montgomery arithmetic + +///| +/// `-m^-1 mod 2^64`, the Montgomery constant. Requires `m` odd. +/// +/// Newton-Raphson on the 2-adic inverse (Dumas, "On Newton-Raphson Iteration +/// for Multiplicative Inverses Modulo Prime Powers"): each round doubles the +/// number of correct low bits, so six rounds cover 64. +fn mont_k0(m0 : UInt64) -> UInt64 { + let mut k0 = 2UL - m0 + let mut t = m0 - 1 + let mut i = 1 + while i < 64 { + t = t * t + k0 = k0 * (t + 1) + i = i << 1 + } + 0UL - k0 +} + +///| +/// `out[0..n) = x * y * R^-1 mod m` where `R = 2^(64n)` and `k = -m^-1 mod 2^64`. +/// +/// `x`, `y` and `m` are each exactly `n` limbs; `t` is `2n` words of scratch. +/// Interleaved multiply-and-reduce (CIOS) keeps the intermediate within `2n` +/// words and requires no division. +/// +/// `out` must not alias `x`, `y` or `m`. +fn montgomery( + out : FixedArray[UInt64], + x : FixedArray[UInt64], + y : FixedArray[UInt64], + m : FixedArray[UInt64], + k : UInt64, + n : Int, + t : FixedArray[UInt64], +) -> Unit { + // Only the low half needs clearing; t[n..2n) is written as the loop advances. + for i in 0.. UInt64 { + if n == 0 { + return 0 + } + let t = 64 - s + let c = x.unsafe_get(n - 1) >> t + for i = n - 1; i > 0; i = i - 1 { + z.unsafe_set(i, (x.unsafe_get(i) << s) | (x.unsafe_get(i - 1) >> t)) + } + z.unsafe_set(0, x.unsafe_get(0) << s) + c +} + +///| +/// `z[0..n] = x[0..n] >> s` for `0 < s < 64`, returns the bits shifted out +/// (in the high end of the word). +fn shr_vu( + z : FixedArray[UInt64], + x : FixedArray[UInt64], + s : Int, + n : Int, +) -> UInt64 { + if n == 0 { + return 0 + } + let t = 64 - s + let c = x.unsafe_get(0) << t + for i in 0..<(n - 1) { + z.unsafe_set(i, (x.unsafe_get(i) >> s) | (x.unsafe_get(i + 1) << t)) + } + z.unsafe_set(n - 1, x.unsafe_get(n - 1) >> s) + c +} diff --git a/bigint/bigint_nonjs.mbt b/bigint/bigint_default.mbt similarity index 91% rename from bigint/bigint_nonjs.mbt rename to bigint/bigint_default.mbt index 54dcfd206c..1bb1b0cd57 100644 --- a/bigint/bigint_nonjs.mbt +++ b/bigint/bigint_default.mbt @@ -12,8 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. +// The default implementation retains 32-bit limbs. In particular, wasm-gc +// cannot currently represent wide arithmetic's multi-value results without a +// performance regression. + ///| -/// A big integer represented as an array of Int. +/// A big integer represented as an array of UInt limbs. // // Design explained: // - Why use an FixedArray of Int with a len field instead of an Array[Int]? @@ -89,7 +93,7 @@ const RADIX : UInt64 = 1UL << RADIX_BIT_LEN // TODO: This can be generalized onc const RADIX_MASK : UInt64 = RADIX - 1 ///| -/// The ratio of the number of decimal digits to the number of radix digits. +/// The ratio of the number of decimal digits to the number of bits. const DECIMAL_RATIO : Double = 0.302 // log10(2) ///| @@ -111,7 +115,7 @@ let one : BigInt = 1N /// /// Parameters: /// -/// * `value` : The 32-bit signed integer (`Int`) to be converted. +/// * `n` : The 32-bit signed integer (`Int`) to be converted. /// /// Returns a `BigInt` equivalent to the input integer. /// @@ -134,7 +138,7 @@ pub fn BigInt::from_int(n : Int) -> BigInt { /// /// Parameters: /// -/// * `value` : The unsigned 32-bit integer to be converted. +/// * `n` : The unsigned 32-bit integer to be converted. /// /// Returns a `BigInt` representing the same numerical value as the input. /// @@ -155,7 +159,7 @@ pub fn BigInt::from_uint(n : UInt) -> BigInt { /// /// Parameters: /// -/// * `number` : A 64-bit signed integer (`Int64`) to be converted. +/// * `n` : A 64-bit signed integer (`Int64`) to be converted. /// /// Returns a `BigInt` value that represents the same numerical value as the /// input. @@ -183,7 +187,7 @@ pub fn BigInt::from_int64(n : Int64) -> BigInt { /// /// Parameters: /// -/// * `value` : The unsigned 64-bit integer (`UInt64`) to be converted. +/// * `n` : The unsigned 64-bit integer (`UInt64`) to be converted. /// /// Returns a new `BigInt` with the same value as the input. The resulting /// `BigInt` will always have a positive sign since the input is an unsigned @@ -201,7 +205,7 @@ pub fn BigInt::from_int64(n : Int64) -> BigInt { /// ``` pub fn BigInt::from_uint64(n : UInt64) -> BigInt { if n == 0UL { - return { limbs: FixedArray::make(1, 0), sign: Positive, len: 1 } + return { limbs: FixedArray::make(1, 0), sign: Positive, len: 1, } } let limbs = FixedArray::make(64 / RADIX_BIT_LEN, 0U) let i = for m = n, i = 0; m > 0; { @@ -210,7 +214,7 @@ pub fn BigInt::from_uint64(n : UInt64) -> BigInt { } nobreak { i } - { limbs, sign: Positive, len: i } + { limbs, sign: Positive, len: i, } } // Arithmetic Operations @@ -239,7 +243,7 @@ pub impl Neg for BigInt with fn neg(self : BigInt) -> BigInt { if self.is_zero() { return zero } - { ..self, sign: if self.sign == Positive { Negative } else { Positive } } + { ..self, sign: if self.sign == Positive { Negative } else { Positive }, } } ///| @@ -286,7 +290,7 @@ pub impl Add for BigInt with fn add(self : BigInt, other : BigInt) -> BigInt { } nobreak { i } - { limbs, sign: Positive, len: i } + { limbs, sign: Positive, len: i, } } ///| @@ -351,7 +355,7 @@ pub impl Sub for BigInt with fn sub(self : BigInt, other : BigInt) -> BigInt { } nobreak { i } - { limbs, sign: Positive, len: i } + { limbs, sign: Positive, len: i, } } ///| @@ -397,7 +401,7 @@ pub impl Mul for BigInt with fn mul(self : BigInt, other : BigInt) -> BigInt { } else { self.karatsuba_mul(other) } - { ..ret, sign: if self.sign == other.sign { Positive } else { Negative } } + { ..ret, sign: if self.sign == other.sign { Positive } else { Negative }, } } ///| @@ -428,7 +432,7 @@ fn BigInt::mul_single_limb(self : BigInt, x : UInt) -> BigInt { limbs[n] = carry.to_uint() n + 1 } - { limbs, sign: Positive, len } + { limbs, sign: Positive, len, } } // Simplest way to multiply two BigInts. @@ -458,7 +462,7 @@ fn BigInt::grade_school_mul(self : BigInt, other : BigInt) -> BigInt { if limbs[self_len + other_len - 1] == 0 { len -= 1 } - { limbs, sign: Positive, len } + { limbs, sign: Positive, len, } } // Karatsuba multiplication @@ -481,7 +485,7 @@ fn BigInt::karatsuba_mul(self : BigInt, other : BigInt) -> BigInt { ///| fn BigInt::split(self : BigInt, half : Int) -> (BigInt, BigInt) { if self.len <= half { - return ({ ..self, sign: Positive }, zero) + return ({ ..self, sign: Positive, }, zero) } let lower_len = for i in half>..1 { if self.limbs[i] > 0 { @@ -495,8 +499,8 @@ fn BigInt::split(self : BigInt, half : Int) -> (BigInt, BigInt) { let upper = FixedArray::make(self.len - half, 0U) upper.unsafe_blit(0, self.limbs, half, self.len - half) ( - { limbs: lower, sign: Positive, len: lower_len }, - { limbs: upper, sign: Positive, len: self.len - half }, + { limbs: lower, sign: Positive, len: lower_len, }, + { limbs: upper, sign: Positive, len: self.len - half, }, ) } @@ -554,7 +558,7 @@ pub impl Div for BigInt with fn div(self : BigInt, other : BigInt) -> BigInt { /// /// Returns the remainder of the division operation. /// -/// Throws an error if `other` is zero. +/// Throws a panic if `other` is zero. /// /// Example: /// @@ -615,13 +619,13 @@ fn BigInt::grade_school_div(self : BigInt, other : BigInt) -> (BigInt, BigInt) { } if ret.limbs[ret.len - 1] == 0 { return ( - { ..ret, len: ret.len - 1 }, - { limbs: FixedArray::make(1, y.to_uint()), sign: Positive, len: 1 }, + { ..ret, len: ret.len - 1, }, + { limbs: FixedArray::make(1, y.to_uint()), sign: Positive, len: 1, }, ) } return ( ret, - { limbs: FixedArray::make(1, y.to_uint()), sign: Positive, len: 1 }, + { limbs: FixedArray::make(1, y.to_uint()), sign: Positive, len: 1, }, ) } @@ -721,8 +725,8 @@ fn BigInt::grade_school_div(self : BigInt, other : BigInt) -> (BigInt, BigInt) { for j in 0..> lshift) + let modulo = { limbs: modulo, sign: Positive, len: i, } + ({ limbs: q, sign: Positive, len, }, modulo >> lshift) } // Bitwise Operations @@ -779,7 +783,7 @@ pub impl Shl for BigInt with fn shl(self : BigInt, n : Int) -> BigInt { } else { new_limbs.unsafe_blit(lz, self.limbs, 0, self.len) } - { limbs: new_limbs, sign: self.sign, len } + { limbs: new_limbs, sign: self.sign, len, } } else { zero } @@ -821,14 +825,14 @@ pub impl Shr for BigInt with fn shr(self : BigInt, n : Int) -> BigInt { match self.sign { Positive => return zero Negative => - return { limbs: FixedArray::make(1, 1), sign: Negative, len: 1 } + return { limbs: FixedArray::make(1, 1), sign: Negative, len: 1, } } } let mut new_len = self.len - lz if r == 0 { let new_limbs = FixedArray::make(new_len, 0U) new_limbs.unsafe_blit(0, self.limbs, lz, new_len) - let result = { limbs: new_limbs, sign: self.sign, len: new_len } + let result = { limbs: new_limbs, sign: self.sign, len: new_len, } if self.sign == Negative { for i in 0.. BigInt { } } if has_remainder { - return { limbs: new_limbs, sign: self.sign, len: new_len } - 1 + return { limbs: new_limbs, sign: self.sign, len: new_len, } - 1 } } - { limbs: new_limbs, sign: self.sign, len: new_len } + { limbs: new_limbs, sign: self.sign, len: new_len, } } } @@ -1105,29 +1109,61 @@ fn BigInt::to_string_radix(self : BigInt, radix : Int) -> String { match pow2_shift(radix) { Some(shift) => self.to_string_radix_pow2(shift) None => { - let is_negative = self.sign == Negative - let base = BigInt::from_int(radix) - let value = if is_negative { -self } else { self } - let digits = [] - for v = value { - if v > zero { - let (q, r) = BigInt::grade_school_div(v, base) - digits.push(char_from_digit(r.to_int())) - continue q - } else { - break + // Same limb-by-limb conversion as the radix-10 path in to_string: + // convert to base chunk = radix^chunk_len using only Int64 division, + // then emit chunk_len digits per slot. This replaces one full BigInt + // division per output digit with one Int64 division per digit. + let radix64 = radix.to_int64() + // Largest radix^chunk_len such that (slot << RADIX_BIT_LEN) | limb + // still fits in Int64 (slots stay below 2^(63 - RADIX_BIT_LEN)). + let chunk_limit = 0x7FFF_FFFF_FFFF_FFFFL >> RADIX_BIT_LEN + let mut chunk = radix64 + let mut chunk_len = 1 + while chunk <= chunk_limit / radix64 { + chunk = chunk * radix64 + chunk_len += 1 + } + // Digits in radix >= 3 never exceed the bit count, so this bounds the + // number of slots. + let slots = self.len * RADIX_BIT_LEN / chunk_len + 2 + let v = Array::make(slots, 0L) + let mut v_idx = 0 + for i in self.len>..0 { + let mut x = self.limbs[i].to_int64() + for j in 0.. 0L { + v[v_idx] = x % chunk + v_idx += 1 + x /= chunk } } - let builder = StringBuilder( - size_hint=digits.length() + (if is_negative { 1 } else { 0 }), - ) - if is_negative { - builder.write_char('-') + let cap = v_idx * chunk_len + 1 // +1 for an optional sign + let chars = FixedArray::make(cap, '0') + let mut pos = cap + // Lower slots each contribute exactly chunk_len digits, zero-padded. + for i in 0..<(v_idx - 1) { + let mut x = v[i] + for _ in 0.. 0L; x = x / radix64 { + pos -= 1 + chars[pos] = char_from_digit((x % radix64).to_int()) } - for i in digits.length()>..0 { - builder.write_char(digits[i]) + if self.sign == Negative { + pos -= 1 + chars[pos] = '-' } - builder.to_string() + String::from_array(chars[pos:]) } } } @@ -1252,7 +1288,7 @@ fn BigInt::from_string_radix_pow2( } let b_len = normalize_len(limbs, b_len) let sign = if b_len == 1 && limbs[0] == 0 { Positive } else { sign } - { limbs, sign, len: b_len } + { limbs, sign, len: b_len, } } ///| @@ -1320,14 +1356,14 @@ fn BigInt::from_string_dec(input : StringView) -> BigInt raise { b_len -= 1 } let sign = if b_len == 1 && b[0] == 0 { Positive } else { sign } - { limbs: b, sign, len: b_len } + { limbs: b, sign, len: b_len, } } ///| fn BigInt::copy(self : BigInt) -> BigInt { let new_limbs = FixedArray::make(self.len, 0U) new_limbs.unsafe_blit(0, self.limbs, 0, self.len) - { limbs: new_limbs, sign: self.sign, len: self.len } + { limbs: new_limbs, sign: self.sign, len: self.len, } } ///| @@ -1427,16 +1463,17 @@ pub fn BigInt::pow(self : BigInt, exp : BigInt, modulus? : BigInt) -> BigInt { /// /// Parameters: /// -/// * `bytes` : A sequence of bytes representing the magnitude of the number in -/// big-endian order. The sequence must not be empty unless `sign` is 0. -/// * `sign` : An integer specifying the sign of the resulting number (default: -/// 1). A value of 1 creates a positive number, -1 creates a negative number, and -/// 0 returns zero regardless of the input bytes. +/// * `input` : A sequence of bytes representing the magnitude of the number in +/// big-endian order. An empty sequence represents a zero magnitude. +/// * `signum` : An integer specifying the sign of the resulting number +/// (default: 1). A positive value creates a positive number, a negative value +/// creates a negative number, and 0 returns zero regardless of the input bytes. /// /// Returns a `BigInt` value representing the number encoded in the byte sequence /// with the specified sign. /// -/// Throws a panic if the input byte sequence is empty and the sign is not 0. +/// An empty byte sequence yields zero for any `signum`, matching the behavior +/// of Java's `BigInteger`, Python's `int.from_bytes`, and Rust's `num-bigint`. /// /// Example: /// @@ -1447,6 +1484,7 @@ pub fn BigInt::pow(self : BigInt, exp : BigInt, modulus? : BigInt) -> BigInt { /// let negative = @bigint.BigInt::from_octets(bytes, signum=-1) /// inspect(positive, content="66051") /// inspect(negative, content="-66051") +/// inspect(@bigint.BigInt::from_octets(b""), content="0") /// } /// ``` pub fn BigInt::from_octets(input : BytesView, signum? : Int = 1) -> BigInt { @@ -1457,7 +1495,7 @@ pub fn BigInt::from_octets(input : BytesView, signum? : Int = 1) -> BigInt { return -BigInt::from_octets(input) } if len == 0 { - abort("empty octet string") + return zero } let div = len * 8 / RADIX_BIT_LEN let mod = len * 8 % RADIX_BIT_LEN // number of bits in the first limb @@ -1468,14 +1506,15 @@ pub fn BigInt::from_octets(input : BytesView, signum? : Int = 1) -> BigInt { limbs[limbs_len - 1] = (limbs[limbs_len - 1] << 8) | input[i].to_uint() } let byte_per_limb = RADIX_BIT_LEN / 8 - // tail + // tail: at RADIX_BIT_LEN == 32 a limb is exactly one big-endian 32-bit word, + // so the shift-accumulate loop collapses to a single u32be read. That + // constant is pinned by a test in bigint_default_wbtest.mbt, so narrowing it + // fails there rather than silently mis-decoding here. for i in 0..
BigInt // return the result let limbs = FixedArray::make(max_length, 0U) limbs.unsafe_blit(0, x_limbs, 0, max_length) - { limbs, sign: new_sign, len: normalize_len(limbs, max_length) } + { limbs, sign: new_sign, len: normalize_len(limbs, max_length), } } ///| @@ -1716,7 +1755,7 @@ pub impl BitOr for BigInt with fn lor(self : BigInt, other : BigInt) -> BigInt { // return the result let limbs = FixedArray::make(max_length, 0U) limbs.unsafe_blit(0, x_limbs, 0, max_length) - { limbs, sign: new_sign, len: normalize_len(limbs, max_length) } + { limbs, sign: new_sign, len: normalize_len(limbs, max_length), } } ///| @@ -1806,7 +1845,7 @@ pub impl BitXOr for BigInt with fn lxor(self : BigInt, other : BigInt) -> BigInt // return the result let limbs = FixedArray::make(max_length, 0U) limbs.unsafe_blit(0, x_limbs, 0, max_length) - { limbs, sign: new_sign, len: normalize_len(limbs, max_length) } + { limbs, sign: new_sign, len: normalize_len(limbs, max_length), } } ///| @@ -1851,8 +1890,15 @@ pub fn BigInt::to_int(self : BigInt) -> Int { /// } /// ``` pub fn BigInt::to_uint(self : BigInt) -> UInt { - let value = if self.sign == Negative { (1N << 32) + self } else { self } - value.limbs[0] + // Truncation to the low 32 bits is a ring homomorphism, so for a + // negative value it is the wrapping negation of the magnitude's low + // limb. (Adding one modulus and reading the limb would be wrong + // whenever the sum is still negative, i.e. whenever |self| > 2^32.) + if self.sign == Negative { + 0U - self.limbs[0] + } else { + self.limbs[0] + } } ///| @@ -1860,7 +1906,7 @@ pub fn BigInt::to_uint(self : BigInt) -> UInt { /// /// Parameters: /// -/// * `value` : The `BigInt` value to be converted. +/// * `self` : The `BigInt` value to be converted. /// /// Returns a 64-bit signed integer (`Int64`) representing the lower 64 bits of /// the input `BigInt`. @@ -1900,15 +1946,22 @@ pub fn BigInt::to_int64(self : BigInt) -> Int64 { /// } /// ``` pub fn BigInt::to_uint64(self : BigInt) -> UInt64 { - let value = if self.sign == Negative { (1N << 64) + self } else { self } let len = 64 / RADIX_BIT_LEN - let len = if value.len < len { value.len } else { len } + let len = if self.len < len { self.len } else { len } let mut result = 0UL for i in len>..0 { result = result << RADIX_BIT_LEN - result = result | (value.limbs[i].to_uint64() & RADIX_MASK) + result = result | (self.limbs[i].to_uint64() & RADIX_MASK) + } + // Truncation to the low 64 bits is a ring homomorphism, so for a + // negative value it is the wrapping negation of the magnitude's low + // bits. (Adding one modulus and reading the limbs would be wrong + // whenever the sum is still negative, i.e. whenever |self| > 2^64.) + if self.sign == Negative { + 0UL - result + } else { + result } - result } ///| diff --git a/bigint/bigint_nonjs_wbtest.mbt b/bigint/bigint_default_wbtest.mbt similarity index 89% rename from bigint/bigint_nonjs_wbtest.mbt rename to bigint/bigint_default_wbtest.mbt index 4eeaa35b5a..dfdfbce603 100644 --- a/bigint/bigint_nonjs_wbtest.mbt +++ b/bigint/bigint_default_wbtest.mbt @@ -12,14 +12,15 @@ // See the License for the specific language governing permissions and // limitations under the License. +// White-box tests for the default 32-bit-limb implementation. + ///| -struct MyBigInt(BigInt) +priv struct MyBigInt(BigInt) ///| impl Show for MyBigInt with fn output(self, logger) { - logger.write_string( - "{limbs : \{@debug.Repr(self.limbs)}, sign : \{@debug.Repr(self.sign)}, len : \{self.len} }", - ) + logger <+ + "{limbs : \{@debug.Repr(self.limbs)}, sign : \{@debug.Repr(self.sign)}, len : \{self.len} }" } ///| @@ -183,3 +184,16 @@ test { content="{limbs : , sign : Negative, len : 2 }", ) // Int64.min_value - 2 } + +///| +/// `BigInt::from_octets` reads each tail limb with a single `u32be` bits +/// pattern, which agrees with the shift-accumulate loop it replaced only when a +/// limb is exactly four bytes wide. Pin the constant here so narrowing it fails +/// this test instead of silently mis-decoding octets. +/// +/// Deliberately `assert_eq` rather than `inspect`: a snapshot would be rewritten +/// by `moon test --update`, which would silently retire this guard at exactly +/// the moment it is supposed to fire. +test "from_octets assumes 32-bit limbs" { + @test.assert_eq(RADIX_BIT_LEN, 32) +} diff --git a/bigint/bigint_js.mbt b/bigint/bigint_js.mbt index fce60c99eb..8978e1e96c 100644 --- a/bigint/bigint_js.mbt +++ b/bigint/bigint_js.mbt @@ -199,12 +199,15 @@ extern "js" fn hex2(b : Byte) -> String = /// Parameters: /// /// * `octets` : A sequence of bytes representing the magnitude of the number in -/// big-endian order. It must not be empty unless `signum` is 0. +/// big-endian order. An empty sequence represents a zero magnitude. /// * `signum` : The sign of the resulting number. A negative value creates a /// negative number, zero returns zero, and a positive value creates a positive /// number. Defaults to 1. /// /// Returns a `BigInt` value represented by the byte sequence and sign. +/// +/// An empty byte sequence yields zero for any `signum`, matching the behavior +/// of Java's `BigInteger`, Python's `int.from_bytes`, and Rust's `num-bigint`. pub fn BigInt::from_octets(octets : BytesView, signum? : Int = 1) -> BigInt { if signum < 0 { return -1N * BigInt::from_octets(octets, signum=1) @@ -213,7 +216,7 @@ pub fn BigInt::from_octets(octets : BytesView, signum? : Int = 1) -> BigInt { return 0N } if octets.is_empty() { - abort("empty octet string") + return 0N } let str = StringBuilder() for octet in octets { @@ -239,14 +242,21 @@ pub fn BigInt::from_octets(octets : BytesView, signum? : Int = 1) -> BigInt { /// Returns a byte sequence representing the number in big-endian order. /// /// Throws a panic if the input number is negative, or if `length` is zero or -/// negative for a non-zero input. +/// negative. pub fn BigInt::to_octets(self : BigInt, length? : Int) -> Bytes { if self < 0 { abort("negative BigInt") } if self == 0 { return match length { - Some(len) => Bytes::make(len, 0) + Some(len) => + // keep the documented contract (and the non-js behavior): a + // non-positive requested length panics even for zero + if len <= 0 { + abort("negative length") + } else { + Bytes::make(len, 0) + } None => [0] } } @@ -651,7 +661,7 @@ pub fn BigInt::to_uint64(self : BigInt) -> UInt64 { /// /// Parameters: /// -/// * `value` : The `BigInt` value to be converted. +/// * `self` : The `BigInt` value to be converted. /// /// Returns a 64-bit signed integer (`Int64`) representing the lower 64 bits of /// the input `BigInt`. diff --git a/bigint/bigint_test.mbt b/bigint/bigint_test.mbt index adea8ac9ad..5365ef0699 100644 --- a/bigint/bigint_test.mbt +++ b/bigint/bigint_test.mbt @@ -66,6 +66,13 @@ test "to_octets pads larger length" { inspect(n.to_octets(length=5), content="b\"\\x00\\x00\\x01\\x02\\x03\"") } +///| +test "from_octets treats empty input as zero" { + inspect(@bigint.BigInt::from_octets(b""), content="0") + inspect(@bigint.BigInt::from_octets(b"", signum=0), content="0") + inspect(@bigint.BigInt::from_octets(b"", signum=-1), content="0") +} + ///| test "add" { let a = @bigint.BigInt::from_int64(123456789012345678L) @@ -1386,3 +1393,31 @@ test "BigInt modpow negative base normalization" { // (-3)^3 = -27, -27 mod 10 = 3 (canonical non-negative) inspect((-3N).pow(3N, modulus=10N), content="3") } + +///| +test "to_string non-power-of-two radixes round-trip and match known values" { + // known small values + inspect(255N.to_string(radix=3), content="100110") + inspect((-255N).to_string(radix=3), content="-100110") + inspect(35N.to_string(radix=36), content="z") + inspect(36N.to_string(radix=36), content="10") + inspect(6N.to_string(radix=6), content="10") + // round-trip large values through every non-power-of-two radix + let big = (@bigint.BigInt::from_string("123456789123456789") << 700) + + @bigint.BigInt::from_string("987654321987654321") + for radix in [3, 5, 6, 7, 11, 12, 15, 20, 33, 36] { + let s = big.to_string(radix~) + assert_eq(@bigint.BigInt::from_string(s, radix~), big) + let s_neg = (-big).to_string(radix~) + assert_eq(@bigint.BigInt::from_string(s_neg, radix~), -big) + } + // boundary around a chunk: radix 3 uses 19-digit chunks; exercise values + // spanning one and several slots + for e in [1, 18, 19, 20, 37, 38, 39] { + let p = @bigint.BigInt::from_string("3").pow( + @bigint.BigInt::from_string(e.to_string()), + ) + assert_eq(@bigint.BigInt::from_string(p.to_string(radix=3), radix=3), p) + inspect(p.to_string(radix=3).length(), content=(e + 1).to_string()) + } +} diff --git a/bigint/bigint_wbtest.mbt b/bigint/bigint_wbtest.mbt index 7b7b5b8b2d..41007f4fe7 100644 --- a/bigint/bigint_wbtest.mbt +++ b/bigint/bigint_wbtest.mbt @@ -575,6 +575,30 @@ test "from_octets" { check_invariant(a) @test.assert_eq(a, 0) + // Test multiple leading zero limbs + let a = BigInt::from_octets(b"\x00\x00\x00\x00\x00\x00\x00\x00\x26") + check_invariant(a) + inspect(a.to_string(), content="38") + @test.assert_eq(a, 38N) + @test.assert_eq(a.compare(38N), 0) + + // Test zero spanning multiple limbs + let a = BigInt::from_octets( + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", + ) + check_invariant(a) + inspect(a.to_string(), content="0") + @test.assert_eq(a, 0N) + @test.assert_eq(a.compare(0N), 0) + let a = BigInt::from_octets( + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", + signum=-1, + ) + check_invariant(a) + inspect(a.to_string(), content="0") + @test.assert_eq(a, 0N) + @test.assert_eq(a.compare(0N), 0) + // Test positive number let a = BigInt::from_octets(b"\x01") check_invariant(a) diff --git a/bigint/bigint_wide.mbt b/bigint/bigint_wide.mbt new file mode 100644 index 0000000000..45a422b436 --- /dev/null +++ b/bigint/bigint_wide.mbt @@ -0,0 +1,1197 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// A sign-magnitude arbitrary-precision integer over 64-bit limbs. +// +// Native and wasm1 use `FixedArray[UInt64]` limbs with base 2^64. wasm-gc +// retains 32-bit limbs, and JavaScript keeps its host-BigInt implementation. +// +// Invariants: +// - len > 0 +// - (exists 0 <= i < len. limbs[i] > 0) => limbs[len-1] > 0 +// - (forall 0 <= i < len. limbs[i] == 0) => limbs[0] == 0 and len == 1 +// - forall len <= i < limbs.length(). limbs[i] == 0 + +///| +#valtype +struct BigInt { + limbs : FixedArray[UInt64] + sign : Sign + len : Int +} + +///| +priv enum Sign { + Positive + Negative +} derive(Eq) + +///| +/// Switch from schoolbook to Karatsuba at this many limbs. +const KARATSUBA_THRESHOLD = 64 + +///| +/// Number of bits represented by each wide limb. +const RADIX_BIT_LEN = 64 + +///| +let zero : BigInt = { limbs: FixedArray::make(1, 0UL), sign: Positive, len: 1, } + +///| +let one : BigInt = { limbs: FixedArray::make(1, 1UL), sign: Positive, len: 1, } + +// Construction + +///| +fn make(len : Int) -> FixedArray[UInt64] { + FixedArray::make(len, 0UL) +} + +///| +/// Drop leading zero limbs; never returns less than 1. +fn normalize_len(limbs : FixedArray[UInt64], max_len : Int) -> Int { + let mut i = max_len + while i > 1 && limbs.unsafe_get(i - 1) == 0 { + i -= 1 + } + i +} + +///| +/// Creates a non-negative `BigInt` from an unsigned 64-bit integer. +pub fn BigInt::from_uint64(n : UInt64) -> BigInt { + { limbs: FixedArray::make(1, n), sign: Positive, len: 1, } +} + +///| +/// Creates a non-negative `BigInt` from an unsigned 32-bit integer. +pub fn BigInt::from_uint(n : UInt) -> BigInt { + BigInt::from_uint64(n.to_uint64()) +} + +///| +/// Creates a `BigInt` from a signed 64-bit integer. +pub fn BigInt::from_int64(n : Int64) -> BigInt { + if n == 0 { + return zero + } + if n < 0 { + // Negating Int64::MIN overflows, so go through the unsigned domain. + let mag = 0UL - n.reinterpret_as_uint64() + { limbs: FixedArray::make(1, mag), sign: Negative, len: 1, } + } else { + { + limbs: FixedArray::make(1, n.reinterpret_as_uint64()), + sign: Positive, + len: 1, + } + } +} + +///| +/// Creates a `BigInt` from a signed 32-bit integer. +pub fn BigInt::from_int(n : Int) -> BigInt { + BigInt::from_int64(n.to_int64()) +} + +///| +/// Returns whether this integer is zero. +pub fn BigInt::is_zero(self : BigInt) -> Bool { + self.len == 1 && self.limbs.unsafe_get(0) == 0 +} + +///| +fn BigInt::with_sign(self : BigInt, sign : Sign) -> BigInt { + if self.is_zero() { + self + } else { + { ..self, sign, } + } +} + +///| +pub impl Neg for BigInt with fn neg(self : BigInt) -> BigInt { + if self.is_zero() { + return self + } + { ..self, sign: if self.sign == Positive { Negative } else { Positive }, } +} + +// Magnitude comparison + +///| +/// Compare |self| with |other|. +fn BigInt::cmp_mag(self : BigInt, other : BigInt) -> Int { + if self.len != other.len { + return if self.len < other.len { -1 } else { 1 } + } + for i = self.len - 1; i >= 0; i = i - 1 { + let a = self.limbs.unsafe_get(i) + let b = other.limbs.unsafe_get(i) + if a != b { + return if a < b { -1 } else { 1 } + } + } + 0 +} + +///| +pub impl Compare for BigInt with fn compare(self : BigInt, other : BigInt) -> Int { + match (self.sign, other.sign) { + (Positive, Negative) => + if self.is_zero() && other.is_zero() { + 0 + } else { + 1 + } + (Negative, Positive) => + if self.is_zero() && other.is_zero() { + 0 + } else { + -1 + } + (Positive, Positive) => self.cmp_mag(other) + (Negative, Negative) => -self.cmp_mag(other) + } +} + +///| +pub impl Eq for BigInt with fn equal(self : BigInt, other : BigInt) -> Bool { + self.compare(other) == 0 +} + +// Magnitude add / sub +// +// These ignore signs entirely; the signed `Add`/`Sub` impls dispatch to them. + +///| +/// |self| + |other|, always positive. +fn BigInt::add_mag(self : BigInt, other : BigInt) -> BigInt { + // Ensure self is the longer operand. + let (a, b) = if self.len >= other.len { (self, other) } else { (other, self) } + let an = a.len + let bn = b.len + let limbs = make(an + 1) + let c = add_vv(limbs, 0, a.limbs, 0, b.limbs, 0, bn) + let c = add_vw(limbs, bn, a.limbs, bn, c, an - bn) + limbs.unsafe_set(an, c) + { limbs, sign: Positive, len: if c != 0 { an + 1 } else { an }, } +} + +///| +/// |self| - |other|, requires |self| >= |other|. Always positive. +fn BigInt::sub_mag(self : BigInt, other : BigInt) -> BigInt { + let an = self.len + let bn = other.len + let limbs = make(an) + let c = sub_vv(limbs, 0, self.limbs, 0, other.limbs, 0, bn) + ignore(sub_vw(limbs, bn, self.limbs, bn, c, an - bn)) + { limbs, sign: Positive, len: normalize_len(limbs, an), } +} + +///| +pub impl Add for BigInt with fn add(self : BigInt, other : BigInt) -> BigInt { + if self.sign == other.sign { + self.add_mag(other).with_sign(self.sign) + } else if self.cmp_mag(other) >= 0 { + self.sub_mag(other).with_sign(self.sign) + } else { + other.sub_mag(self).with_sign(other.sign) + } +} + +///| +pub impl Sub for BigInt with fn sub(self : BigInt, other : BigInt) -> BigInt { + if self.sign != other.sign { + self.add_mag(other).with_sign(self.sign) + } else if self.cmp_mag(other) >= 0 { + self.sub_mag(other).with_sign(self.sign) + } else { + other + .sub_mag(self) + .with_sign(if self.sign == Positive { Negative } else { Positive }) + } +} + +// Multiplication + +///| +pub impl Mul for BigInt with fn mul(self : BigInt, other : BigInt) -> BigInt { + if self.is_zero() || other.is_zero() { + return zero + } + let sign = if self.sign == other.sign { Positive } else { Negative } + // Dispatch on the shorter operand, with the longer operand first. + let (a, b) = if self.len >= other.len { (self, other) } else { (other, self) } + let ret = if b.len == 1 { + a.mul_single_limb(b.limbs.unsafe_get(0)) + } else if b.len < KARATSUBA_THRESHOLD { + a.grade_school_mul(b) + } else { + a.karatsuba_mul(b) + } + { ..ret, sign, } +} + +///| +/// |self| * x for a single limb x. Requires x != 0 and self != 0. +fn BigInt::mul_single_limb(self : BigInt, x : UInt64) -> BigInt { + let n = self.len + let limbs = make(n + 1) + let c = mul_add_vww(limbs, 0, self.limbs, 0, x, 0, n) + limbs.unsafe_set(n, c) + { limbs, sign: Positive, len: if c != 0 { n + 1 } else { n }, } +} + +///| +/// Schoolbook O(n*m) multiply of the magnitudes. +fn BigInt::grade_school_mul(self : BigInt, other : BigInt) -> BigInt { + let an = self.len + let bn = other.len + let limbs = make(an + bn) + basic_mul(limbs, 0, self.limbs, 0, an, other.limbs, 0, bn) + { limbs, sign: Positive, len: normalize_len(limbs, an + bn), } +} + +///| +/// Karatsuba over one scratch buffer. +/// +/// Requires `self.len >= other.len >= KARATSUBA_THRESHOLD`. Karatsuba itself +/// only handles the low `k` limbs of each operand (`k` from `karatsuba_len`, so +/// the recursion splits evenly); whatever sits above `k` is folded back in +/// afterwards as three ordinary products. +fn BigInt::karatsuba_mul(self : BigInt, other : BigInt) -> BigInt { + let m = self.len + let n = other.len + let k = karatsuba_len(n, KARATSUBA_THRESHOLD) + let scratch = make(6 * k) + karatsuba(scratch, 0, self.limbs, 0, other.limbs, 0, k) + let z = make(m + n) + z.unsafe_blit(0, scratch, 0, 2 * k) + if k < n || m != n { + // With B = 2^(64k), x = xh*B + x0 and y = yh*B + y0. The scratch pass + // produced x0*y0; add x0*yh*B, xh*y0*B and xh*yh*B^2. + let x0 = slice_mag(self.limbs, 0, k) + let xh = slice_mag(self.limbs, k, m - k) + let y0 = slice_mag(other.limbs, 0, k) + let yh = slice_mag(other.limbs, k, n - k) + add_term(z, m + n, x0 * yh, k) + add_term(z, m + n, xh * y0, k) + add_term(z, m + n, xh * yh, 2 * k) + } + { limbs: z, sign: Positive, len: normalize_len(z, m + n), } +} + +///| +/// `z[i..zn) += t`, skipping the no-op case so `add_at` never walks a zero. +fn add_term(z : FixedArray[UInt64], zn : Int, t : BigInt, i : Int) -> Unit { + if !t.is_zero() { + add_at(z, zn, t.limbs, t.len, i) + } +} + +///| +/// A normalized magnitude holding `x[off..off+n)`; `zero` when `n <= 0`. +fn slice_mag(x : FixedArray[UInt64], off : Int, n : Int) -> BigInt { + if n <= 0 { + return zero + } + let mut len = n + while len > 1 && x.unsafe_get(off + len - 1) == 0 { + len -= 1 + } + let limbs = make(len) + limbs.unsafe_blit(0, x, off, len) + { limbs, sign: Positive, len, } +} + +// Division + +///| +pub impl Div for BigInt with fn div(self : BigInt, other : BigInt) -> BigInt { + let (q, _) = self.div_mod(other) + q +} + +///| +pub impl Mod for BigInt with fn mod(self : BigInt, other : BigInt) -> BigInt { + let (_, r) = self.div_mod(other) + r +} + +///| +/// Truncating division: the quotient rounds toward zero and the remainder takes +/// the sign of the dividend, matching core's `Div`/`Mod`. +fn BigInt::div_mod(self : BigInt, other : BigInt) -> (BigInt, BigInt) { + if other.is_zero() { + abort("division by zero") + } + let cmp = self.cmp_mag(other) + if cmp < 0 { + return (zero, self) + } + let q_sign = if self.sign == other.sign { Positive } else { Negative } + if cmp == 0 { + return (one.with_sign(q_sign), zero) + } + let (q, r) = if other.len == 1 { + self.div_mod_single_limb(other.limbs.unsafe_get(0)) + } else { + self.knuth_div(other) + } + (q.with_sign(q_sign), r.with_sign(self.sign)) +} + +///| +/// |self| divmod a single limb, using the Möller-Granlund 2/1 divider so the +/// inner loop is two wide multiplies rather than a hardware 128/64 divide. +fn BigInt::div_mod_single_limb(self : BigInt, d : UInt64) -> (BigInt, BigInt) { + let n = self.len + let q = make(n) + if d == 1 { + q.unsafe_blit(0, self.limbs, 0, n) + return ({ limbs: q, sign: Positive, len: n, }, zero) + } + let s = nlz(d) + let dn = d << s + let r = div_w(q, self.limbs, n, dn, reciprocal_word(dn), s) + ( + { limbs: q, sign: Positive, len: normalize_len(q, n), }, + { limbs: FixedArray::make(1, r), sign: Positive, len: 1, }, + ) +} + +///| +/// Knuth TAOCP 4.3.1 Algorithm D over 64-bit limbs. +/// +/// Requires `|self| > |other|` and `other.len >= 2`. +fn BigInt::knuth_div(self : BigInt, other : BigInt) -> (BigInt, BigInt) { + let n = other.len + let m = self.len - n + + // D1. Normalize so the divisor's top limb has its high bit set. + let s = nlz(other.limbs.unsafe_get(n - 1)) + let v = make(n) + if s == 0 { + v.unsafe_blit(0, other.limbs, 0, n) + } else { + ignore(shl_vu(v, other.limbs, s, n)) + } + // u gets one extra limb to hold the shifted-out bits. + let u = make(self.len + 1) + if s == 0 { + u.unsafe_blit(0, self.limbs, 0, self.len) + } else { + let carry = shl_vu(u, self.limbs, s, self.len) + u.unsafe_set(self.len, carry) + } + let q = make(m + 1) + let vn1 = v.unsafe_get(n - 1) + let vn2 = v.unsafe_get(n - 2) + let rec = reciprocal_word(vn1) + let qhatv = make(n + 1) + for j = m; j >= 0; j = j - 1 { + // D3. Estimate q̂ from the top two limbs. + let ujn = u.unsafe_get(j + n) + let mut qhat = 0xffff_ffff_ffff_ffffUL + if ujn != vn1 { + let dm = div2by1(ujn, u.unsafe_get(j + n - 1), vn1, rec) + qhat = dm.q + let mut rhat = dm.r + // Refine: while q̂ * v[n-2] > (r̂ << 64) + u[j+n-2], decrement q̂. + let mut p = umul_wide(qhat, vn2) + let ujn2 = u.unsafe_get(j + n - 2) + while greater_than(p.hi, p.lo, rhat, ujn2) { + qhat -= 1 + let prev = rhat + rhat += vn1 + if rhat < prev { + break + } + p = umul_wide(qhat, vn2) + } + } + + // D4. Multiply and subtract. + let c = mul_add_vww(qhatv, 0, v, 0, qhat, 0, n) + qhatv.unsafe_set(n, c) + let borrow = sub_vv(u, j, u, j, qhatv, 0, n + 1) + + // D5/D6. Rare: q̂ was one too large, add the divisor back. + if borrow != 0 { + let carry = add_vv(u, j, u, j, v, 0, n) + u.unsafe_set(j + n, u.unsafe_get(j + n) + carry) + qhat -= 1 + } + q.unsafe_set(j, qhat) + } + + // D8. Unnormalize the remainder. + let r = make(n) + if s == 0 { + r.unsafe_blit(0, u, 0, n) + } else { + ignore(shr_vu(r, u, s, n)) + } + ( + { limbs: q, sign: Positive, len: normalize_len(q, m + 1), }, + { limbs: r, sign: Positive, len: normalize_len(r, n), }, + ) +} + +// Shifts + +///| +pub impl Shl for BigInt with fn shl(self : BigInt, n : Int) -> BigInt { + if n < 0 { + abort("negative shift count") + } + if self.is_zero() || n == 0 { + return self + } + let words = n / 64 + let bits = n % 64 + let len = self.len + words + 1 + let limbs = make(len) + if bits == 0 { + limbs.unsafe_blit(words, self.limbs, 0, self.len) + } else { + let shifted = make(self.len) + let carry = shl_vu(shifted, self.limbs, bits, self.len) + limbs.unsafe_blit(words, shifted, 0, self.len) + limbs.unsafe_set(words + self.len, carry) + } + { limbs, sign: self.sign, len: normalize_len(limbs, len), } +} + +///| +pub impl Shr for BigInt with fn shr(self : BigInt, n : Int) -> BigInt { + if n < 0 { + abort("negative shift count") + } + if self.is_zero() || n == 0 { + return self + } + let words = n / 64 + let bits = n % 64 + if words >= self.len { + // Arithmetic shift: negatives round toward -infinity, like core. + return if self.sign == Positive { zero } else { -one } + } + let len = self.len - words + let limbs = make(len) + if bits == 0 { + limbs.unsafe_blit(0, self.limbs, words, len) + } else { + let src = make(len) + src.unsafe_blit(0, self.limbs, words, len) + ignore(shr_vu(limbs, src, bits, len)) + } + let res = { limbs, sign: self.sign, len: normalize_len(limbs, len), } + if self.sign == Negative { + // Check whether any bit was shifted out; if so round away from zero. + let lost = for i in 0.. 0 && (self.limbs.unsafe_get(words) & ((1UL << bits) - 1)) != 0 + } + if lost { + return res - one + } + } + res +} + +// Conversions + +///| +/// Returns the number of bits in the minimal representation excluding its +/// sign bit. +pub fn BigInt::bit_length(self : BigInt) -> Int { + if self.is_zero() { + return 0 + } + let mut bits = self.len * RADIX_BIT_LEN - + nlz(self.limbs.unsafe_get(self.len - 1)) + if self.sign == Negative { + // Core defines negative bit length from the minimal two's-complement + // representation, so -2^k needs one fewer magnitude bit. + let is_power_of_two = for i in 0.. 1 { + break false + } + continue one_bits + } nobreak { + true + } + if is_power_of_two { + bits -= 1 + } + } + bits +} + +///| +/// Largest power of ten that fits in a limb: 10^19 < 2^64. +const DECIMAL_CHUNK : UInt64 = 10_000_000_000_000_000_000UL + +///| +const DECIMAL_CHUNK_DIGITS = 19 + +///| +/// `DECIMAL_CHUNK` already has its top bit set, so it is normalized with a +/// shift of zero. Cache its expensive software reciprocal at module startup. +let decimal_chunk_reciprocal : UInt64 = reciprocal_word(DECIMAL_CHUNK) + +///| +/// Decimal rendering by repeated division by 10^19, so each division peels off +/// 19 digits at a time. +fn BigInt::to_string_dec(self : BigInt) -> String { + if self.is_zero() { + return "0" + } + let n = self.len + let work = make(n) + work.unsafe_blit(0, self.limbs, 0, n) + let mut len = n + let chunks = [] + while len > 1 || work.unsafe_get(0) != 0 { + chunks.push( + div_w(work, work, len, DECIMAL_CHUNK, decimal_chunk_reciprocal, 0), + ) + len = normalize_len(work, len) + } + let buf = StringBuilder() + if self.sign == Negative { + buf.write_char('-') + } + buf.write_string(chunks[chunks.length() - 1].to_string()) + for i = chunks.length() - 2; i >= 0; i = i - 1 { + let s = chunks[i].to_string() + for _ in 0..<(DECIMAL_CHUNK_DIGITS - s.length()) { + buf.write_char('0') + } + buf.write_string(s) + } + buf.to_string() +} + +///| +/// `self ^ exp` by square-and-multiply. +pub fn BigInt::pow(self : BigInt, exp : BigInt, modulus? : BigInt) -> BigInt { + if exp.sign == Negative { + abort("negative exponent") + } + match modulus { + None => { + let bits = exp.bit_length() + for i in 0.. { + if m.is_zero() || m.sign == Negative { + abort("modulus must be positive") + } + if m.len == 1 && m.limbs.unsafe_get(0) == 1 { + return zero + } + if exp.is_zero() { + return one + } + // Reduce the base into [0, m), matching core's `(self % m + m) % m`. + let r = self % m + let base = if r.sign == Negative { r + m } else { r } + if base.is_zero() { + return zero + } + if (m.limbs.unsafe_get(0) & 1) == 1 { + base.pow_mont(exp, m) + } else { + base.pow_mod_plain(exp, m) + } + } + } +} + +///| +/// Bit `i` of the magnitude. +fn BigInt::test_bit(self : BigInt, i : Int) -> Bool { + let w = i / 64 + if w >= self.len { + return false + } + ((self.limbs.unsafe_get(w) >> (i % 64)) & 1) == 1 +} + +///| +/// Square-and-multiply with an explicit reduction each step. Used when the +/// modulus is even, where Montgomery does not apply (it needs `m` invertible +/// mod 2^64). +fn BigInt::pow_mod_plain(self : BigInt, exp : BigInt, m : BigInt) -> BigInt { + let bits = exp.bit_length() + for i in 0.. 0`. The win over `pow_mod_plain` is +/// that every reduction becomes a Montgomery multiply — O(n^2) shift-and-add +/// instead of a full Knuth D division — and the window cuts the number of +/// multiplies by roughly a third. +fn BigInt::pow_mont(self : BigInt, exp : BigInt, m : BigInt) -> BigInt { + let n = m.len + let mlimbs = m.limbs + let k0 = mont_k0(mlimbs.unsafe_get(0)) + let t = make(2 * n) + + // x, one, and RR = 2^(128n) mod m, each padded to exactly n limbs. + let x = make(n) + x.unsafe_blit(0, self.limbs, 0, self.len) + let one_arr = make(n) + one_arr.unsafe_set(0, 1) + let rr_big = (one << (2 * 64 * n)) % m + let rr = make(n) + rr.unsafe_blit(0, rr_big.limbs, 0, rr_big.len) + + // powers[i] = Montgomery form of self^i, for the 4-bit window. + let powers : Array[FixedArray[UInt64]] = [] + for _ in 0..<16 { + powers.push(make(n)) + } + montgomery(powers[0], one_arr, rr, mlimbs, k0, n, t) + montgomery(powers[1], x, rr, mlimbs, k0, n, t) + for i in 2..<16 { + montgomery(powers[i], powers[i - 1], powers[1], mlimbs, k0, n, t) + } + let mut z = make(n) + z.unsafe_blit(0, powers[0], 0, n) + let mut zz = make(n) + for i = exp.len - 1; i >= 0; i = i - 1 { + let mut yi = exp.limbs.unsafe_get(i) + let mut j = 0 + while j < 64 { + // Four squarings per nibble, skipped only on the very first window. + if i != exp.len - 1 || j != 0 { + montgomery(zz, z, z, mlimbs, k0, n, t) + montgomery(z, zz, zz, mlimbs, k0, n, t) + montgomery(zz, z, z, mlimbs, k0, n, t) + montgomery(z, zz, zz, mlimbs, k0, n, t) + } + montgomery(zz, z, powers[(yi >> 60).to_int()], mlimbs, k0, n, t) + let swap = z + z = zz + zz = swap + yi = yi << 4 + j += 4 + } + } + // Leave Montgomery form. + montgomery(zz, z, one_arr, mlimbs, k0, n, t) + let res = { limbs: zz, sign: Positive, len: normalize_len(zz, n), } + // Montgomery's conditional subtraction leaves the result below 2m, not m. + if res.cmp_mag(m) >= 0 { + res.sub_mag(m) + } else { + res + } +} + +// Conversions and bitwise operations. + +///| +fn minimum_int(a : Int, b : Int) -> Int { + if a < b { + a + } else { + b + } +} + +///| +fn maximum_int(a : Int, b : Int) -> Int { + if a > b { + a + } else { + b + } +} + +///| +fn digit_from_char(x : Int) -> Int { + match x { + '0'..='9' => x - '0' + 'A'..='Z' => x + (10 - 'A') + 'a'..='z' => x + (10 - 'a') + _ => -1 + } +} + +///| +fn char_from_digit(d : Int) -> Char { + if d < 10 { + (d + '0').unsafe_to_char() + } else { + (d - 10 + 'a').unsafe_to_char() + } +} + +///| +fn pow2_shift(radix : Int) -> Int? { + if radix >= 2 && (radix & (radix - 1)) == 0 { + Some(radix.ctz()) + } else { + None + } +} + +///| +/// Converts this integer to a string in a radix between 2 and 36. +pub fn BigInt::to_string(self : BigInt, radix? : Int = 10) -> String { + if radix < 2 || radix > 36 { + abort("radix must be between 2 and 36") + } + if radix == 10 { + self.to_string_dec() + } else { + self.to_string_radix(radix) + } +} + +///| +fn BigInt::to_string_radix(self : BigInt, radix : Int) -> String { + if self.is_zero() { + return "0" + } + match pow2_shift(radix) { + Some(shift) => self.to_string_radix_pow2(shift) + None => { + let is_negative = self.sign == Negative + let base = BigInt::from_int(radix) + let mut value = if is_negative { -self } else { self } + let digits = [] + while !value.is_zero() { + let (q, r) = value.div_mod(base) + digits.push(char_from_digit(r.to_int())) + value = q + } + let builder = StringBuilder( + size_hint=digits.length() + (if is_negative { 1 } else { 0 }), + ) + if is_negative { + builder.write_char('-') + } + for i in digits.length()>..0 { + builder.write_char(digits[i]) + } + builder.to_string() + } + } +} + +///| +fn BigInt::to_string_radix_pow2(self : BigInt, shift : Int) -> String { + let is_negative = self.sign == Negative + let value = if is_negative { -self } else { self } + let bit_len = value.bit_length() + let digit_len = (bit_len + shift - 1) / shift + let builder = StringBuilder( + size_hint=digit_len + (if is_negative { 1 } else { 0 }), + ) + if is_negative { + builder.write_char('-') + } + let mask = (1UL << shift) - 1 + for pos in digit_len>..0 { + let bit_index = pos * shift + let limb_index = bit_index / RADIX_BIT_LEN + let offset = bit_index % RADIX_BIT_LEN + let mut chunk = value.limbs.unsafe_get(limb_index) >> offset + if offset + shift > RADIX_BIT_LEN && limb_index + 1 < value.len { + chunk = chunk | + (value.limbs.unsafe_get(limb_index + 1) << (RADIX_BIT_LEN - offset)) + } + builder.write_char(char_from_digit((chunk & mask).to_int())) + } + builder.to_string() +} + +///| +fn BigInt::from_string_radix(input : StringView, radix : Int) -> BigInt raise { + match pow2_shift(radix) { + Some(shift) => BigInt::from_string_radix_pow2(input, radix, shift) + None => { + let len = input.length() + if len == 0 { + syntax_err() + } + let sign = if input.unsafe_get(0) == '-' { Negative } else { Positive } + let start = if sign == Negative { 1 } else { 0 } + if start == len { + syntax_err() + } + let base = BigInt::from_int(radix) + let acc = for i in start..= radix { + syntax_err() + } + continue acc * base + BigInt::from_int(digit) + } nobreak { + acc + } + acc.with_sign(sign) + } + } +} + +///| +fn BigInt::from_string_radix_pow2( + input : StringView, + radix : Int, + shift : Int, +) -> BigInt raise { + let len = input.length() + if len == 0 { + syntax_err() + } + let sign = if input.unsafe_get(0) == '-' { Negative } else { Positive } + let start = if sign == Negative { 1 } else { 0 } + if start == len { + syntax_err() + } + let mut first = start + while first < len { + let digit = digit_from_char(input.unsafe_get(first).to_int()) + if digit < 0 || digit >= radix { + syntax_err() + } + if digit != 0 { + break + } + first += 1 + } + if first == len { + return zero + } + let total_bits = (len - first) * shift + let limbs_len = (total_bits + RADIX_BIT_LEN - 1) / RADIX_BIT_LEN + let limbs = FixedArray::make(limbs_len, 0UL) + for i in len>..first; bit_pos = 0 { + let digit = digit_from_char(input.unsafe_get(i).to_int()) + if digit < 0 || digit >= radix { + syntax_err() + } + let limb_index = bit_pos / RADIX_BIT_LEN + let offset = bit_pos % RADIX_BIT_LEN + let value = digit.to_uint64() + limbs.unsafe_set( + limb_index, + limbs.unsafe_get(limb_index) | (value << offset), + ) + if offset + shift > RADIX_BIT_LEN { + let hi = value >> (RADIX_BIT_LEN - offset) + limbs.unsafe_set(limb_index + 1, limbs.unsafe_get(limb_index + 1) | hi) + } + continue bit_pos + shift + } + { limbs, sign, len: normalize_len(limbs, limbs_len), } +} + +///| +fn BigInt::from_string_dec(input : StringView) -> BigInt raise { + let len = input.length() + if len == 0 { + syntax_err() + } + let sign = if input.unsafe_get(0) == '-' { Negative } else { Positive } + let mut i = if sign == Negative { 1 } else { 0 } + if i == len { + syntax_err() + } + let mut acc = zero + while i < len { + let take = minimum_int(DECIMAL_CHUNK_DIGITS, len - i) + let (chunk, scale) = for offset in 0.. 9 { + syntax_err() + } + continue chunk * 10 + digit.to_uint64(), scale * 10 + } nobreak { + (chunk, scale) + } + i += take + acc = acc * BigInt::from_uint64(scale) + BigInt::from_uint64(chunk) + } + acc.with_sign(sign) +} + +///| +/// Parses a string into a `BigInt` using a base between 2 and 36. +#internal(internal, "use `@string.parse_bigint` instead") +#doc(hidden) +pub fn parse_bigint(str : StringView, base? : Int = 10) -> BigInt raise { + if base < 2 || base > 36 { + base_err() + } + if base == 10 { + BigInt::from_string_dec(str) + } else { + BigInt::from_string_radix(str, base) + } +} + +///| +/// Converts a string representation in the specified radix to a `BigInt`. +/// +/// Panics if the input is malformed. Use `@string.parse_bigint` to handle +/// errors. +pub fn BigInt::from_string(input : String, radix? : Int = 10) -> BigInt { + parse_bigint(input.view(), base=radix) catch { + Failure(msg) => abort(msg) + _ => abort("invalid syntax") + } +} + +///| +/// Creates a magnitude from unsigned big-endian bytes. +pub fn BigInt::from_octets(input : BytesView, signum? : Int = 1) -> BigInt { + if signum == 0 || input.length() == 0 { + return zero + } + if signum < 0 { + return -BigInt::from_octets(input) + } + let byte_len = input.length() + let full_limbs = byte_len / 8 + let head_len = byte_len % 8 + let limbs_len = if head_len == 0 { full_limbs } else { full_limbs + 1 } + let limbs = FixedArray::make(limbs_len, 0UL) + // Assemble the partial most-significant limb. + for i in 0.. Bytes { + let minimum_length = match length { + None => 1 + Some(value) => if value <= 0 { abort("negative length") } else { value } + } + if self.is_zero() { + return Bytes::new(maximum_int(1, minimum_length)) + } + if self.sign == Negative { + abort("negative BigInt") + } + let value_length = (self.bit_length() + 7) / 8 + let result_length = maximum_int(minimum_length, value_length) + let result = FixedArray::make(result_length, b'\x00') + for i in 0..> (i % 8 * 8)) & 0xffUL).to_int().to_byte(), + ) + } + unsafe_fixedarray_to_bytes(result) +} + +///| +fn fill_twos_complement(value : BigInt, out : FixedArray[UInt64]) -> Unit { + out.unsafe_blit(0, value.limbs, 0, value.len) + if value.sign == Negative { + for i in 0.. UInt64, +) -> BigInt { + let limbs_len = maximum_int(self.len, other.len) + 1 + let x = FixedArray::make(limbs_len, 0UL) + let y = FixedArray::make(limbs_len, 0UL) + fill_twos_complement(self, x) + fill_twos_complement(other, y) + for i in 0.. BigInt { + self.bitwise(other, (a, b) => a & b) +} + +///| +pub impl BitOr for BigInt with fn lor(self : BigInt, other : BigInt) -> BigInt { + self.bitwise(other, (a, b) => a | b) +} + +///| +pub impl BitXOr for BigInt with fn lxor(self : BigInt, other : BigInt) -> BigInt { + self.bitwise(other, (a, b) => a ^ b) +} + +///| +/// Returns the low 32 bits reinterpreted as a signed integer. +pub fn BigInt::to_int(self : BigInt) -> Int { + self.to_uint().reinterpret_as_int() +} + +///| +/// Returns the low 32 bits as an unsigned integer. +pub fn BigInt::to_uint(self : BigInt) -> UInt { + let low = self.limbs.unsafe_get(0).to_uint() + if self.sign == Negative { + 0U - low + } else { + low + } +} + +///| +/// Returns the low 64 bits reinterpreted as a signed integer. +pub fn BigInt::to_int64(self : BigInt) -> Int64 { + self.to_uint64().reinterpret_as_int64() +} + +///| +/// Returns the low 64 bits as an unsigned integer. +pub fn BigInt::to_uint64(self : BigInt) -> UInt64 { + let low = self.limbs.unsafe_get(0) + if self.sign == Negative { + 0UL - low + } else { + low + } +} + +///| +/// Returns the number of trailing zero bits in the magnitude. +pub fn BigInt::ctz(self : BigInt) -> Int { + if self.is_zero() { + return 0 + } + let mut i = 0 + while self.limbs.unsafe_get(i) == 0 { + i += 1 + } + RADIX_BIT_LEN * i + self.limbs.unsafe_get(i).ctz() +} + +///| +fn unsafe_fixedarray_to_bytes(arr : FixedArray[Byte]) -> Bytes = "%identity" + +///| +fn can_convert_to_int(x : BigInt) -> Bool { + x.len == 1 && + (if x.sign == Negative { + x.limbs.unsafe_get(0) <= 0x8000_0000UL + } else { + x.limbs.unsafe_get(0) < 0x8000_0000UL + }) +} + +///| +fn can_convert_to_int64(x : BigInt) -> Bool { + x.len == 1 && + (if x.sign == Negative { + x.limbs.unsafe_get(0) <= 0x8000_0000_0000_0000UL + } else { + x.limbs.unsafe_get(0) < 0x8000_0000_0000_0000UL + }) +} + +///| +fn is_neg(x : BigInt) -> Bool { + x.sign == Negative +} + +///| +/// Returns the magnitude as 32-bit words so hashing remains representation +/// independent and agrees with the JavaScript backend. +fn BigInt::limbs(self : Self) -> Array[UInt] { + let result = [] + for i in 0..> 32).to_uint() + if i + 1 < self.len || high != 0 { + result.push(high) + } + } + result +} diff --git a/bigint/bigint_wide_wbtest.mbt b/bigint/bigint_wide_wbtest.mbt new file mode 100644 index 0000000000..4a5e8e44fc --- /dev/null +++ b/bigint/bigint_wide_wbtest.mbt @@ -0,0 +1,207 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +priv struct MyBigInt(BigInt) + +///| +impl Show for MyBigInt with fn output(self, logger) { + let sign = match self.sign { + Positive => "Positive" + Negative => "Negative" + } + logger <+ + "{limbs : \{@debug.Repr(self.limbs)}, sign : \{sign}, len : \{self.len} }" +} + +///| +test "debug_string" { + let buf = StringBuilder() + let v : Array[MyBigInt] = [0, 1, 2, 3, 4, -0, -1, -2, -3] + (buf as &Logger).write_iter(v.iter(), sep="\n", prefix="", suffix="") + // Logger::writer_iter() + // trait logger has no method write_iter + // precise: + // (dyn Logger)::write_iter(buf,..) + // Logger::trait_method() + inspect( + buf, + content=( + #|{limbs : , sign : Positive, len : 1 } + #|{limbs : , sign : Positive, len : 1 } + #|{limbs : , sign : Positive, len : 1 } + #|{limbs : , sign : Positive, len : 1 } + #|{limbs : , sign : Positive, len : 1 } + #|{limbs : , sign : Positive, len : 1 } + #|{limbs : , sign : Negative, len : 1 } + #|{limbs : , sign : Negative, len : 1 } + #|{limbs : , sign : Negative, len : 1 } + ), + ) +} + +///| +fn check_invariant(a : BigInt) -> Unit raise { + guard a.len > 0 else { fail("invariant len > 0 is broken: len = \{a.len}") } + if a.limbs.iter().take(a.len).any(x => x > 0) { + guard a.limbs[a.len - 1] > 0 else { + fail( + "invariant (exists 0 <= i < len. limbs[i] > 0) => limbs[len-1] > 0 is broken", + ) + } + } else { + guard a.len == 1 && a.limbs[0] == 0 else { + fail( + "invariant (forall 0 <= i < len. limbs[i] == 0) => limbs[0] == 0 and len == 1 is broken: len = \{a.len}, limbs = \{@debug.Repr(a.limbs)}", + ) + } + } + guard a.limbs.iter().drop(a.len).all(x => x == 0) else { + fail( + "invariant forall len <= i < limbs.length(). limbs[i] == 0 is broken: len = \{a.len}, limbs = \{@debug.Repr(a.limbs)}", + ) + } +} + +///| +test "shr" { + let a = BigInt::from_int64(1234567890123456789L) + let b = a >> 1 + check_invariant(b) + inspect(b, content="617283945061728394") + let c = a >> 64 + check_invariant(c) + inspect(c, content="0") + // 2^127 spans two UInt64 limbs; shifting by two complete limbs drops it. + let a = 1N << 127 + let b = a >> (RADIX_BIT_LEN * 2) + check_invariant(b) + inspect(b, content="0") +} + +///| +test "mul_single_limb boundaries" { + // One-limb by one-limb with maximal carry-out: (2^64 - 1)^2. + let max_limb = 18446744073709551615N + let p = max_limb * max_limb + check_invariant(p) + @test.assert_eq(p, (1N << 128) - (1N << 65) + 1N) + // Three all-max limbs times one max limb; carry grows len to n + 1. + let a = (1N << 192) - 1N + let expected = (1N << 256) - (1N << 192) - (1N << 64) + 1N + let p = a * max_limb + check_invariant(p) + @test.assert_eq(p, expected) + // commuted operand order takes the same fast path + let p = max_limb * a + check_invariant(p) + @test.assert_eq(p, expected) + // no carry-out: len stays at self.len, the spare limb beyond len stays zero + let p = (1N << 128) * 2N + check_invariant(p) + @test.assert_eq(p, 1N << 129) + // the dispatch applies the sign on top of the magnitude-only helper + @test.assert_eq(a * -max_limb, -expected) + @test.assert_eq(-a * max_limb, -expected) + @test.assert_eq(-a * -max_limb, expected) + // fast path agrees with the general routine on the same magnitudes + let fast = a.mul_single_limb(0xffff_ffff_ffff_ffffUL) + check_invariant(fast) + @test.assert_eq(fast, a.grade_school_mul(max_limb)) +} + +///| +test "multiplication around the wide Karatsuba threshold" { + let cases : Array[(Int, Int)] = [ + (63, 63), // schoolbook on both sides of the last pre-threshold case + (63, 64), // asymmetric input still dispatches on the shorter operand + (64, 64), // first exact Karatsuba case + (64, 65), // Karatsuba with one high tail limb + (65, 65), + ] + for case in cases { + let (limbs_a, limbs_b) = case + let bits_a = limbs_a * RADIX_BIT_LEN + let bits_b = limbs_b * RADIX_BIT_LEN + let a = (1N << bits_a) - 1N + let b = (1N << bits_b) - 1N + let expected = (1N << (bits_a + bits_b)) - + (1N << bits_a) - + (1N << bits_b) + + 1N + @test.assert_eq(a.len, limbs_a) + @test.assert_eq(b.len, limbs_b) + let product = a * b + check_invariant(product) + @test.assert_eq(product, expected) + } +} + +///| +test "add coverage for max(self_len, other_len)" { + let a = BigInt::from_int(123456789) + let b = BigInt::from_int(987654321) + let result = a + b + inspect(a.len, content="1") + inspect(b.len, content="1") + inspect(result.len, content="1") +} + +///| +test "sub coverage for max(self_len, other_len)" { + let a = BigInt::from_int(987654321) + let b = BigInt::from_int(123456789) + let result = a - b + inspect(a.len, content="1") + inspect(b.len, content="1") + inspect(result.len, content="1") +} + +///| +test "BigInt::bit_length" { + inspect(0N.bit_length(), content="0") + inspect(1N.bit_length(), content="1") + inspect((-1N).bit_length(), content="0") + inspect(16N.bit_length(), content="5") + inspect((-16N).bit_length(), content="4") + inspect(42N.bit_length(), content="6") + inspect((-42N).bit_length(), content="6") + inspect(1024N.bit_length(), content="11") + inspect(10000000N.bit_length(), content="24") + inspect((1N << 31).bit_length(), content="32") + inspect((1N << 63).bit_length(), content="64") +} + +///| +test "BigInt::ctz" { + inspect(0N.ctz(), content="0") + inspect(1N.ctz(), content="0") + inspect(8N.ctz(), content="3") // 1000 + inspect(12N.ctz(), content="2") // 1100 + inspect(174056N.ctz(), content="3") // 101010011111101000 + inspect((1N << 31).ctz(), content="31") + inspect((1N << 63).ctz(), content="63") +} + +///| +test { + inspect( + MyBigInt(-9223372036854775809N), + content="{limbs : , sign : Negative, len : 1 }", + ) // Int64.min_value - 1 + inspect( + MyBigInt(-9223372036854775810N), + content="{limbs : , sign : Negative, len : 1 }", + ) // Int64.min_value - 2 +} diff --git a/bigint/deprecated.mbt b/bigint/deprecated.mbt deleted file mode 100644 index 2f37fba359..0000000000 --- a/bigint/deprecated.mbt +++ /dev/null @@ -1,129 +0,0 @@ -// Copyright 2026 International Digital Economy Academy -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -///| -/// Deprecated: use `to_string(radix=16)` instead. -#deprecated("Use `to_string(radix=16)` instead") -#coverage.skip -#cfg(not(target="js")) -pub fn BigInt::to_hex(self : BigInt, uppercase? : Bool = true) -> String { - if self.is_zero() { - return "0" - } - // WARN: this implementation assumes that `RADIX_BIT_LEN` is a multiple of 4. - let digits_per_limb = RADIX_BIT_LEN / 4 - let buf = if self.sign is Negative { - let builder = StringBuilder(size_hint=self.len * digits_per_limb + 2) - builder.write_char('-') - builder - } else { - StringBuilder(size_hint=self.len * digits_per_limb) - } - for i in self.len>..0 { - // split the limb into 4-bit chunks - let digits = FixedArray::make(digits_per_limb, '0') - let idx = for x = self.limbs[i], idx = 0; x > 0; { - let y = x % 16 - digits[idx] = if y < 10 { - (y.reinterpret_as_int() + '0'.to_int()).unsafe_to_char() - } else if uppercase { - (y.reinterpret_as_int() - 10 + 'A'.to_int()).unsafe_to_char() - } else { - (y.reinterpret_as_int() - 10 + 'a'.to_int()).unsafe_to_char() - } - continue x / 16, idx + 1 - } nobreak { - idx - } - let idx = if i != self.len - 1 { digits_per_limb } else { idx } - for j in 0.. String = - #|(x, uppercase) => { - #| const r = x.toString(16); - #| return uppercase ? r.toUpperCase() : r; - #|} - -///| -/// Deprecated: use `from_string(radix=16)` instead. -#deprecated("Use `from_string(radix=16)` instead") -#coverage.skip -#cfg(not(target="js")) -pub fn BigInt::from_hex(input : String) -> BigInt { - // WARN: this implementation assumes that `RADIX_BIT_LEN` is a multiple of 4. - fn char_from_hex(x : Int) -> UInt { - (match x { - '0'..='9' => x - '0' - 'A'..='F' => x + (10 - 'A') - 'a'..='f' => x + (10 - 'a') - _ => abort("invalid character") - }).reinterpret_as_uint() - } - - let len = input.length() - if len == 0 { - abort("empty string") - } - let (sign, number_len) = if input.unsafe_get(0) == '-' { - (Negative, len - 1) - } else { - (Positive, len) - } - let nb_char = RADIX_BIT_LEN / 4 // number of char per limb - let quotient = number_len / nb_char - let mod = number_len % nb_char - let b_len = if mod == 0 { quotient } else { quotient + 1 } - let b = FixedArray::make(b_len, 0U) - if mod != 0 { - let start = len - quotient * nb_char - mod - for i in 0.. 1; { - continue b_len - 1 - } nobreak { - b_len - } - let sign = if b_len == 1 && b[0] == 0 { Positive } else { sign } - { limbs: b, sign, len: b_len } -} - -///| -/// Deprecated: use `from_string(radix=16)` instead. -#deprecated("Use `from_string(radix=16)` instead") -#coverage.skip -#cfg(target="js") -pub extern "js" fn BigInt::from_hex(str : String) -> BigInt = - #|(x) => x.startsWith('-') ? -BigInt(`0x${x.slice(1)}`) : BigInt(`0x${x}`) diff --git a/strconv/number_test.mbt b/bigint/from_octets_bench_test.mbt similarity index 55% rename from strconv/number_test.mbt rename to bigint/from_octets_bench_test.mbt index 37f98e938b..47b31ab771 100644 --- a/strconv/number_test.mbt +++ b/bigint/from_octets_bench_test.mbt @@ -13,24 +13,18 @@ // limitations under the License. ///| -#warnings("-deprecated") -test "parse_inf_nan positive NaN" { - let result = @strconv.parse_double("+nan") - inspect(result.is_nan(), content="true") +fn from_octets_bench_data(len : Int) -> Bytes { + Bytes::makei(len, i => ((i * 37 + 11) & 0xff).to_byte()) } ///| -#warnings("-deprecated") -test "parse_inf_nan negative NaN" { - let result = @strconv.parse_double("-nan") - inspect(result.is_nan(), content="true") +test "bench BigInt::from_octets n=64" (it : @bench.T) { + let data = from_octets_bench_data(64) + it.bench(fn() { it.keep(@bigint.BigInt::from_octets(data)) }) } ///| -#warnings("-deprecated") -test "from_str generic" { - let i : Int = @strconv.from_str("123") - inspect(i, content="123") - let j : Int64 = @strconv.from_str("1234567890123") - inspect(j, content="1234567890123") +test "bench BigInt::from_octets n=1024" (it : @bench.T) { + let data = from_octets_bench_data(1024) + it.bench(fn() { it.keep(@bigint.BigInt::from_octets(data)) }) } diff --git a/bigint/moon.pkg b/bigint/moon.pkg index d3e778f145..43056d1571 100644 --- a/bigint/moon.pkg +++ b/bigint/moon.pkg @@ -23,9 +23,12 @@ warnings = "-29" options( targets: { + "arith_wide.mbt": [ "native", "wasm" ], "bigint_js.mbt": [ "js" ], "bigint_js_wbtest.mbt": [ "js" ], - "bigint_nonjs.mbt": [ "not", "js" ], - "bigint_nonjs_wbtest.mbt": [ "not", "js" ], + "bigint_default.mbt": [ "not", "js", "wasm", "native" ], + "bigint_default_wbtest.mbt": [ "not", "js", "wasm", "native" ], + "bigint_wide.mbt": [ "native", "wasm" ], + "bigint_wide_wbtest.mbt": [ "native", "wasm" ], }, ) diff --git a/bigint/octets_regression_test.mbt b/bigint/octets_regression_test.mbt new file mode 100644 index 0000000000..e4f4052f1b --- /dev/null +++ b/bigint/octets_regression_test.mbt @@ -0,0 +1,31 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Regression test for moonbitlang/core#4052 — the js `to_octets` ran its +// zero shortcut before validating the requested length, silently +// returning b"" for `0N.to_octets(length=0)` while the documented +// contract (and every other target) panics on a non-positive length. + +///| +test "panic to_octets of zero rejects non-positive length" { + // documented contract: non-positive length panics on every target + // (the js implementation used to return b"" here) + let _ = 0N.to_octets(length=0) +} + +///| +test "to_octets of zero still pads positive lengths" { + inspect(0N.to_octets(), content="b\"\\x00\"") + inspect(0N.to_octets(length=3), content="b\"\\x00\\x00\\x00\"") +} diff --git a/bigint/panic_test.mbt b/bigint/panic_test.mbt index 347fe11ac1..b603121d9c 100644 --- a/bigint/panic_test.mbt +++ b/bigint/panic_test.mbt @@ -85,11 +85,6 @@ test "panic pow negative exponent" { base.pow(exp) |> ignore } -///| -test "panic from_octets empty" { - @bigint.BigInt::from_octets(Bytes::new(0)) |> ignore -} - ///| test "panic to_octets negative" { (-1N).to_octets() |> ignore diff --git a/bigint/pkg.generated.mbti b/bigint/pkg.generated.mbti index 0541c82e3c..e001d571f2 100644 --- a/bigint/pkg.generated.mbti +++ b/bigint/pkg.generated.mbti @@ -26,8 +26,6 @@ pub fn BigInt::equal_int(Self, Int) -> Bool pub fn BigInt::equal_int64(Self, Int64) -> Bool pub fn BigInt::equal_uint(Self, UInt) -> Bool pub fn BigInt::equal_uint64(Self, UInt64) -> Bool -#deprecated -pub fn BigInt::from_hex(String) -> Self pub fn BigInt::from_int(Int) -> Self pub fn BigInt::from_int64(Int64) -> Self pub fn BigInt::from_octets(BytesView, signum? : Int) -> Self @@ -46,8 +44,6 @@ pub fn BigInt::pow(Self, Self, modulus? : Self) -> Self pub fn BigInt::shl(Self, Int) -> Self pub fn BigInt::shr(Self, Int) -> Self pub fn BigInt::sub(Self, Self) -> Self -#deprecated -pub fn BigInt::to_hex(Self, uppercase? : Bool) -> String pub fn BigInt::to_int(Self) -> Int pub fn BigInt::to_int16(Self) -> Int16 pub fn BigInt::to_int64(Self) -> Int64 diff --git a/bigint/quickcheck_deep_test.mbt b/bigint/quickcheck_deep_test.mbt new file mode 100644 index 0000000000..37a0f4ed30 --- /dev/null +++ b/bigint/quickcheck_deep_test.mbt @@ -0,0 +1,464 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Deep arithmetic properties for BigInt, complementing quickcheck_test.mbt. +// That suite pins small-value semantics (an Int64 oracle) and +// construction-path invariance; this one drives the large-operand +// algorithm paths that only engage far beyond 64 bits: +// +// * multiplication straddling the Karatsuba threshold (50 limbs), +// cross-checked against products reassembled from sub-threshold chunks +// and against closed forms with maximal carry chains; +// * Knuth division (TAOCP 4.3.1) with adversarial divisor top limbs, +// where quotient-digit estimation is famously fragile; +// * shifts, radix round-trips, pow/modpow, octet round-trips, and +// fixed-width truncation, at sizes crossing many limb boundaries. +// +// All inputs are deterministic functions of literal seeds (SplitMix64), +// so every target runs the identical value stream — any cross-target +// divergence is itself a failure. This suite found two such divergences, +// moonbitlang/core#4050 (non-js `to_uint`/`to_uint64` truncation of +// large negatives) and moonbitlang/core#4052 (js `to_octets` length +// validation); their dedicated regression tests live alongside the +// respective fixes (truncation_regression_test.mbt and +// octets_regression_test.mbt). + +///| +fn mix64(s : UInt64) -> UInt64 { + let z = s * 6364136223846793005UL + 1442695040888963407UL + let z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9UL + let z = (z ^ (z >> 27)) * 0x94D049BB133111EBUL + z ^ (z >> 31) +} + +///| +fn seed_u64(i : Int) -> UInt64 { + i.to_int64().reinterpret_as_uint64() +} + +///| +/// A pseudorandom positive BigInt with exactly `limbs` 32-bit limbs (the +/// top byte is forced nonzero), built through the octet path. +fn mag_of_limbs(seed : UInt64, limbs : Int) -> @bigint.BigInt { + let n = limbs * 4 + let bytes = Bytes::makei(n, i => { + let b = (mix64(seed + seed_u64(i)) & 0xffUL).to_int() + let b = if i == 0 && b == 0 { 1 } else { b } + b.to_byte() + }) + @bigint.BigInt::from_octets(bytes) +} + +///| +/// Multiplication reassembled from 1024-bit (32-limb) chunk products. +/// Every chunk product stays below the Karatsuba threshold, so this is an +/// independently-routed oracle for the Karatsuba path. +fn chunked_mul(a : @bigint.BigInt, b : @bigint.BigInt) -> @bigint.BigInt { + let chunk_bits = 1024 + let mask = (1N << chunk_bits) - 1N + let mut acc = 0N + let mut x = a + let mut shift_a = 0 + while x > 0N { + let xa = x & mask + if xa > 0N { + let mut y = b + let mut shift_b = 0 + while y > 0N { + let yb = y & mask + if yb > 0N { + acc += (xa * yb) << (shift_a + shift_b) + } + y = y >> chunk_bits + shift_b += chunk_bits + } + } + x = x >> chunk_bits + shift_a += chunk_bits + } + acc +} + +///| +test "multiplication straddling the Karatsuba threshold" { + // dense sweep right at the cutoff (50 limbs), where the operand split + // and recombination is most likely to go wrong + for la in 47..<54 { + for lb in 47..<54 { + let a = mag_of_limbs(seed_u64(la * 100 + lb), la) + let b = mag_of_limbs(seed_u64(la * 313 + lb) + 1UL, lb) + assert_eq(a * b, chunked_mul(a, b)) + // all-ones magnitude: every partial product carries + let ones = (1N << (32 * la)) - 1N + assert_eq(ones * b, chunked_mul(ones, b)) + } + } + // asymmetric and large sizes, plus the sign table + let sizes : Array[(Int, Int)] = [ + (50, 99), + (99, 50), + (100, 100), + (150, 77), + (255, 256), + (300, 129), + ] + for si in 0.. { + let (s, i, j, k) = input + let szs : Array[Int] = [1, 2, 49, 50, 51, 120] + let sa = szs[(i & 0x7fffffff) % szs.length()] + let sb = szs[(j & 0x7fffffff) % szs.length()] + let sc = szs[(k & 0x7fffffff) % szs.length()] + let seed = seed_u64(s) * 0x9E3779B9UL + let a = mag_of_limbs(seed + 1UL, sa) + let b = mag_of_limbs(seed + 2UL, sb) + let c = mag_of_limbs(seed + 3UL, sc) + a * (b + c) == a * b + a * c && + a + b - b == a && + a * b / b == a && + (a * b % b).is_zero() + }, + count=60, + ) +} + +///| +test "quickcheck: truncated division law across mixed size classes" { + @quickcheck.check( + (input : (Int, Int, Int, Bool, Bool)) => { + let (s, i, j, na, nb) = input + let la = 1 + (i & 63) // 1..64 limbs + let lb = 1 + (j & 63) + let seed = seed_u64(s) * 0xDEADBEEFUL + 12345UL + let a0 = mag_of_limbs(seed, la) + let b0 = mag_of_limbs(seed + 424242UL, lb) + let a = if na { -a0 } else { a0 } + let b = if nb { -b0 } else { b0 } + let q = a / b + let r = a % b + guard q * b + r == a else { return false } + let abs_r = if r < 0N { -r } else { r } + let abs_b = if b < 0N { -b } else { b } + guard abs_r < abs_b else { return false } + // truncated convention: remainder takes the dividend's sign, + // quotient rounds toward zero + guard r.is_zero() || (r < 0N) == (a < 0N) else { return false } + guard q.is_zero() || (q < 0N) == ((a < 0N) != (b < 0N)) else { + return false + } + true + }, + count=120, + ) +} + +///| +test "division with adversarial divisor top limbs" { + // Knuth 4.3.1 quotient-digit estimation is most fragile when the + // normalized divisor's top limb is minimal (0x80000000) or maximal + // (0xffffffff) and the quotient digits are all-ones. Exact roundtrip + // (q*b + r) / b == q with 0 <= r < b catches any mis-estimation. + let bs : Array[@bigint.BigInt] = [] + for blen in ([2, 3, 5, 20, 60] : Array[_]) { + bs.push(1N << (32 * blen - 1)) // 0x80000000 0...0 + bs.push((1N << (32 * blen - 1)) + 1N) + bs.push((1N << (32 * blen)) - 1N) // all 0xffffffff + bs.push((1N << (32 * blen - 1)) + ((1N << (32 * (blen - 1))) - 1N)) + bs.push((1N << (32 * blen)) - (1N << 32)) // 0xff.. with a zero low limb + } + let qs : Array[@bigint.BigInt] = [] + for qlen in ([1, 2, 3, 51, 70] : Array[_]) { + qs.push((1N << (32 * qlen)) - 1N) + qs.push(1N << (32 * qlen - 1)) + qs.push((1N << (32 * qlen - 1)) + 1N) + } + for b in bs { + for q in qs { + for r in ([0N, 1N, b - 1N, b >> 1, b - 2N] : Array[_]) { + let a = q * b + r + assert_eq(a / b, q) + assert_eq(a % b, r) + } + } + } +} + +///| +test "division qh estimation corners, three-limb divisors" { + let pats : Array[UInt] = [ + 0U, 1U, 0x7fffffffU, 0x80000000U, 0x80000001U, 0xffffffffU, + ] + let big_qs : Array[@bigint.BigInt] = [ + (1N << 1920) - 1N, // 60 all-ones limbs + (1N << 1600) + 1N, + ((1N << 96) - 1N) << 512, + ] + for v1 in pats { + if v1 == 0 { + continue + } + for v2 in pats { + for v3 in pats { + let b = (@bigint.BigInt::from_uint(v1) << 64) | + (@bigint.BigInt::from_uint(v2) << 32) | + @bigint.BigInt::from_uint(v3) + let qs : Array[@bigint.BigInt] = [] + for q1 in pats { + if q1 == 0 { + continue + } + qs.push( + (@bigint.BigInt::from_uint(q1) << 32) | + @bigint.BigInt::from_uint(0xffffffffU), + ) + } + for bq in big_qs { + qs.push(bq) + } + for q in qs { + for r in ([0N, 1N, b - 1N, b >> 1] : Array[_]) { + let a = q * b + r + assert_eq(a / b, q) + assert_eq(a % b, r) + } + } + } + } + } +} + +///| +test "shifts against multiplication and floor division" { + let ks : Array[Int] = [1, 31, 32, 33, 63, 64, 65, 95, 1023, 1024, 1025, 1600] + for t in 0..<4 { + let m = mag_of_limbs(0xBEEF00UL + seed_u64(t), 40 + t * 17) + for a in ([m, -m] : Array[_]) { + for k in ks { + let p = 1N << k + assert_eq(a << k, a * p) + // a >> k is the floor: the unique q with q*2^k <= a < (q+1)*2^k + let q = a >> k + assert_true(q * p <= a) + assert_true(a < q * p + p) + } + } + } + // extremes and exact limb-width multiples on negative values + assert_eq(5N >> 100000, 0N) + assert_eq(-5N >> 100000, -1N) + assert_eq(-1N >> 1, -1N) + assert_eq(-2N >> 1, -1N) + assert_eq(-3N >> 1, -2N) + let v = -((1N << 96) + 1N) + assert_eq(v >> 32, -((1N << 64) + 1N)) + assert_eq(v >> 96, -2N) + assert_eq(-(1N << 96) >> 96, -1N) +} + +///| +test "radix roundtrip for every base 2..36" { + for base in 2..<=36 { + for t in 0..<3 { + let limbs = [1, 8, 40][t] + let m = mag_of_limbs(seed_u64(base * 100 + t) + 0xD00DUL, limbs) + for a in ([m, -m] : Array[_]) { + let s = a.to_string(radix=base) + assert_eq(@bigint.BigInt::from_string(s, radix=base), a) + } + } + } +} + +///| +test "huge decimal roundtrip anchored to a hex literal" { + // the hex printer is plain bit manipulation, so a parse/print pair that + // reproduces the original 800-digit hex string anchors the value; the + // decimal round-trip then exercises the batched base-10^7 converter and + // the per-digit decimal parser against each other at ~500 limbs + let digits = "0123456789abcdef" + let sb = StringBuilder() + for i in 0..<800 { + let d = (mix64(0xFACEUL + seed_u64(i)) & 15UL).to_int() + let d = if i == 0 && d == 0 { 15 } else { d } + sb.write_char(digits.get_char(d).unwrap()) + } + let hex = sb.to_string() + let a = @bigint.BigInt::from_string(hex, radix=16) + assert_eq(a.to_string(radix=16), hex) + assert_eq(@bigint.BigInt::from_string(a.to_string()), a) + let big = mag_of_limbs(0xC0FFEEUL, 500) + assert_eq(@bigint.BigInt::from_string(big.to_string()), big) + assert_eq(@bigint.BigInt::from_string((-big).to_string()), -big) +} + +///| +test "pow against iterated multiplication, modpow against reduction" { + for t in 0..<4 { + let base0 = mag_of_limbs(0xAB0BAUL + seed_u64(t) * 131UL, 3) + for b in ([base0, -base0] : Array[_]) { + let m = mag_of_limbs(0x60D5EEDUL + seed_u64(t), 2) + let mut acc = 1N + for e in 0..<9 { + let eb = @bigint.BigInt::from_int(e) + assert_eq(b.pow(eb), acc) + // modpow result is canonical: in [0, m) even for negative bases + assert_eq(b.pow(eb, modulus=m), (acc % m + m) % m) + acc = acc * b + } + } + } +} + +///| +test "octets roundtrip at every length" { + let lens : Array[Int] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 63, 64, 65] + for li in 0.. { + let b = (mix64(0x0C7E75UL + seed_u64(li * 1000 + i)) & 0xffUL).to_int() + let b = if i == 0 && b == 0 { 1 } else { b } + b.to_byte() + }) + let x = @bigint.BigInt::from_octets(bytes) + assert_eq(x.to_octets(length=n), bytes) + assert_eq(x.to_octets(), bytes) + } +} + +///| +test "fixed-width conversion boundaries and truncation" { + let int64s : Array[Int64] = [ + 0L, 1L, -1L, 2147483647L, -2147483648L, 2147483648L, -2147483649L, 4294967295L, + 4294967296L, -4294967296L, 9223372036854775807L, -9223372036854775808L, + ] + for x in int64s { + let b = @bigint.BigInt::from_int64(x) + assert_eq(b.to_int64(), x) + assert_eq(@bigint.BigInt::from_string(x.to_string()), b) + } + let uint64s : Array[UInt64] = [ + 0UL, 1UL, 4294967295UL, 4294967296UL, 9223372036854775807UL, 9223372036854775808UL, + 18446744073709551615UL, + ] + for u in uint64s { + let b = @bigint.BigInt::from_uint64(u) + assert_eq(b.to_uint64(), u) + } + // truncation keeps the low bits in two's complement — regression for + // the magnitude-limb defect on negatives beyond one modulus + assert_eq(((1N << 80) + 5N).to_uint64(), 5UL) + assert_eq((-(1N << 80) - 5N).to_uint64(), 0UL - 5UL) + assert_eq((-(1N << 65) - 5N).to_uint64(), 0UL - 5UL) + assert_eq(((1N << 80) + 5N).to_uint(), 5U) + assert_eq((-(1N << 80) - 5N).to_uint(), 0U - 5U) + assert_eq((-(1N << 33) - 5N).to_uint(), 0U - 5U) + assert_eq((-(1N << 34)).to_uint(), 0U) + assert_eq((-(1N << 33) - 4294967295N).to_uint(), 1U) +} + +///| +test "comparison sign agrees with subtraction" { + let sizes : Array[Int] = [1, 2, 3, 40, 60, 100] + for si in 0.. @bigint.parse_bigint(s)) is Failure::Failure(_), + ) + } + let bad16 : Array[String] = [ + "", "-", "g", "0x5", " f", "f ", "+f", "f_f", "--f", + ] + for s in bad16 { + assert_true( + @test.expect_error(() => @bigint.parse_bigint(s, base=16)) + is Failure::Failure(_), + ) + } + inspect(@bigint.parse_bigint("0005"), content="5") + inspect(@bigint.parse_bigint("-0"), content="0") + inspect(@bigint.parse_bigint("-0000"), content="0") + inspect(@bigint.parse_bigint("00ff", base=16), content="255") + inspect(@bigint.parse_bigint("-00FF", base=16), content="-255") + inspect(@bigint.parse_bigint("-0000", base=16), content="0") + inspect(@bigint.parse_bigint("z", base=36), content="35") + inspect(@bigint.parse_bigint("-Z", base=36), content="-35") + inspect(@bigint.parse_bigint("0007", base=8), content="7") + inspect(@bigint.parse_bigint("-101", base=2), content="-5") +} diff --git a/bigint/to_string_radix_bench_test.mbt b/bigint/to_string_radix_bench_test.mbt new file mode 100644 index 0000000000..7c0f127fc0 --- /dev/null +++ b/bigint/to_string_radix_bench_test.mbt @@ -0,0 +1,38 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +fn radix_bench_value() -> @bigint.BigInt { + // ~4000 bits + (@bigint.BigInt::from_string("7") << 4000) + + @bigint.BigInt::from_string("12345678901234567890") +} + +///| +test "bench bigint to_string radix=7 (4000 bits)" (it : @bench.T) { + let v = radix_bench_value() + it.bench(fn() { it.keep(v.to_string(radix=7)) }) +} + +///| +test "bench bigint to_string radix=36 (4000 bits)" (it : @bench.T) { + let v = radix_bench_value() + it.bench(fn() { it.keep(v.to_string(radix=36)) }) +} + +///| +test "bench bigint to_string radix=10 reference (4000 bits)" (it : @bench.T) { + let v = radix_bench_value() + it.bench(fn() { it.keep(v.to_string()) }) +} diff --git a/bigint/truncation_regression_test.mbt b/bigint/truncation_regression_test.mbt new file mode 100644 index 0000000000..a6649057ab --- /dev/null +++ b/bigint/truncation_regression_test.mbt @@ -0,0 +1,53 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Regression tests for moonbitlang/core#4050 — the non-js +// `to_uint`/`to_uint64` (and hence `to_int`/`to_int64`) added one +// modulus and read sign-magnitude limbs, which is wrong whenever the +// sum is still negative, i.e. for negative values with |x| > 2^32 +// (resp. 2^64) — already -(2^32 + 1) was mishandled; only exact +// multiples of the modulus and scattered coincidental residues came out +// right. js was already correct via `BigInt.asUintN`. + +///| +test "to_uint truncates large negatives to two's complement" { + // the failure region starts right past one modulus: + // x mod 2^32 for x = -(2^32 + 1) is 2^32 - 1 + inspect((-(1N << 32) - 1N).to_uint(), content="4294967295") + // x mod 2^32 for x = -(2^33 + 5) is 2^32 - 5 (js oracle: BigInt.asUintN) + inspect((-(1N << 33) - 5N).to_uint(), content="4294967291") + inspect((-(1N << 34)).to_uint(), content="0") + inspect((-(1N << 33) - 4294967295N).to_uint(), content="1") + inspect((-(1N << 80) - 5N).to_uint(), content="4294967291") + // reinterpretation must follow + inspect((-(1N << 33) - 5N).to_int(), content="-5") + // still-correct cases within one modulus + inspect((-5N).to_uint(), content="4294967291") + inspect((-4294967296N).to_uint(), content="0") +} + +///| +test "to_uint64 truncates large negatives to two's complement" { + // the failure region starts right past one modulus: + // y mod 2^64 for y = -(2^64 + 1) is 2^64 - 1 + inspect((-(1N << 64) - 1N).to_uint64(), content="18446744073709551615") + // y mod 2^64 for y = -(2^65 + 5) is 2^64 - 5 + inspect((-(1N << 65) - 5N).to_uint64(), content="18446744073709551611") + inspect((-(1N << 66)).to_uint64(), content="0") + inspect((-(1N << 80) - 5N).to_uint64(), content="18446744073709551611") + inspect((-(1N << 65) - 5N).to_int64(), content="-5") + // still-correct cases within one modulus + inspect((-5N).to_uint64(), content="18446744073709551611") + inspect((-(1N << 64)).to_uint64(), content="0") +} diff --git a/bool/bool_test.mbt b/bool/bool_test.mbt index 469f42245b..0c0e4a20d3 100644 --- a/bool/bool_test.mbt +++ b/bool/bool_test.mbt @@ -33,8 +33,8 @@ test "Bool hash function with struct" { // type MyInt Int derive(Show) this does not work // let m : Map[TestHash, Char] = {} let m = Map([]) - m[{ flag: true, x: 1 }] = '3' - m[{ flag: false, x: 1 }] = '3' + m[{ flag: true, x: 1, }] = '3' + m[{ flag: false, x: 1, }] = '3' debug_inspect( m, content=( diff --git a/buffer/README.mbt.md b/buffer/README.mbt.md index 390ef2be8f..8af74d220b 100644 --- a/buffer/README.mbt.md +++ b/buffer/README.mbt.md @@ -9,9 +9,9 @@ Create an empty buffer, optionally with a capacity hint to reduce reallocations: ```mbt check ///| test { - let buf = Buffer() + let buf = @buffer.Buffer() @test.assert_eq(buf.is_empty(), true) - let buf2 = Buffer(size_hint=1024) + let buf2 = @buffer.Buffer(size_hint=1024) @test.assert_eq(buf2.length(), 0) } ``` @@ -37,17 +37,17 @@ Write individual bytes, byte slices, or byte iterators: ```mbt check ///| test { - let buf = Buffer() + let buf = @buffer.Buffer() buf.write_byte(b'H') buf.write_byte(b'i') buf.write_bytes(b" world") inspect(buf.to_bytes(), content="b\"Hi world\"") // write a sub-range via BytesView - let buf2 = Buffer() + let buf2 = @buffer.Buffer() buf2.write_bytesview(b"Hello"[0:2]) inspect(buf2.to_bytes(), content="b\"He\"") // write from an iterator - let buf3 = Buffer() + let buf3 = @buffer.Buffer() buf3.write_iter(b"ok".iter()) inspect(buf3.to_bytes(), content="b\"ok\"") } @@ -61,7 +61,7 @@ All integer writes come in `_be` (big-endian) and `_le` (little-endian) variants ///| test { // 32-bit signed/unsigned - let buf = Buffer() + let buf = @buffer.Buffer() buf.write_int_be(0x01020304) inspect( buf.to_bytes(), @@ -69,7 +69,7 @@ test { #|b"\x01\x02\x03\x04" ), ) - let buf2 = Buffer() + let buf2 = @buffer.Buffer() buf2.write_int_le(0x01020304) inspect( buf2.to_bytes(), @@ -78,7 +78,7 @@ test { ), ) // unsigned 32-bit - let buf3 = Buffer() + let buf3 = @buffer.Buffer() buf3.write_uint_be(0xAABBU) inspect( buf3.to_bytes(), @@ -95,7 +95,7 @@ test { ///| test { // 16-bit signed - let buf = Buffer() + let buf = @buffer.Buffer() buf.write_int16_be((0x0102 : Int16)) buf.write_int16_le((0x0102 : Int16)) inspect( @@ -105,7 +105,7 @@ test { ), ) // 16-bit unsigned - let buf2 = Buffer() + let buf2 = @buffer.Buffer() buf2.write_uint16_be(Int::to_uint16(0x00AB)) inspect( buf2.to_bytes(), @@ -114,7 +114,7 @@ test { ), ) // 64-bit signed - let buf3 = Buffer() + let buf3 = @buffer.Buffer() buf3.write_int64_be(0x0102030405060708L) inspect( buf3.to_bytes(), @@ -123,7 +123,7 @@ test { ), ) // 64-bit unsigned - let buf4 = Buffer() + let buf4 = @buffer.Buffer() buf4.write_uint64_le(0xAABBUL) inspect( buf4.to_bytes(), @@ -139,10 +139,10 @@ test { ```mbt check ///| test { - let buf = Buffer() + let buf = @buffer.Buffer() buf.write_float_be(1.0) @test.assert_eq(buf.length(), 4) - let buf2 = Buffer() + let buf2 = @buffer.Buffer() buf2.write_double_le(1.0) @test.assert_eq(buf2.length(), 8) } @@ -156,16 +156,16 @@ Write individual characters or entire strings as UTF-8 or UTF-16 (LE/BE): ///| test { // UTF-8 - let buf = Buffer() + let buf = @buffer.Buffer() buf.write_char_utf8('A') buf.write_string_utf8("BC") inspect(buf.to_bytes(), content="b\"ABC\"") // UTF-16 little-endian - let buf2 = Buffer() + let buf2 = @buffer.Buffer() buf2.write_char_utf16le('A') inspect(buf2.to_bytes(), content="b\"A\\x00\"") // UTF-16 big-endian - let buf3 = Buffer() + let buf3 = @buffer.Buffer() buf3.write_string_utf16be("AB") inspect(buf3.to_bytes(), content="b\"\\x00A\\x00B\"") } @@ -178,7 +178,7 @@ Write any type that implements `Show` as UTF-8 bytes via `write_utf8()`. The cur ```mbt check ///| test { - let buf = Buffer() + let buf = @buffer.Buffer() buf.write_utf8(42) inspect(buf.to_bytes(), content="b\"42\"") } @@ -191,10 +191,10 @@ Write integers in LEB128 variable-length encoding. Supported for `Int`, `UInt`, ```mbt check ///| test { - let buf = Buffer() + let buf = @buffer.Buffer() buf.write_leb128(624485) inspect(buf.to_bytes(), content="b\"\\xe5\\x8e&\"") - let buf2 = Buffer() + let buf2 = @buffer.Buffer() buf2.write_leb128(300UL) inspect(buf2.to_bytes(), content="b\"\\xac\\x02\"") } @@ -207,7 +207,7 @@ test { ```mbt check ///| test { - let buf = Buffer() + let buf = @buffer.Buffer() buf.write_bytes(b"hello") let bytes = buf.to_bytes() inspect(bytes, content="b\"hello\"") @@ -223,7 +223,7 @@ test { ```mbt check ///| test { - let buf = Buffer() + let buf = @buffer.Buffer() buf.write_bytes(b"data") @test.assert_eq(buf.length(), 4) buf.reset() @@ -238,7 +238,7 @@ Pre-allocate capacity to minimize reallocations when the final size is known: ```mbt check ///| test { - let buf = Buffer(size_hint=1024) + let buf = @buffer.Buffer(size_hint=1024) for i in 0..<100 { buf.write_int_le(i) } diff --git a/buffer/buffer.mbt b/buffer/buffer.mbt index 33afa765d4..21e8522fde 100644 --- a/buffer/buffer.mbt +++ b/buffer/buffer.mbt @@ -94,7 +94,7 @@ fn Buffer::grow(self : Buffer, required : Int) -> Unit { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// buf.write_string_utf16le("Test") /// inspect(buf.length(), content="8") // each char takes 2 bytes in UTF-16 /// } @@ -117,7 +117,7 @@ pub fn Buffer::length(self : Buffer) -> Int { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// inspect(buf.is_empty(), content="true") /// buf.write_string_utf16le("test") /// inspect(buf.is_empty(), content="false") @@ -142,7 +142,7 @@ pub fn Buffer::is_empty(self : Buffer) -> Bool { /// /// ```mbt check /// test { -/// let buf = Buffer(size_hint=10) +/// let buf = @buffer.Buffer(size_hint=10) /// inspect(buf.length(), content="0") /// buf.write_string_utf16le("test") /// inspect(buf.length(), content="8") @@ -168,7 +168,7 @@ pub fn new(size_hint? : Int = 0) -> Buffer { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// buf.write_string_utf16le("test") /// inspect(buf.length(), content="8") /// } @@ -176,7 +176,7 @@ pub fn new(size_hint? : Int = 0) -> Buffer { pub fn Buffer::Buffer(size_hint? : Int = 0) -> Buffer { let initial = if size_hint < 1 { 1 } else { size_hint } let data = FixedArray::make(initial, b'\x00') - { data, len: 0 } + { data, len: 0, } } ///| @@ -195,12 +195,16 @@ pub fn from_bytes(bytes : BytesView) -> Buffer { /// Create a buffer from an array. pub fn from_array(arr : ArrayView[Byte]) -> Buffer { let buf = Buffer(size_hint=arr.length()) + // Inline write_byte because the exact capacity is known. + // SAFETY: the buffer was sized for `arr.length()` bytes, so every index + // below stays within `data`. + let data = buf.data + let mut len = buf.len for byte in arr { - // Inline write_byte because the exact capacity is known. - // SAFETY: known array size - buf.data[buf.len] = byte - buf.len += 1 + data.unsafe_set(len, byte) + len += 1 } + buf.len = len buf } @@ -216,7 +220,9 @@ pub fn from_iter(iter : Iter[Byte]) -> Buffer { } else { capacity } - buf.data[buf.len] = byte + // SAFETY: `capacity` tracks `data.length()` and the branch above ensures + // `len < capacity`. + buf.data.unsafe_set(buf.len, byte) buf.len += 1 continue capacity } @@ -260,7 +266,7 @@ pub impl Logger for Buffer with fn write_string(self, value) { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// buf.write_uint64_be(0xAABBCCDD11223344) /// // Bytes are written in big-endian order /// inspect( @@ -275,15 +281,17 @@ pub fn Buffer::write_uint64_be(self : Buffer, value : UInt64) -> Unit { if self.data.length() - self.len < 8 { self.grow(self.len + 8) } + // SAFETY: the guard above reserves 8 bytes at `len`. + let data = self.data let offset = self.len - self.data[offset] = (value >> 56).to_byte() - self.data[offset + 1] = (value >> 48).to_byte() - self.data[offset + 2] = (value >> 40).to_byte() - self.data[offset + 3] = (value >> 32).to_byte() - self.data[offset + 4] = (value >> 24).to_byte() - self.data[offset + 5] = (value >> 16).to_byte() - self.data[offset + 6] = (value >> 8).to_byte() - self.data[offset + 7] = value.to_byte() + data.unsafe_set(offset, (value >> 56).to_byte()) + data.unsafe_set(offset + 1, (value >> 48).to_byte()) + data.unsafe_set(offset + 2, (value >> 40).to_byte()) + data.unsafe_set(offset + 3, (value >> 32).to_byte()) + data.unsafe_set(offset + 4, (value >> 24).to_byte()) + data.unsafe_set(offset + 5, (value >> 16).to_byte()) + data.unsafe_set(offset + 6, (value >> 8).to_byte()) + data.unsafe_set(offset + 7, value.to_byte()) self.len += 8 } @@ -300,7 +308,7 @@ pub fn Buffer::write_uint64_be(self : Buffer, value : UInt64) -> Unit { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// buf.write_uint64_le(0x0123456789ABCDEF) /// inspect( /// buf.contents(), @@ -314,15 +322,17 @@ pub fn Buffer::write_uint64_le(self : Buffer, value : UInt64) -> Unit { if self.data.length() - self.len < 8 { self.grow(self.len + 8) } + // SAFETY: the guard above reserves 8 bytes at `len`. + let data = self.data let offset = self.len - self.data[offset] = value.to_byte() - self.data[offset + 1] = (value >> 8).to_byte() - self.data[offset + 2] = (value >> 16).to_byte() - self.data[offset + 3] = (value >> 24).to_byte() - self.data[offset + 4] = (value >> 32).to_byte() - self.data[offset + 5] = (value >> 40).to_byte() - self.data[offset + 6] = (value >> 48).to_byte() - self.data[offset + 7] = (value >> 56).to_byte() + data.unsafe_set(offset, value.to_byte()) + data.unsafe_set(offset + 1, (value >> 8).to_byte()) + data.unsafe_set(offset + 2, (value >> 16).to_byte()) + data.unsafe_set(offset + 3, (value >> 24).to_byte()) + data.unsafe_set(offset + 4, (value >> 32).to_byte()) + data.unsafe_set(offset + 5, (value >> 40).to_byte()) + data.unsafe_set(offset + 6, (value >> 48).to_byte()) + data.unsafe_set(offset + 7, (value >> 56).to_byte()) self.len += 8 } @@ -339,7 +349,7 @@ pub fn Buffer::write_uint64_le(self : Buffer, value : UInt64) -> Unit { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// buf.write_int64_be(0x0102030405060708L) /// inspect( /// buf.contents(), @@ -365,7 +375,7 @@ pub fn Buffer::write_int64_be(self : Buffer, value : Int64) -> Unit { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// buf.write_int64_le(-1L) /// inspect( /// buf.contents(), @@ -392,7 +402,7 @@ pub fn Buffer::write_int64_le(self : Buffer, value : Int64) -> Unit { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// buf.write_uint_be(0x12345678) /// inspect( /// buf.contents(), @@ -406,11 +416,13 @@ pub fn Buffer::write_uint_be(self : Buffer, value : UInt) -> Unit { if self.data.length() - self.len < 4 { self.grow(self.len + 4) } + // SAFETY: the guard above reserves 4 bytes at `len`. + let data = self.data let offset = self.len - self.data[offset] = (value >> 24).to_byte() - self.data[offset + 1] = (value >> 16).to_byte() - self.data[offset + 2] = (value >> 8).to_byte() - self.data[offset + 3] = value.to_byte() + data.unsafe_set(offset, (value >> 24).to_byte()) + data.unsafe_set(offset + 1, (value >> 16).to_byte()) + data.unsafe_set(offset + 2, (value >> 8).to_byte()) + data.unsafe_set(offset + 3, value.to_byte()) self.len += 4 } @@ -428,7 +440,7 @@ pub fn Buffer::write_uint_be(self : Buffer, value : UInt) -> Unit { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// buf.write_uint_le(0x12345678) /// inspect( /// buf.contents(), @@ -442,11 +454,13 @@ pub fn Buffer::write_uint_le(self : Buffer, value : UInt) -> Unit { if self.data.length() - self.len < 4 { self.grow(self.len + 4) } + // SAFETY: the guard above reserves 4 bytes at `len`. + let data = self.data let offset = self.len - self.data[offset] = value.to_byte() - self.data[offset + 1] = (value >> 8).to_byte() - self.data[offset + 2] = (value >> 16).to_byte() - self.data[offset + 3] = (value >> 24).to_byte() + data.unsafe_set(offset, value.to_byte()) + data.unsafe_set(offset + 1, (value >> 8).to_byte()) + data.unsafe_set(offset + 2, (value >> 16).to_byte()) + data.unsafe_set(offset + 3, (value >> 24).to_byte()) self.len += 4 } @@ -463,7 +477,7 @@ pub fn Buffer::write_uint_le(self : Buffer, value : UInt) -> Unit { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// buf.write_int_be(0x12345678) /// inspect( /// buf.contents(), @@ -491,7 +505,7 @@ pub fn Buffer::write_int_be(self : Buffer, value : Int) -> Unit { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// buf.write_int_le(-1) /// inspect(buf.contents(), content="b\"\\xff\\xff\\xff\\xff\"") /// } @@ -513,7 +527,7 @@ pub fn Buffer::write_int_le(self : Buffer, value : Int) -> Unit { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// buf.write_uint16_be(0x1234) /// inspect( /// buf.contents(), @@ -527,9 +541,11 @@ pub fn Buffer::write_uint16_be(self : Buffer, value : UInt16) -> Unit { if self.data.length() - self.len < 2 { self.grow(self.len + 2) } + // SAFETY: the guard above reserves 2 bytes at `len`. + let data = self.data let offset = self.len - self.data[offset] = (value.to_int() >> 8).to_byte() - self.data[offset + 1] = value.to_byte() + data.unsafe_set(offset, (value.to_int() >> 8).to_byte()) + data.unsafe_set(offset + 1, value.to_byte()) self.len += 2 } @@ -547,7 +563,7 @@ pub fn Buffer::write_uint16_be(self : Buffer, value : UInt16) -> Unit { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// buf.write_uint16_le(0x1234) /// inspect( /// buf.contents(), @@ -561,9 +577,11 @@ pub fn Buffer::write_uint16_le(self : Buffer, value : UInt16) -> Unit { if self.data.length() - self.len < 2 { self.grow(self.len + 2) } + // SAFETY: the guard above reserves 2 bytes at `len`. + let data = self.data let offset = self.len - self.data[offset] = value.to_byte() - self.data[offset + 1] = (value.to_int() >> 8).to_byte() + data.unsafe_set(offset, value.to_byte()) + data.unsafe_set(offset + 1, (value.to_int() >> 8).to_byte()) self.len += 2 } @@ -580,7 +598,7 @@ pub fn Buffer::write_uint16_le(self : Buffer, value : UInt16) -> Unit { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// buf.write_int16_be(0x1234) /// inspect( /// buf.contents(), @@ -594,9 +612,11 @@ pub fn Buffer::write_int16_be(self : Buffer, value : Int16) -> Unit { if self.data.length() - self.len < 2 { self.grow(self.len + 2) } + // SAFETY: the guard above reserves 2 bytes at `len`. + let data = self.data let offset = self.len - self.data[offset] = (value.to_int() >> 8).to_byte() - self.data[offset + 1] = value.to_byte() + data.unsafe_set(offset, (value.to_int() >> 8).to_byte()) + data.unsafe_set(offset + 1, value.to_byte()) self.len += 2 } @@ -613,7 +633,7 @@ pub fn Buffer::write_int16_be(self : Buffer, value : Int16) -> Unit { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// buf.write_int16_le(-1) /// inspect( /// buf.contents(), @@ -627,9 +647,11 @@ pub fn Buffer::write_int16_le(self : Buffer, value : Int16) -> Unit { if self.data.length() - self.len < 2 { self.grow(self.len + 2) } + // SAFETY: the guard above reserves 2 bytes at `len`. + let data = self.data let offset = self.len - self.data[offset] = value.to_byte() - self.data[offset + 1] = (value.to_int() >> 8).to_byte() + data.unsafe_set(offset, value.to_byte()) + data.unsafe_set(offset + 1, (value.to_int() >> 8).to_byte()) self.len += 2 } @@ -646,7 +668,7 @@ pub fn Buffer::write_int16_le(self : Buffer, value : Int16) -> Unit { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// buf.write_double_be(1.0) /// inspect( /// buf.contents(), @@ -673,7 +695,7 @@ pub fn Buffer::write_double_be(self : Buffer, value : Double) -> Unit { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// buf.write_double_le(3.14) /// inspect( /// buf.contents(), @@ -701,7 +723,7 @@ pub fn Buffer::write_double_le(self : Buffer, value : Double) -> Unit { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// buf.write_float_be(3.14) /// // In big-endian format, 3.14 is represented as [0x40, 0x48, 0xF5, 0xC3] /// inspect( @@ -729,7 +751,7 @@ pub fn Buffer::write_float_be(self : Buffer, value : Float) -> Unit { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// buf.write_float_le(3.14) /// // The bytes are written in little-endian format /// inspect( @@ -773,7 +795,7 @@ pub fn Buffer::write_object(self : Buffer, value : &Show) -> Unit { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// buf.write_utf8(42) /// inspect( /// buf.contents(), @@ -800,7 +822,7 @@ pub fn[T : Show] Buffer::write_utf8(self : Buffer, value : T) -> Unit { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// buf.write_bytes(b"Test") /// inspect( /// buf.contents(), @@ -826,7 +848,7 @@ pub fn Buffer::write_bytes(self : Buffer, value : BytesView) -> Unit { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// let view = b"Test"[1:3] /// buf.write_bytesview(view) /// inspect( @@ -861,34 +883,44 @@ pub fn Buffer::write_char_utf8(buf : Self, value : Char) -> Unit { if buf.len >= buf.data.length() { buf.grow(buf.len + 1) } - buf.data[buf.len] = ((code & 0x7F) | 0x00).to_byte() + // SAFETY: the guard above reserves 1 byte at `len`. + buf.data.unsafe_set(buf.len, ((code & 0x7F) | 0x00).to_byte()) buf.len += 1 } _..<0x0800 => { if buf.data.length() - buf.len < 2 { buf.grow(buf.len + 2) } - buf.data[buf.len] = (((code >> 6) & 0x1F) | 0xC0).to_byte() - buf.data[buf.len + 1] = ((code & 0x3F) | 0x80).to_byte() + // SAFETY: the guard above reserves 2 bytes at `len`. + let data = buf.data + let offset = buf.len + data.unsafe_set(offset, (((code >> 6) & 0x1F) | 0xC0).to_byte()) + data.unsafe_set(offset + 1, ((code & 0x3F) | 0x80).to_byte()) buf.len += 2 } _..<0x010000 => { if buf.data.length() - buf.len < 3 { buf.grow(buf.len + 3) } - buf.data[buf.len] = (((code >> 12) & 0x0F) | 0xE0).to_byte() - buf.data[buf.len + 1] = (((code >> 6) & 0x3F) | 0x80).to_byte() - buf.data[buf.len + 2] = ((code & 0x3F) | 0x80).to_byte() + // SAFETY: the guard above reserves 3 bytes at `len`. + let data = buf.data + let offset = buf.len + data.unsafe_set(offset, (((code >> 12) & 0x0F) | 0xE0).to_byte()) + data.unsafe_set(offset + 1, (((code >> 6) & 0x3F) | 0x80).to_byte()) + data.unsafe_set(offset + 2, ((code & 0x3F) | 0x80).to_byte()) buf.len += 3 } _..<0x110000 => { if buf.data.length() - buf.len < 4 { buf.grow(buf.len + 4) } - buf.data[buf.len] = (((code >> 18) & 0x07) | 0xF0).to_byte() - buf.data[buf.len + 1] = (((code >> 12) & 0x3F) | 0x80).to_byte() - buf.data[buf.len + 2] = (((code >> 6) & 0x3F) | 0x80).to_byte() - buf.data[buf.len + 3] = ((code & 0x3F) | 0x80).to_byte() + // SAFETY: the guard above reserves 4 bytes at `len`. + let data = buf.data + let offset = buf.len + data.unsafe_set(offset, (((code >> 18) & 0x07) | 0xF0).to_byte()) + data.unsafe_set(offset + 1, (((code >> 12) & 0x3F) | 0x80).to_byte()) + data.unsafe_set(offset + 2, (((code >> 6) & 0x3F) | 0x80).to_byte()) + data.unsafe_set(offset + 3, ((code & 0x3F) | 0x80).to_byte()) buf.len += 4 } _ => abort("Char out of range") @@ -903,8 +935,11 @@ pub fn Buffer::write_char_utf16le(buf : Self, value : Char) -> Unit { if buf.data.length() - buf.len < 2 { buf.grow(buf.len + 2) } - buf.data[buf.len + 0] = (code & 0xFF).to_byte() - buf.data[buf.len + 1] = (code >> 8).to_byte() + // SAFETY: the guard above reserves 2 bytes at `len`. + let data = buf.data + let offset = buf.len + data.unsafe_set(offset, (code & 0xFF).to_byte()) + data.unsafe_set(offset + 1, (code >> 8).to_byte()) buf.len += 2 } else if code < 0x110000 { let cp = code - 0x10000 @@ -913,10 +948,13 @@ pub fn Buffer::write_char_utf16le(buf : Self, value : Char) -> Unit { if buf.data.length() - buf.len < 4 { buf.grow(buf.len + 4) } - buf.data[buf.len + 0] = (high & 0xFF).to_byte() - buf.data[buf.len + 1] = (high >> 8).to_byte() - buf.data[buf.len + 2] = (low & 0xFF).to_byte() - buf.data[buf.len + 3] = (low >> 8).to_byte() + // SAFETY: the guard above reserves 4 bytes at `len`. + let data = buf.data + let offset = buf.len + data.unsafe_set(offset, (high & 0xFF).to_byte()) + data.unsafe_set(offset + 1, (high >> 8).to_byte()) + data.unsafe_set(offset + 2, (low & 0xFF).to_byte()) + data.unsafe_set(offset + 3, (low >> 8).to_byte()) buf.len += 4 } else { abort("Char out of range") @@ -931,8 +969,11 @@ pub fn Buffer::write_char_utf16be(buf : Self, value : Char) -> Unit { if buf.data.length() - buf.len < 2 { buf.grow(buf.len + 2) } - buf.data[buf.len + 0] = (code >> 8).to_byte() - buf.data[buf.len + 1] = (code & 0xFF).to_byte() + // SAFETY: the guard above reserves 2 bytes at `len`. + let data = buf.data + let offset = buf.len + data.unsafe_set(offset, (code >> 8).to_byte()) + data.unsafe_set(offset + 1, (code & 0xFF).to_byte()) buf.len += 2 } else if code < 0x110000 { if buf.data.length() - buf.len < 4 { @@ -941,10 +982,13 @@ pub fn Buffer::write_char_utf16be(buf : Self, value : Char) -> Unit { let cp = code - 0x10000 let high = (cp >> 10) | 0xD800 let low = (cp & 0x3FF) | 0xDC00 - buf.data[buf.len + 0] = (high >> 8).to_byte() - buf.data[buf.len + 1] = (high & 0xFF).to_byte() - buf.data[buf.len + 2] = (low >> 8).to_byte() - buf.data[buf.len + 3] = (low & 0xFF).to_byte() + // SAFETY: the guard above reserves 4 bytes at `len`. + let data = buf.data + let offset = buf.len + data.unsafe_set(offset, (high >> 8).to_byte()) + data.unsafe_set(offset + 1, (high & 0xFF).to_byte()) + data.unsafe_set(offset + 2, (low >> 8).to_byte()) + data.unsafe_set(offset + 3, (low & 0xFF).to_byte()) buf.len += 4 } else { abort("Char out of range") @@ -963,7 +1007,7 @@ pub fn Buffer::write_char_utf16be(buf : Self, value : Char) -> Unit { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// buf.write_string_utf8("Hi") /// inspect(buf.contents().length(), content="2") /// } @@ -986,7 +1030,7 @@ pub fn Buffer::write_string_utf8(buf : Self, string : StringView) -> Unit { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// buf.write_string_utf16le("A") /// inspect(buf.contents().length(), content="2") /// } @@ -998,10 +1042,13 @@ pub fn Buffer::write_string_utf16le(buf : Self, string : StringView) -> Unit { if required > buf.data.length() || required < buf.len { buf.grow(required) } + // SAFETY: the guard above reserves `len * 2` bytes at `buf.len`, and + // `code_units()` yields exactly `len` code units. + let data = buf.data for code_unit in string.code_units(); j = buf.len { let c = code_unit.to_int().reinterpret_as_uint() - buf.data[j] = (c & 0xff).to_byte() - buf.data[j + 1] = (c >> 8).to_byte() + data.unsafe_set(j, (c & 0xff).to_byte()) + data.unsafe_set(j + 1, (c >> 8).to_byte()) continue j + 2 } buf.len += len * 2 @@ -1019,7 +1066,7 @@ pub fn Buffer::write_string_utf16le(buf : Self, string : StringView) -> Unit { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// buf.write_string_utf16be("A") /// inspect(buf.contents().length(), content="2") /// } @@ -1030,10 +1077,13 @@ pub fn Buffer::write_string_utf16be(buf : Self, string : StringView) -> Unit { if required > buf.data.length() || required < buf.len { buf.grow(required) } + // SAFETY: the guard above reserves `len * 2` bytes at `buf.len`, and + // `code_units()` yields exactly `len` code units. + let data = buf.data for code_unit in string.code_units(); j = buf.len { let c = code_unit.to_int().reinterpret_as_uint() - buf.data[j + 1] = (c & 0xff).to_byte() - buf.data[j] = (c >> 8).to_byte() + data.unsafe_set(j + 1, (c & 0xff).to_byte()) + data.unsafe_set(j, (c >> 8).to_byte()) continue j + 2 } buf.len += len * 2 @@ -1043,11 +1093,7 @@ pub fn Buffer::write_string_utf16be(buf : Self, string : StringView) -> Unit { /// Parameters: /// /// * `self` : The buffer to write to. -/// * `str` : The source string from which the substring will be taken. -/// * `offset` : The starting position in the source string (inclusive). Must be -/// non-negative. -/// * `count` : The number of characters to write. Must be non-negative and -/// `offset + count` must not exceed the length of the source string. +/// * `value` : The string view to be written, encoded as UTF-16LE bytes. pub impl Logger for Buffer with fn write_view(self : Buffer, value : StringView) -> Unit { let required = self.len + value.length() * 2 if required > self.data.length() || required < self.len { @@ -1091,7 +1137,7 @@ pub impl Logger for Buffer with fn write_char(self : Buffer, value : Char) -> Un /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// buf.write_byte(b'\x41') /// inspect( /// buf.contents(), @@ -1105,7 +1151,9 @@ pub fn Buffer::write_byte(self : Buffer, value : Byte) -> Unit { if self.len >= self.data.length() { self.grow(self.len + 1) } - self.data[self.len] = value + // SAFETY: the guard above restores `len < data.length()`, and the buffer + // invariant keeps `len >= 0`. + self.data.unsafe_set(self.len, value) self.len += 1 } @@ -1121,7 +1169,7 @@ pub fn Buffer::write_byte(self : Buffer, value : Byte) -> Unit { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// let bytes = b"Hello" /// buf.write_iter(bytes.iter()) /// inspect( @@ -1150,7 +1198,7 @@ pub fn Buffer::write_iter(self : Buffer, iter : Iter[Byte]) -> Unit { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// buf.write_string_utf16le("Hello") /// inspect(buf.length(), content="10") /// buf.reset() @@ -1177,7 +1225,7 @@ pub fn Buffer::reset(self : Buffer) -> Unit { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// buf.write_string_utf16le("Test") /// let bytes = buf.to_bytes() /// inspect(bytes.length(), content="8") //utf16 @@ -1198,7 +1246,7 @@ pub fn Buffer::to_bytes(self : Buffer) -> Bytes { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// buf.write_byte(b'A') /// let v = buf.view() /// inspect(v.length(), content="1") diff --git a/buffer/buffer_test.mbt b/buffer/buffer_test.mbt index 9f57e3b512..832c05c30a 100644 --- a/buffer/buffer_test.mbt +++ b/buffer/buffer_test.mbt @@ -28,10 +28,10 @@ test "create buffer from array" { ///| test "create buffer from iterator" { - let iter = [b'5', b'\x00', b'6', b'\x00', b'7', b'\x00'].iter() + let iter = [|b'5', b'\x00', b'6', b'\x00', b'7', b'\x00'|] let buf = @buffer.from_iter(iter) inspect(buf, content="567") - let iter = [].iter() + let iter = [||] let buf = @buffer.from_iter(iter) inspect(buf, content="") let iter = b"0\x00".iter() @@ -41,7 +41,7 @@ test "create buffer from iterator" { ///| test "length method" { - let buf = Buffer(size_hint=100) + let buf = @buffer.Buffer(size_hint=100) inspect(buf.length(), content="0") buf.write_string_utf16le("Test") inspect(buf.length(), content="8") @@ -49,7 +49,7 @@ test "length method" { ///| test "is_empty method" { - let buf = Buffer(size_hint=100) + let buf = @buffer.Buffer(size_hint=100) inspect(buf.is_empty(), content="true") buf.write_string_utf16le("Test") inspect(buf.is_empty(), content="false") @@ -57,14 +57,14 @@ test "is_empty method" { ///| test "expect method with matching content" { - let buf = Buffer(size_hint=100) + let buf = @buffer.Buffer(size_hint=100) buf.write_string_utf16le("Test") inspect(buf, content="Test") } ///| test "buffer grows beyond its initial capacity" { - let buf = Buffer(size_hint=10) + let buf = @buffer.Buffer(size_hint=10) buf.write_string_utf16le( "This is a test string that is longer than the initial capacity", ) @@ -73,14 +73,14 @@ test "buffer grows beyond its initial capacity" { ///| test "write UTF-16LE string view" { - let buf = Buffer(size_hint=10) + let buf = @buffer.Buffer(size_hint=10) buf.write_string_utf16le("Hello, World!"[7:12]) inspect(buf, content="World") } ///| test "write_byte method" { - let buf = Buffer(size_hint=10) + let buf = @buffer.Buffer(size_hint=10) buf.write_byte(b'A') buf.write_byte(b'\x00') inspect(buf, content="A") @@ -88,7 +88,7 @@ test "write_byte method" { ///| test "write_utf8 method" { - let buf = Buffer() + let buf = @buffer.Buffer() buf.write_utf8(42) buf.write_utf8('!') buf.write_utf8("hi"[:]) @@ -102,7 +102,7 @@ test "write_utf8 method" { ///| test "to_bytes method" { - let buf = Buffer(size_hint=10) + let buf = @buffer.Buffer(size_hint=10) buf.write_string_utf16le("Test") let bytes = buf.to_bytes() inspect(bytes.length(), content="8") // Each character in "Test" is 2 bytes @@ -110,7 +110,7 @@ test "to_bytes method" { ///| test "write_bytes" { - let buf = Buffer(size_hint=4) + let buf = @buffer.Buffer(size_hint=4) buf.write_bytes(b"1\x002\x003\x004\x00") buf.write_bytes(b"5\x006\x007\x008\x00") inspect(buf, content="12345678") @@ -118,7 +118,7 @@ test "write_bytes" { ///| test "write_uint64_le method" { - let buf = Buffer(size_hint=16) + let buf = @buffer.Buffer(size_hint=16) buf.write_uint64_le(0xdeadbeefaabbccdd) buf.write_uint64_le(0xfacefeedaabbccdd) inspect( @@ -131,7 +131,7 @@ test "write_uint64_le method" { ///| test "write_uint64_be method" { - let buf = Buffer(size_hint=16) + let buf = @buffer.Buffer(size_hint=16) buf.write_uint64_be(0xdeadbeefaabbccdd) buf.write_uint64_be(0xfacefeedaabbccdd) inspect( @@ -144,7 +144,7 @@ test "write_uint64_be method" { ///| test "write_int64_le method" { - let buf = Buffer(size_hint=16) + let buf = @buffer.Buffer(size_hint=16) buf.write_int64_le(-2) buf.write_int64_le(0xdeadbeeffacefeed) inspect( @@ -157,7 +157,7 @@ test "write_int64_le method" { ///| test "write_int64_be method" { - let buf = Buffer(size_hint=16) + let buf = @buffer.Buffer(size_hint=16) buf.write_int64_be(-2) buf.write_int64_be(0xdeadbeef) inspect( @@ -170,7 +170,7 @@ test "write_int64_be method" { ///| test "write_uint_le method" { - let buf = Buffer(size_hint=8) + let buf = @buffer.Buffer(size_hint=8) buf.write_uint_le(0xdeadbeef) buf.write_uint_le(0xdeadc0de) inspect( @@ -183,7 +183,7 @@ test "write_uint_le method" { ///| test "write_uint_be method" { - let buf = Buffer(size_hint=8) + let buf = @buffer.Buffer(size_hint=8) buf.write_uint_be(0xdeadbeef) buf.write_uint_be(0xdeadc0de) inspect( @@ -196,7 +196,7 @@ test "write_uint_be method" { ///| test "write_int_le method" { - let buf = Buffer(size_hint=8) + let buf = @buffer.Buffer(size_hint=8) buf.write_int_le(-2) buf.write_int_le(0xdeadbeef) inspect( @@ -209,7 +209,7 @@ test "write_int_le method" { ///| test "write_int_be method" { - let buf = Buffer(size_hint=8) + let buf = @buffer.Buffer(size_hint=8) buf.write_int_be(-2) buf.write_int_be(0xdeadbeef) inspect( @@ -222,7 +222,7 @@ test "write_int_be method" { ///| test "write_string_utf16be and view" { - let buf = Buffer(size_hint=8) + let buf = @buffer.Buffer(size_hint=8) buf.write_string_utf16be("AZ") inspect( buf.to_bytes(), @@ -235,25 +235,25 @@ test "write_string_utf16be and view" { ///| test "panic Buffer::write_char_utf8 out of range" { - let buf = Buffer() + let buf = @buffer.Buffer() buf.write_char_utf8((0x110000).unsafe_to_char()) } ///| test "panic Buffer::write_char_utf16le out of range" { - let buf = Buffer() + let buf = @buffer.Buffer() buf.write_char_utf16le((0x110000).unsafe_to_char()) } ///| test "panic Buffer::write_char_utf16be out of range" { - let buf = Buffer() + let buf = @buffer.Buffer() buf.write_char_utf16be((0x110000).unsafe_to_char()) } ///| test "write_double_le method" { - let buf = Buffer(size_hint=16) + let buf = @buffer.Buffer(size_hint=16) buf.write_double_le(-2) buf.write_double_le(3.14) inspect( @@ -266,7 +266,7 @@ test "write_double_le method" { ///| test "write_double_be method" { - let buf = Buffer(size_hint=16) + let buf = @buffer.Buffer(size_hint=16) buf.write_double_be(-2) buf.write_double_be(3.14) inspect( @@ -279,7 +279,7 @@ test "write_double_be method" { ///| test "write_float_le method" { - let buf = Buffer(size_hint=8) + let buf = @buffer.Buffer(size_hint=8) buf.write_float_le(-2) buf.write_float_le(3.14) inspect( @@ -292,7 +292,7 @@ test "write_float_le method" { ///| test "write_float_be method" { - let buf = Buffer(size_hint=8) + let buf = @buffer.Buffer(size_hint=8) buf.write_float_be(-2) buf.write_float_be(3.14) inspect( @@ -306,7 +306,7 @@ test "write_float_be method" { ///| test "write_iter" { let bytes = b"hello" - let buf = Buffer() + let buf = @buffer.Buffer() buf.write_iter(bytes.iter()) inspect( buf.contents(), @@ -326,7 +326,7 @@ test "write_iter" { ///| test "write_bytesview" { - let buf = Buffer(size_hint=4) + let buf = @buffer.Buffer(size_hint=4) buf.write_bytesview(b"Test"[1:3]) inspect( buf.contents(), @@ -338,7 +338,7 @@ test "write_bytesview" { ///| test "write_stringview" { - let buf = Buffer() + let buf = @buffer.Buffer() buf.write_string_utf16le("hello"[1:3]) inspect( buf.contents(), @@ -350,7 +350,7 @@ test "write_stringview" { ///| test "write_char_utf8" { - let buf = Buffer(size_hint=10) + let buf = @buffer.Buffer(size_hint=10) buf.write_char_utf8('A') buf.write_char_utf8('α') buf.write_char_utf8('啊') @@ -365,7 +365,7 @@ test "write_char_utf8" { ///| test "write_char_utf16le" { - let buf = Buffer(size_hint=10) + let buf = @buffer.Buffer(size_hint=10) buf.write_char_utf16le('A') buf.write_char_utf16le('α') buf.write_char_utf16le('啊') @@ -380,7 +380,7 @@ test "write_char_utf16le" { ///| test "write_char_utf16be" { - let buf = Buffer(size_hint=10) + let buf = @buffer.Buffer(size_hint=10) buf.write_char_utf16be('A') buf.write_char_utf16be('α') buf.write_char_utf16be('啊') @@ -398,7 +398,7 @@ const BOM : Char = '\u{FEFF}' ///| test "write_bom_utf8" { - let buf = Buffer() + let buf = @buffer.Buffer() buf.write_char_utf8(BOM) inspect( buf.to_bytes(), @@ -410,7 +410,7 @@ test "write_bom_utf8" { ///| test "write_bom_utf16le" { - let buf = Buffer() + let buf = @buffer.Buffer() buf.write_char_utf16le(BOM) inspect( buf.to_bytes(), @@ -422,7 +422,7 @@ test "write_bom_utf16le" { ///| test "write_bom_utf16be" { - let buf = Buffer() + let buf = @buffer.Buffer() buf.write_char_utf16be(BOM) inspect( buf.to_bytes(), @@ -434,7 +434,7 @@ test "write_bom_utf16be" { ///| test "write_uint16_le method" { - let buf = Buffer(size_hint=4) + let buf = @buffer.Buffer(size_hint=4) buf.write_uint16_le(Int::to_uint16(0x1234)) buf.write_uint16_le(Int::to_uint16(0xabcd)) inspect( @@ -447,7 +447,7 @@ test "write_uint16_le method" { ///| test "write_uint16_be method" { - let buf = Buffer(size_hint=4) + let buf = @buffer.Buffer(size_hint=4) buf.write_uint16_be(Int::to_uint16(0x1234)) buf.write_uint16_be(Int::to_uint16(0xabcd)) inspect( @@ -460,7 +460,7 @@ test "write_uint16_be method" { ///| test "write_int16_le method" { - let buf = Buffer(size_hint=4) + let buf = @buffer.Buffer(size_hint=4) buf.write_int16_le(Int16(-2)) buf.write_int16_le(Int16(0x1234)) inspect( @@ -473,7 +473,7 @@ test "write_int16_le method" { ///| test "write_int16_be method" { - let buf = Buffer(size_hint=4) + let buf = @buffer.Buffer(size_hint=4) buf.write_int16_be(Int16(-2)) buf.write_int16_be(Int16(0x1234)) inspect( diff --git a/buffer/moon.pkg b/buffer/moon.pkg index b81f463ee0..ad510f2c32 100644 --- a/buffer/moon.pkg +++ b/buffer/moon.pkg @@ -8,6 +8,7 @@ import { } import { + "moonbitlang/core/bench", "moonbitlang/core/int", "moonbitlang/core/test", "moonbitlang/core/quickcheck", diff --git a/buffer/quickcheck_test.mbt b/buffer/quickcheck_test.mbt index eb938b22d3..b1b03fa07b 100644 --- a/buffer/quickcheck_test.mbt +++ b/buffer/quickcheck_test.mbt @@ -97,7 +97,7 @@ let sample_strings : Array[String] = [ test "quickcheck: op sequences agree with an Array[Byte] model" { @quickcheck.check( (ops : Array[(Int, Int)]) => { - let buf = Buffer() + let buf = @buffer.Buffer() let model : Array[Byte] = [] for op in ops { let x = op.1 @@ -164,7 +164,7 @@ test "quickcheck: multi-width integer writes reconstruct the original value" { @quickcheck.check( (input : (Int, Int64, UInt, Double)) => { let (i, l, u, d) = input - let buf = Buffer() + let buf = @buffer.Buffer() buf.write_int_le(i) buf.write_int_be(i) buf.write_int64_le(l) @@ -235,7 +235,7 @@ test "quickcheck: write_bytesview of any slice equals writing the sliced copy" { // slices at every position. let start = (a & 0x7FFFFFFF) % (n + 1) let len = (b & 0x7FFFFFFF) % (n - start + 1) - let buf = Buffer() + let buf = @buffer.Buffer() buf.write_bytesview(data[start:start + len]) let model : Array[Byte] = [] for i in start..<(start + len) { @@ -252,7 +252,7 @@ test "quickcheck: utf16le string writes concatenate like their model" { @quickcheck.check( (input : (String, String)) => { let (s1, s2) = input - let buf = Buffer() + let buf = @buffer.Buffer() buf.write_string_utf16le(s1) guard buf.length() == s1.length() * 2 else { return false } buf.write_string_utf16le(s2) @@ -271,7 +271,7 @@ test "quickcheck: reset makes the buffer behave as fresh" { @quickcheck.check( (input : (Bytes, Bytes)) => { let (pre, post) = input - let buf = Buffer() + let buf = @buffer.Buffer() buf.write_bytes(pre) buf.write_double_be(1.5) buf.reset() @@ -296,7 +296,7 @@ test "quickcheck: size_hint never affects observable contents" { let hints = [0, 1, 7, 4096] let results : Array[Bytes] = [] for hint in hints { - let buf = Buffer(size_hint=hint) + let buf = @buffer.Buffer(size_hint=hint) for chunk in chunks { buf.write_bytes(chunk) buf.write_byte(b'\xAB') @@ -319,7 +319,7 @@ test "quickcheck: contents() is non-destructive and writes append after it" { @quickcheck.check( (input : (Bytes, Bytes)) => { let (first, second) = input - let buf = Buffer() + let buf = @buffer.Buffer() buf.write_bytes(first) let c1 = buf.contents() let c2 = buf.contents() diff --git a/buffer/sleb128.mbt b/buffer/sleb128.mbt index 97f813dd59..5920d02692 100644 --- a/buffer/sleb128.mbt +++ b/buffer/sleb128.mbt @@ -82,10 +82,11 @@ pub impl Leb128 for Int64 with fn output(self, buffer) { } ///| -/// Encode a value as signed LEB128 and append it to the buffer. +/// Encode a value as LEB128 and append it to the buffer. /// -/// This works for types implementing `Leb128`, currently including `Int` and -/// `Int64`. +/// This works for types implementing `Leb128`: `Int` and `Int64` are encoded as +/// signed LEB128 (SLEB128), while `UInt` and `UInt64` are encoded as unsigned +/// LEB128 (ULEB128). /// /// Parameters: /// @@ -96,7 +97,7 @@ pub impl Leb128 for Int64 with fn output(self, buffer) { /// /// ```mbt check /// test { -/// let buf = Buffer() +/// let buf = @buffer.Buffer() /// buf.write_leb128(127) /// inspect(buf.contents().length() > 0, content="true") /// } diff --git a/buffer/sleb128_test.mbt b/buffer/sleb128_test.mbt index 4453bd4b0e..8829ca6d31 100644 --- a/buffer/sleb128_test.mbt +++ b/buffer/sleb128_test.mbt @@ -20,7 +20,7 @@ fn[A : Leb128] @buffer.Buffer::test_leb128(self : Self, x : A) -> Unit { ///| test "write_leb128 Int" { - let buffer = Buffer() + let buffer = @buffer.Buffer() buffer.test_leb128(0) inspect( buffer.to_bytes(), @@ -63,7 +63,7 @@ test "write_leb128 Int" { ///| test "write_leb128 Int64" { - let buffer = Buffer() + let buffer = @buffer.Buffer() buffer.write_leb128(0L) assert_eq(buffer.to_bytes(), b"\x00") buffer.reset() @@ -88,7 +88,7 @@ test "write_leb128 Int64" { ///| test "write_leb128 UInt" { - let buffer = Buffer() + let buffer = @buffer.Buffer() // Test zero buffer.write_leb128(0U) assert_eq(buffer.to_bytes(), b"\x00") @@ -137,7 +137,7 @@ test "write_leb128 UInt" { ///| test "write_leb128_edge_cases" { - let buffer = Buffer() + let buffer = @buffer.Buffer() // Test boundary values for each byte length buffer.write_leb128(0U) @@ -171,7 +171,7 @@ test "write_leb128_edge_cases" { ///| test "write_leb128_consecutive_writes" { - let buffer = Buffer() + let buffer = @buffer.Buffer() // Test writing multiple values consecutively buffer.write_leb128(1U) @@ -184,7 +184,7 @@ test "write_leb128_consecutive_writes" { ///| test "write_leb128_negative_values" { - let buffer = Buffer() + let buffer = @buffer.Buffer() // Test negative values for SLEB128 buffer.write_leb128(-1) @@ -206,7 +206,7 @@ test "write_leb128_negative_values" { ///| test "write_leb128_negative_Int64" { - let buffer = Buffer() + let buffer = @buffer.Buffer() // Test negative Int64 values for SLEB128 buffer.write_leb128(-1L) @@ -254,14 +254,14 @@ test "write_leb128_negative_Int64" { ///| test { - let buffer = Buffer() + let buffer = @buffer.Buffer() buffer.write_leb128(-65) assert_eq(buffer.to_bytes(), b"\xbf\x7f") } ///| test "write_leb128 UInt64" { - let buffer = Buffer() + let buffer = @buffer.Buffer() buffer.write_leb128(0UL) assert_eq(buffer.to_bytes(), b"\x00") buffer.reset() @@ -287,7 +287,7 @@ test "write_leb128 UInt64" { ///| test "write_leb128 UInt64 large values" { - let buffer = Buffer() + let buffer = @buffer.Buffer() buffer.write_leb128(0xFFFFFFFFUL) assert_eq(buffer.to_bytes(), b"\xff\xff\xff\xff\x0f") buffer.reset() diff --git a/buffer/write_bench_test.mbt b/buffer/write_bench_test.mbt new file mode 100644 index 0000000000..1edd2bef85 --- /dev/null +++ b/buffer/write_bench_test.mbt @@ -0,0 +1,136 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +/// Append-path benchmarks. The buffer is allocated once outside the timed +/// closure and rewound with `reset()`, so each run measures only the append +/// loop: `Buffer(size_hint=n)` would otherwise charge every run a malloc plus +/// a zero-fill of the whole capacity, on freshly handed-back memory. +/// +/// Each run keeps the buffer itself rather than `buf.length()`: the length is +/// derivable from the fixed loop bounds, so keeping only that would let the +/// backend prove the byte stores unobservable and drop the loop under test. +let buffer_bench_repeats = 4096 + +///| +let buffer_bench_piece = "abcdefghijklmnop" + +///| +test "bench Buffer::write_byte n=4096" (it : @bench.T) { + let buf = @buffer.Buffer(size_hint=buffer_bench_repeats) + it.bench(fn() { + buf.reset() + for i in 0.. i.to_byte()) + it.bench(fn() { + let buf = @buffer.from_array(arr) + it.keep(buf) + }) +} diff --git a/builtin/LinkedHashMap.mbt.md b/builtin/LinkedHashMap.mbt.md index 1cecfc7baf..d05167da14 100644 --- a/builtin/LinkedHashMap.mbt.md +++ b/builtin/LinkedHashMap.mbt.md @@ -91,7 +91,7 @@ test { ### Size & Capacity -You can use `size()` to get the number of key-value pairs in the map, or `capacity()` to get the current capacity. +You can use `length()` to get the number of key-value pairs in the map, or `capacity()` to get the current capacity. ```mbt check ///| diff --git a/builtin/README.mbt.md b/builtin/README.mbt.md index 9f45ff2557..b2082d887a 100644 --- a/builtin/README.mbt.md +++ b/builtin/README.mbt.md @@ -141,7 +141,7 @@ Built-in array types for storing collections: ///| test "arrays" { // Dynamic arrays - let arr1 = Array::new() + let arr1 = Array() arr1.push(1) arr1.push(2) arr1.push(3) diff --git a/builtin/array.mbt b/builtin/array.mbt index 36d6884435..27c26a202b 100644 --- a/builtin/array.mbt +++ b/builtin/array.mbt @@ -74,7 +74,7 @@ pub fn[T] Array::from_fixed_array(arr : FixedArray[T]) -> Array[T] { /// One should use makei() instead which creates an object for each index. #owned(elem) pub fn[T] Array::make(len : Int, elem : T) -> Array[T] { - let arr = Array::make_uninit(len) + let arr = Array::unsafe_make_uninit(len) for i in 0.. T raise?) -> Array[T] raise? { if length <= 0 { [] } else { - let array = Array::make_uninit(length) + let array = Array::unsafe_make_uninit(length) for i in 0.. T { /// /// ```mbt check /// test { -/// let arr : ReadOnlyArray[Int] = [1, 2, 3] +/// let arr : Array[Int] = [1, 2, 3] /// inspect(arr[1], content="2") /// } /// ``` @@ -209,6 +209,7 @@ pub fn[T] Array::at(self : Array[T], index : Int) -> T { /// debug_inspect(arr.get(3), content="None") /// } /// ``` +#intrinsic("%array.get_opt") pub fn[T] Array::get(self : Array[T], index : Int) -> T? { let len = self.length() guard index >= 0 && index < len else { None } @@ -559,6 +560,13 @@ pub fn[T] Array::any(self : Array[T], f : (T) -> Bool raise?) -> Bool raise? { /// /// This method has no effect on the allocated capacity of the array, only setting the length to 0. /// +/// Emptying an array is a removal like any other: the buffer keeps referring to +/// the elements that were in it, and they are released once later pushes reuse +/// those slots, the buffer grows, or the array is dropped. Call +/// `Array::release_unused` to overwrite them at once, or `Array::shrink_to_fit` +/// to hand the buffer back entirely. On the JavaScript backend the removed +/// elements are released right away and `Array::release_unused` is a no-op. +/// /// # Example /// ```mbt check /// test { @@ -587,7 +595,7 @@ pub fn[T, U] Array::map( self : Array[T], f : (T) -> U raise?, ) -> Array[U] raise? { - let arr = Array::make_uninit(self.length()) + let arr = Array::unsafe_make_uninit(self.length()) for i, v in self { arr.unsafe_set(i, f(v)) } @@ -635,7 +643,7 @@ pub fn[T, U] Array::mapi( if self.is_empty() { return [] } - let arr = Array::make_uninit(self.length()) + let arr = Array::unsafe_make_uninit(self.length()) for i, v in self { arr.unsafe_set(i, f(i, v)) } @@ -774,7 +782,7 @@ pub fn[T] Array::rev_in_place(self : Array[T]) -> Unit { /// ``` pub fn[T] Array::rev(self : Array[T]) -> Array[T] { let len = self.length() - let arr = Array::make_uninit(len) + let arr = Array::unsafe_make_uninit(len) for i in 0.. (Array[T], Array[T]) len=len2, ) } else { - Array::make_uninit(0) + Array::unsafe_make_uninit(0) } (v1, v2) } @@ -1270,7 +1278,7 @@ pub fn[T] Array::flatten(self : Array[Array[T]]) -> Array[T] { } nobreak { len } - let res = Array::make_uninit(len) + let res = Array::unsafe_make_uninit(len) for xs in self; i = 0 { res.unsafe_blit(i, xs, 0, xs.length()) continue i + xs.length() @@ -1301,7 +1309,7 @@ pub fn[T] Array::repeat(self : Array[T], times : Int) -> Array[T] { } let total = len * times guard total / times == len else { abort("repeat result too large") } - let v = Array::new(capacity=total) + let v = Array::Array(capacity=total) for _ in 0.. Array[ArrayView[T]] { /// * `predicate` : A function that takes two adjacent elements and returns /// `true` if they should be in the same chunk, `false` otherwise. /// -/// Returns an array of arrays, where each inner array is a chunk of consecutive +/// Returns an array of views, where each view is a chunk of consecutive /// elements that satisfy the predicate with their adjacent elements. /// /// Example: @@ -1861,6 +1869,14 @@ pub fn[A] Array::unsafe_pop_back(self : Array[A]) -> Unit { /// - If `len` is negative, the function does nothing. /// - If `len` exceeds current length, the array remains unchanged. /// +/// Elements beyond `len` are removed from the array, but the backing buffer +/// keeps referring to them: they are released once those slots are reused by +/// later pushes, once the buffer grows, or once the array is dropped. Call +/// `Array::release_unused` to overwrite them at once, or `Array::shrink_to_fit` +/// to move the survivors into an exact-size buffer. On the JavaScript backend +/// the removed elements are released right away and `Array::release_unused` is a +/// no-op. +/// /// Example: /// /// ```mbt check @@ -1924,7 +1940,7 @@ pub fn[A] Array::retain_map( /// /// ```mbt check /// test { -/// let iter = Iter::singleton(42) +/// let iter = [|42|] /// let arr = Array::from_iter(iter) /// debug_inspect(arr, content="[42]") /// } @@ -2018,15 +2034,20 @@ pub fn[T] Array::shuffle(self : Array[T], rand~ : (Int) -> Int) -> Array[T] { } ///| -/// Returns a new array containing the elements of the original array that satisfy the given predicate. +/// Applies a function to each element of the array and collects the results +/// that are `Some`, discarding the elements for which the function returns +/// `None`. /// /// # Arguments /// -/// * `self` - The array to filter. -/// * `f` - The predicate function. +/// * `self` - The array to filter and map. +/// * `f` - The function applied to each element, returning `Some(value)` to +/// keep the mapped `value`, or `None` to drop the element. /// /// # Returns /// +/// A new array containing the unwrapped `Some` results, in the order the +/// corresponding elements appeared in the original array. #locals(f) pub fn[A, B] Array::filter_map( self : Array[A], @@ -2111,8 +2132,8 @@ pub fn[A, B] Array::zip(self : Array[A], other : Array[B]) -> Array[(A, B)] { /// } /// ``` pub fn[T1, T2] Array::unzip(self : Array[(T1, T2)]) -> (Array[T1], Array[T2]) { - let arr1 : Array[T1] = Array::new(capacity=self.length()) - let arr2 : Array[T2] = Array::new(capacity=self.length()) + let arr1 : Array[T1] = Array(capacity=self.length()) + let arr2 : Array[T2] = Array(capacity=self.length()) for pair in self { let (x, y) = pair arr1.push(x) diff --git a/builtin/array_make_blit_bench_test.mbt b/builtin/array_make_blit_bench_test.mbt index 6c099a5c45..c7f4513df6 100644 --- a/builtin/array_make_blit_bench_test.mbt +++ b/builtin/array_make_blit_bench_test.mbt @@ -116,3 +116,23 @@ test "bench Array::push Ref n=1000000 resize" (it : @bench.T) { it.keep(data) }) } + +///| +fn make_blit_int_array(len : Int) -> Array[Int] { + Array::makei(len, i => i) +} + +///| +/// `Array::copy` allocates and fills in one shot. The Int and Ref variants are +/// both kept because the element type decides whether the per-element store +/// carries reference-counting traffic. +test "bench Array::copy Int n=1000000" (it : @bench.T) { + let data = make_blit_int_array(make_blit_ref_bench_size) + it.bench(fn() { it.keep(data.copy()) }) +} + +///| +test "bench Array::copy Ref n=1000000" (it : @bench.T) { + let data = make_blit_ref_array(make_blit_ref_bench_size) + it.bench(fn() { it.keep(data.copy()) }) +} diff --git a/builtin/array_nonjs_test.mbt b/builtin/array_nonjs_test.mbt index 9d7974f5a0..04199df3a1 100644 --- a/builtin/array_nonjs_test.mbt +++ b/builtin/array_nonjs_test.mbt @@ -14,13 +14,13 @@ ///| test "array_capacity" { - let arr : Array[Int] = Array::new(capacity=10) + let arr : Array[Int] = Array(capacity=10) assert_true(arr.capacity() >= 10) } ///| test "array_append_reuses_capacity" { - let arr = Array::new(capacity=4) + let arr = Array(capacity=4) arr.push(1) arr.append([2, 3]) inspect(arr.capacity(), content="4") @@ -29,7 +29,7 @@ test "array_append_reuses_capacity" { ///| test "array_append_grows_geometrically" { - let arr = Array::new(capacity=4) + let arr = Array(capacity=4) arr.append([1, 2, 3, 4, 5]) inspect(arr.capacity(), content="8") debug_inspect(arr, content="[1, 2, 3, 4, 5]") @@ -37,7 +37,7 @@ test "array_append_grows_geometrically" { ///| test "array_blit_to_reuses_and_grows_capacity" { - let dst = Array::new(capacity=4) + let dst = Array(capacity=4) dst.push(0) [1, 2][:].blit_to(dst, dst_offset=1) inspect(dst.capacity(), content="4") @@ -59,7 +59,7 @@ test "array_retain" { ///| test "shrink_to_fit" { - let v = Array::new(capacity=10) + let v = Array(capacity=10) v.push(1) v.push(2) v.push(3) @@ -69,3 +69,209 @@ test "shrink_to_fit" { v.shrink_to_fit() inspect(v.capacity(), content="3") } + +///| +/// Removing elements must never leave a slot a view could read as +/// uninitialized memory, so nothing is written to the slots a removal vacates +/// and a view taken beforehand still sees the original elements. +test "clear leaves the emptied slots as they were" { + let arr = ["a", "b", "c"] + let view = arr[0:3] + arr.clear() + inspect(arr.length(), content="0") + debug_inspect( + view, + content=( + #| + ), + ) +} + +///| +test "release_unused after clear overwrites the whole buffer" { + let arr = ["a", "b", "c"] + let view = arr[0:3] + arr.clear() + arr.release_unused(placeholder="-") + inspect(arr.length(), content="0") + debug_inspect( + view, + content=( + #| + ), + ) +} + +///| +test "truncate keeps removed elements reachable until release_unused" { + let kept = ["a", "b", "c", "d"] + let kept_view = kept[0:4] + kept.truncate(2) + debug_inspect( + kept, + content=( + #|["a", "b"] + ), + ) + debug_inspect( + kept_view, + content=( + #| + ), + ) + let filled = ["a", "b", "c", "d"] + let filled_view = filled[0:4] + filled.truncate(2) + filled.release_unused(placeholder="-") + debug_inspect( + filled, + content=( + #|["a", "b"] + ), + ) + debug_inspect( + filled_view, + content=( + #| + ), + ) +} + +///| +/// No shrinking operation replaces the buffer, so emptying an array never +/// costs an allocation -- whether it happens through `clear`, `truncate`, +/// `remove` or `drain`. A later push makes that observable: it lands in the +/// reused buffer, which a view taken beforehand still points at. +test "no shrinking operation replaces the buffer" { + let cleared = ["a", "b", "c"] + let cleared_view = cleared[0:3] + cleared.clear() + cleared.push("x") + debug_inspect( + cleared_view, + content=( + #| + ), + ) + let truncated = ["a", "b", "c"] + let truncated_view = truncated[0:3] + truncated.truncate(0) + truncated.push("x") + debug_inspect( + truncated_view, + content=( + #| + ), + ) + // `remove(0)` shifts "b" down, so the buffer reads [b, b] before the push. + let removed = ["a", "b"] + let removed_view = removed[0:2] + let _ = removed.remove(0) + removed.push("x") + debug_inspect( + removed_view, + content=( + #| + ), + ) + // `drain(0, 2)` shifts "c" down, so the buffer reads [c, b, c] before the push. + let drained = ["a", "b", "c"] + let drained_view = drained[0:3] + let _ = drained.drain(0, 2) + drained.push("x") + debug_inspect( + drained_view, + content=( + #| + ), + ) +} + +///| +test "release_unused overwrites the tail a drain vacated" { + let arr = ["a", "b", "c", "d"] + let view = arr[0:4] + let drained = arr.drain(1, 3) + arr.release_unused(placeholder="-") + debug_inspect( + drained, + content=( + #|["b", "c"] + ), + ) + debug_inspect( + arr, + content=( + #|["a", "d"] + ), + ) + debug_inspect( + view, + content=( + #| + ), + ) +} + +///| +/// `pop`, `remove` and `retain` offer no fill value of their own, so the +/// elements they remove stay in the buffer until something overwrites them. +/// `release_unused` is what releases them without reallocating, and a view taken +/// beforehand shows exactly which slots it reached. +test "release_unused releases what pop and remove leave behind" { + let popped = ["a", "b", "c"] + let popped_view = popped[0:3] + let _ = popped.pop() + debug_inspect( + popped_view, + content=( + #| + ), + ) + popped.release_unused(placeholder="-") + debug_inspect( + popped_view, + content=( + #| + ), + ) + let removed = ["a", "b", "c"] + let removed_view = removed[0:3] + let _ = removed.remove(0) + removed.release_unused(placeholder="-") + debug_inspect( + removed, + content=( + #|["b", "c"] + ), + ) + debug_inspect( + removed_view, + content=( + #| + ), + ) +} + +///| +/// `release_unused` covers the whole unused region, not just the slots the most +/// recent operation vacated, so one call settles a run of removals. +test "release_unused spans every outstanding removal" { + let arr = ["a", "b", "c", "d"] + let view = arr[0:4] + let _ = arr.pop() + arr.truncate(1) + arr.release_unused(placeholder="-") + debug_inspect( + arr, + content=( + #|["a"] + ), + ) + debug_inspect( + view, + content=( + #| + ), + ) +} diff --git a/builtin/array_sort.mbt b/builtin/array_sort.mbt index 8098938fa6..edc6edd8ce 100644 --- a/builtin/array_sort.mbt +++ b/builtin/array_sort.mbt @@ -285,7 +285,7 @@ pub fn[T : Compare] FixedArray::stable_sort(self : FixedArray[T]) -> Unit { /// /// ```mbt check /// test { -/// let arr = [5, 3, 2, 4, 1] +/// let arr : FixedArray[Int] = [5, 3, 2, 4, 1] /// arr.sort_by_key(x => -x) /// @test.assert_eq(arr, [5, 4, 3, 2, 1]) /// } @@ -316,7 +316,7 @@ test "FixedArray::sort_by_key/basic" { /// /// ```mbt check /// test { -/// let arr = [5, 3, 2, 4, 1] +/// let arr : FixedArray[Int] = [5, 3, 2, 4, 1] /// arr.sort_by((a, b) => a - b) /// @test.assert_eq(arr, [1, 2, 3, 4, 5]) /// } diff --git a/builtin/array_sort_impl.mbt b/builtin/array_sort_impl.mbt index b8494044a9..38c7c3b184 100644 --- a/builtin/array_sort_impl.mbt +++ b/builtin/array_sort_impl.mbt @@ -48,13 +48,13 @@ fn[T : Compare] timsort(arr : MutArrayView[T]) -> Unit { // Insert some more elements into the run if it's too short. Insertion sort is faster than // merge sort on short sequences, so this significantly improves performance. let end = provide_sorted_batch(arr, start, end) - runs.push({ start, len: end - start }) + runs.push({ start, len: end - start, }) while true { guard collapse(runs, len) is Some(r) else { break } let left = runs[r] let right = runs[r + 1] merge(arr.slice(left.start, right.start + right.len), left.len) - runs[r + 1] = { start: left.start, len: left.len + right.len } + runs[r + 1] = { start: left.start, len: left.len + right.len, } runs.remove(r) |> ignore } continue end, end @@ -199,7 +199,7 @@ fn collapse(runs : Array[TimSortRun], stop : Int) -> Int? { /// /// ```mbt check /// test { -/// let arr = [5, 4, 3, 2, 1] +/// let arr : FixedArray[Int] = [5, 4, 3, 2, 1] /// arr.sort() /// @test.assert_eq(arr, [1, 2, 3, 4, 5]) /// } @@ -339,11 +339,10 @@ fn[T : Compare] fixed_try_bubble_sort(arr : MutArrayView[T]) -> Bool { } ///| -/// Try to sort the array with bubble sort. -/// -/// It will only tolerate at most 8 unsorted elements. The time complexity is O(n). -/// -/// Returns whether the array is sorted. +/// Sort the array with insertion sort (despite the name): it grows a sorted +/// prefix, moving each new element left by adjacent swaps until it is in place. +/// +/// It always sorts the whole array. The time complexity is O(n^2) in the worst case. fn[T : Compare] fixed_bubble_sort(arr : MutArrayView[T]) -> Unit { for i in 1.. 0 && arr.unsafe_get(j - 1) > arr.unsafe_get(j); j = j - 1 { diff --git a/builtin/array_test.mbt b/builtin/array_test.mbt index 0a687571ba..d71779fc85 100644 --- a/builtin/array_test.mbt +++ b/builtin/array_test.mbt @@ -14,7 +14,7 @@ ///| test "array_new" { - let arr : Array[Int] = Array::new() + let arr : Array[Int] = Array() @test.assert_eq(arr.length(), 0) } @@ -39,7 +39,7 @@ test "array_make" { ///| test "array_realloc" { - let arr = Array::new(capacity=2) + let arr = Array(capacity=2) arr.push(1) arr.push(2) arr.push(3) // This should trigger a reallocation @@ -149,7 +149,7 @@ test "array_append" { // @test.assert_eq(arr1.capacity(), 6) @test.assert_eq(arr1[3], 4) let cap = 20 - let arr3 = Array::new(capacity=cap) + let arr3 = Array(capacity=cap) arr3.resize(cap, 10) arr1.append(arr3) @test.assert_eq(arr1.length(), 6 + cap) @@ -245,12 +245,7 @@ test "array_eachi" { test "array_rev_eachi" { let arr = ['a', 'b', 'c'] let buf = StringBuilder() - arr.rev_eachi((i, x) => { - buf.write_object(i) - buf.write_string(": ") - buf.write_object(x) - buf.write_string("\n") - }) + arr.rev_eachi((i, x) => buf <+ "\{i}: \{x}\n") inspect( buf, content=( @@ -308,7 +303,7 @@ test "array_filter" { ///| test "array_is_empty" { - let arr : Array[Int] = Array::new() + let arr : Array[Int] = Array() assert_true(arr.is_empty()) } @@ -667,7 +662,7 @@ test "array_reserve_capacity" { ///| test "array_shrink_to_fit" { - let arr = Array::new(capacity=10) + let arr = Array(capacity=10) arr.push(1) arr.push(2) arr.push(3) @@ -746,21 +741,21 @@ struct TestStruct { ///| test "array_binary_search_by_test" { - let arr = [{ num: 10 }, { num: 22 }, { num: 35 }, { num: 48 }] - let mut target = { num: 22 } + let arr = [{ num: 10, }, { num: 22, }, { num: 35, }, { num: 48, }] + let mut target = { num: 22, } let cmp = (val : TestStruct) => val.num - target.num @test.assert_eq(arr.binary_search_by(cmp), Ok(1)) - target = { num: 48 } + target = { num: 48, } @test.assert_eq(arr.binary_search_by(cmp), Ok(3)) - target = { num: -8 } + target = { num: -8, } @test.assert_eq(arr.binary_search_by(cmp), Err(0)) - target = { num: 49 } + target = { num: 49, } @test.assert_eq(arr.binary_search_by(cmp), Err(4)) } ///| test "array of bytes, new & push" { - let bytes : Array[Byte] = Array::new(capacity=10) + let bytes : Array[Byte] = Array(capacity=10) bytes.push(b'a') debug_inspect( bytes, @@ -1423,7 +1418,13 @@ test "arbitrary" { debug_inspect( arr[5:9], content=( - #| + #| ), ) debug_inspect( @@ -1431,11 +1432,33 @@ test "arbitrary" { content=( #| ), ) @@ -1539,7 +1562,7 @@ test "Array::unsafe_pop" { test "Array::append/self_alias" { // Test appending array to itself - this should double the array // Using small capacity to force reallocation during append - let arr = Array::new(capacity=3) + let arr = Array(capacity=3) arr.push(1) arr.push(2) arr.push(3) @@ -1550,7 +1573,7 @@ test "Array::append/self_alias" { ///| test "Array::append/self_alias_partial" { // Test appending a partial view of array to itself - let arr = Array::new(capacity=4) + let arr = Array(capacity=4) arr.push(1) arr.push(2) arr.push(3) @@ -1588,7 +1611,7 @@ test "timsort_early_return_for_small_arrays" { counter: count, }) for i in 0..<10 { - arr[i] = { value: 10 - i, counter: count } + arr[i] = { value: 10 - i, counter: count, } } arr.mut_view().stable_sort() // With insertion sort only: O(n^2/2) comparisons, roughly 45 for n=10 diff --git a/builtin/array_wbtest.mbt b/builtin/array_wbtest.mbt index 0132147da8..d28d7ce6dc 100644 --- a/builtin/array_wbtest.mbt +++ b/builtin/array_wbtest.mbt @@ -14,6 +14,6 @@ ///| test { - let empty : Array[Unit] = Array::make_uninit(0) + let empty : Array[Unit] = Array::unsafe_make_uninit(0) assert_true(empty == []) } diff --git a/builtin/arraycore_js.mbt b/builtin/arraycore_js.mbt index 99850ce3da..5164a78762 100644 --- a/builtin/arraycore_js.mbt +++ b/builtin/arraycore_js.mbt @@ -106,7 +106,14 @@ extern "js" fn JSArray::copy(self : JSArray) -> JSArray = type Array[T] ///| -fn[T] Array::make_uninit(len : Int) -> Array[T] = "%fixedarray.make_uninit" +/// Creates an array of `len` elements whose contents are unspecified. The +/// array reports `length() == len` immediately, so every slot is observable +/// and the caller must write all of them before the array is read or escapes. +/// +/// Prefer `Array::makei` unless the initializing loop cannot be expressed as +/// a function of the index. +#doc(hidden) +pub fn[T] Array::unsafe_make_uninit(len : Int) -> Array[T] = "%fixedarray.make_uninit" ///| fn[T] Array::unsafe_make_and_blit( @@ -116,7 +123,7 @@ fn[T] Array::unsafe_make_and_blit( src_offset? : Int = 0, dst_offset? : Int = 0, ) -> Array[T] { - let dst = Array::make_uninit(allocate_len) + let dst = Array::unsafe_make_uninit(allocate_len) UninitializedArray::unsafe_blit( dst.buffer(), dst_offset, @@ -135,7 +142,7 @@ fn[T] Array::unsafe_make_and_blit_from_fixed( src_offset? : Int = 0, dst_offset? : Int = 0, ) -> Array[T] { - let dst = Array::make_uninit(allocate_len) + let dst = Array::unsafe_make_uninit(allocate_len) UninitializedArray::unsafe_blit_fixed( dst.buffer(), dst_offset, @@ -162,7 +169,7 @@ fn[T] Array::unsafe_resize_with_default( ///| /// Creates a new array. -pub fn[T] Array::new(capacity? : Int = 0) -> Array[T] { +pub fn[T] Array::Array(capacity? : Int = 0) -> Array[T] { ignore(capacity) [] } @@ -232,7 +239,7 @@ pub fn[T] Array::reserve_capacity(self : Array[T], capacity : Int) -> Unit { /// /// ```mbt check /// test { -/// let v = Array::new(capacity=10) +/// let v = Array(capacity=10) /// v.push(1) /// v.push(2) /// v.push(3) @@ -245,6 +252,29 @@ pub fn[T] Array::shrink_to_fit(self : Array[T]) -> Unit { ignore(self) } +///| +/// Overwrites the array's unused capacity with `placeholder`, releasing +/// whatever those slots held. +/// +/// **NOTE**: This method does nothing on the js platform -- shrinking a +/// JavaScript array releases the removed elements outright, so there is no +/// unused capacity holding on to them. +/// +/// Example: +/// +/// ```mbt check +/// test { +/// let arr = ["a", "b", "c"] +/// let _ = arr.pop() +/// arr.release_unused(placeholder="") +/// debug_inspect(arr, content="[\"a\", \"b\"]") +/// } +/// ``` +pub fn[T] Array::release_unused(self : Array[T], placeholder~ : T) -> Unit { + ignore(self) + ignore(placeholder) +} + ///| /// Adds an element to the end of the array. /// @@ -423,6 +453,9 @@ pub fn[T] Array::remove(self : Array[T], index : Int) -> T { /// @test.assert_eq(v, [3, 5]) /// } /// ``` +/// +/// On this backend the underlying JavaScript array is spliced directly, which +/// already releases the drained elements. pub fn[T] Array::drain(self : Array[T], begin : Int, end : Int) -> Array[T] { guard begin >= 0 && end <= self.length() && begin <= end else { abort( diff --git a/builtin/arraycore_nonjs.mbt b/builtin/arraycore_nonjs.mbt index 6977135c76..aa844ffb7d 100644 --- a/builtin/arraycore_nonjs.mbt +++ b/builtin/arraycore_nonjs.mbt @@ -12,9 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -///| -fn[T] UninitializedArray::set_null(self : UninitializedArray[T], index : Int) = "%fixedarray.set_null" - ///| /// An `Array` is a collection of values that supports random access and can /// grow in size. @@ -24,8 +21,15 @@ struct Array[T] { } ///| -fn[T] Array::make_uninit(len : Int) -> Array[T] { - { buf: UninitializedArray::make(len), len } +/// Creates an array of `len` elements whose contents are unspecified. The +/// array reports `length() == len` immediately, so every slot is observable +/// and the caller must write all of them before the array is read or escapes. +/// +/// Prefer `Array::makei` unless the initializing loop cannot be expressed as +/// a function of the index. +#doc(hidden) +pub fn[T] Array::unsafe_make_uninit(len : Int) -> Array[T] { + { buf: UninitializedArray::make(len), len, } } ///| @@ -106,18 +110,18 @@ fn[T] Array::unsafe_resize_with_default( /// /// ```mbt check /// test { -/// let arr : Array[Int] = Array::new(capacity=10) +/// let arr : Array[Int] = Array(capacity=10) /// inspect(arr.length(), content="0") /// inspect(arr.capacity(), content="10") -/// let arr : Array[Int] = Array::new() +/// let arr : Array[Int] = Array() /// inspect(arr.length(), content="0") /// } /// ``` -pub fn[T] Array::new(capacity? : Int = 0) -> Array[T] { +pub fn[T] Array::Array(capacity? : Int = 0) -> Array[T] { if capacity == 0 { [] } else { - { buf: UninitializedArray::make(capacity), len: 0 } + { buf: UninitializedArray::make(capacity), len: 0, } } } @@ -134,9 +138,9 @@ pub fn[T] Array::new(capacity? : Int = 0) -> Array[T] { /// /// ```mbt check /// test { -/// let arr : ReadOnlyArray[Int] = [1, 2, 3] +/// let arr : Array[Int] = [1, 2, 3] /// inspect(arr.length(), content="3") -/// let empty : ReadOnlyArray[Int] = [] +/// let empty : Array[Int] = [] /// inspect(empty.length(), content="0") /// } /// ``` @@ -162,17 +166,18 @@ pub fn[T] Array::length(self : Array[T]) -> Int { /// /// # Errors /// -/// - This function does not explicitly raise errors, but improper use (e.g., -/// setting `new_len` greater than the current length) can lead to undefined -/// behavior. +/// - This function does not raise errors, but it panics if `new_len` is greater +/// than the current length of the array. /// -/// TODO: this can be optimized by using the intrinsic to null out the range +/// # Retention +/// +/// The slots beyond `new_len` are left as they are rather than nulled out, so +/// that an `ArrayView` created before the call can never observe uninitialized +/// memory. The removed elements stay reachable from the buffer until those +/// slots are reused by later pushes, until the buffer grows, or until the +/// array is dropped; `Array::release_unused` overwrites them on demand. fn[T] Array::unsafe_truncate_to_length(self : Array[T], new_len : Int) -> Unit { - let len = self.length() - guard! new_len <= len - for i in new_len.. Unit { let old_buf = self.buf - let old_cap = old_buf.0.length() - let copy_len = if old_cap < new_capacity { old_cap } else { new_capacity } + // Only the live prefix is worth carrying over. The slots beyond `len` hold + // either NULL or the elements earlier removals left behind, and leaving them + // with the old buffer is what releases the latter when that buffer dies. + let len = self.len + let copy_len = if len < new_capacity { len } else { new_capacity } let new_buf = UninitializedArray::make_and_blit( old_buf, allocate_len=new_capacity, @@ -274,7 +282,7 @@ test "UninitializedArray::unsafe_blit_fixed" { ///| test "Array::resize_buffer" { - let arr = Array::new(capacity=2) + let arr = Array::Array(capacity=2) arr.push(1) arr.push(2) arr.resize_buffer(4) @@ -325,7 +333,7 @@ pub fn[T] Array::reserve_capacity(self : Array[T], capacity : Int) -> Unit { /// /// ```mbt check /// test { -/// let v = Array::new(capacity=10) +/// let v = Array(capacity=10) /// v.push(1) /// v.push(2) /// v.push(3) @@ -333,6 +341,12 @@ pub fn[T] Array::reserve_capacity(self : Array[T], capacity : Int) -> Unit { /// @test.assert_eq(v.capacity(), 3) /// } /// ``` +/// +/// The survivors are copied into the new buffer and the old one is released +/// with them, so this also releases whatever earlier removals left in the +/// unused capacity. It pays an allocation plus a copy of every survivor to do +/// so; `Array::release_unused` releases the same elements in one pass over the +/// unused region and no allocation, at the cost of leaving the capacity alone. pub fn[T] Array::shrink_to_fit(self : Array[T]) -> Unit { if self.capacity() <= self.length() { return @@ -340,6 +354,43 @@ pub fn[T] Array::shrink_to_fit(self : Array[T]) -> Unit { self.resize_buffer(self.length()) } +///| +/// Overwrites the array's unused capacity -- every slot from `length()` up to +/// `capacity()` -- with `placeholder`, releasing whatever those slots held. +/// +/// Shrinking an array never clears the slots it vacates, so whatever they held +/// -- a removed element, or a duplicate reference to a survivor that was +/// shifted over it -- stays reachable from the buffer and unreleased until +/// later pushes reuse those slots, the buffer grows, or the array is dropped. +/// This releases them on demand without reallocating, which is what +/// `Array::pop`, `Array::remove`, `Array::retain` and the other operations +/// that take no placeholder leave outstanding. `Array::shrink_to_fit` releases +/// them too, but by allocating an exact-size buffer and copying every survivor +/// into it; this costs one pass over the unused region and no allocation. +/// +/// An `ArrayView` created before the call observes `placeholder` in that region +/// afterwards, in place of whatever it held. +/// +/// This only matters for element types holding references -- for types such as +/// `Int` there is nothing to release and the call merely costs a pass over the +/// buffer. +/// +/// Example: +/// +/// ```mbt check +/// test { +/// let arr = ["a", "b", "c"] +/// let _ = arr.pop() +/// arr.release_unused(placeholder="") +/// debug_inspect(arr, content="[\"a\", \"b\"]") +/// } +/// ``` +#owned(placeholder) +pub fn[T] Array::release_unused(self : Array[T], placeholder~ : T) -> Unit { + let len = self.len + self.buf.unchecked_fill(len, placeholder, self.capacity() - len) +} + ///| /// Adds an element to the end of the array. /// @@ -525,6 +576,11 @@ pub fn[A] ArrayView::blit_to( ///| /// Removes the last element from an array and returns it, or `None` if it is empty. /// +/// The vacated slot goes on referring to the returned element, so an +/// `ArrayView` created beforehand keeps observing that element, and it is +/// released only once a later push reuses the slot, the buffer grows, or the +/// buffer is dropped. Call `Array::release_unused` to release it at once. +/// /// # Example /// ```mbt check /// test { @@ -540,7 +596,8 @@ pub fn[T] Array::pop(self : Array[T]) -> T? { } else { let index = len - 1 let v = self.unsafe_get(index) - self.buf.set_null(index) + // The slot keeps referring to `v` until it is reused or the buffer dies; + // see `unsafe_truncate_to_length` for why it is not nulled out. self.len = index Some(v) } @@ -555,6 +612,10 @@ pub fn[T] Array::pop(self : Array[T]) -> T? { /// /// Returns the last element of the array before removal. /// +/// As with `Array::pop`, the vacated slot goes on referring to the returned +/// element until a later push reuses it, the buffer grows, the buffer is +/// dropped, or `Array::release_unused` overwrites it. +/// /// Example: /// /// ```mbt check @@ -573,7 +634,6 @@ pub fn[T] Array::unsafe_pop(self : Array[T]) -> T { guard! len != 0 let index = len - 1 let v = self.unsafe_get(index) - self.buf.set_null(index) self.len = index v } @@ -626,6 +686,14 @@ pub fn[T] Array::remove(self : Array[T], index : Int) -> T { /// @test.assert_eq(v, [3, 5]) /// } /// ``` +/// +/// The `end - begin` slots vacated at the end of the array are not cleared: +/// each keeps whatever it held before the survivors were shifted down, so a +/// drained element or a duplicate reference to a survivor stays reachable +/// there until the slot is reused, the buffer grows, or the array is dropped. +/// Call `Array::release_unused` to overwrite them at once, or +/// `Array::shrink_to_fit` to move the survivors into an exact-size buffer. +/// pub fn[T] Array::drain(self : Array[T], begin : Int, end : Int) -> Array[T] { guard! begin >= 0 && end <= self.length() && begin <= end let num = end - begin @@ -775,10 +843,9 @@ pub fn[A] Array::fill( pub fn[T] Array::copy(self : Array[T]) -> Array[T] { let len = self.length() if len == 0 { - [] - } else { - let arr = Array::make(len, self[0]) - Array::unsafe_blit(arr, 0, self, 0, len) - arr + return [] } + let arr = Array::unsafe_make_uninit(len) + Array::unsafe_blit(arr, 0, self, 0, len) + arr } diff --git a/builtin/arrayview.mbt b/builtin/arrayview.mbt index 9a5013e8ff..197098b11c 100644 --- a/builtin/arrayview.mbt +++ b/builtin/arrayview.mbt @@ -20,6 +20,25 @@ /// over a view keeps using those bounds even if the underlying array is later /// structurally modified. /// +/// Mutating an array while a view of it is alive is a program error. Because a +/// view keeps its original bounds and does not track the array, after such a +/// mutation it may observe elements that have since been removed, a value +/// handed to `Array::release_unused`, or the contents of a buffer the array has +/// stopped using. What it yields is always a valid value of `T` -- never +/// uninitialized memory -- but is otherwise unspecified. +/// +/// No removal writes to the slots it vacates, so the removed elements stay +/// reachable, and so unreleased, until a later push reuses the slot, until the +/// buffer grows, or until the buffer itself is dropped. That holds uniformly: +/// `clear` empties an array the same way `pop` shortens it. Two operations +/// release those elements on demand -- `Array::release_unused` overwrites the +/// unused capacity in place, and `Array::shrink_to_fit` moves the survivors +/// into an exact-size buffer and lets the old one go. +/// +/// On the JavaScript backend this guarantee does not yet hold: operations such +/// as `Array::pop` shrink the underlying JavaScript array, so a view reaching +/// past the array's current length observes `undefined`. +/// /// # Example /// /// ```mbt check @@ -166,6 +185,7 @@ pub fn[T] ArrayView::at(self : ArrayView[T], index : Int) -> T { /// debug_inspect(view.get(5), content="None") /// } /// ``` +#intrinsic("%arrayview.get_opt") pub fn[T] ArrayView::get(self : ArrayView[T], index : Int) -> T? { let len = self.length() guard index >= 0 && index < len else { None } @@ -223,7 +243,7 @@ pub fn[T] ArrayView::unsafe_get(self : ArrayView[T], index : Int) -> T { } ///| -/// Creates a view of a portion of the array. The view provides read-write access +/// Creates a view of a portion of the array. The view provides read-only access /// to the underlying array without copying the elements. /// /// Parameters: @@ -249,6 +269,7 @@ pub fn[T] ArrayView::unsafe_get(self : ArrayView[T], index : Int) -> T { /// inspect(view.length(), content="3") // View contains 3 elements /// } /// ``` +#intrinsic("%array.view") #alias("_[_:_]") #alias(sub, deprecated="Use _[_:_] instead") pub fn[T] Array::view( @@ -342,6 +363,7 @@ pub fn[T] Array::get_view( /// inspect(subview[0], content="3") /// } /// ``` +#intrinsic("%arrayview.view") #alias("_[_:_]") #alias(sub, deprecated="Use _[_:_] instead") pub fn[T] ArrayView::view( @@ -439,6 +461,7 @@ fn[T] unsafe_cast_fixedarray_to_uninitializedarray( /// inspect(view[0], content="2") /// } /// ``` +#intrinsic("%fixedarray.view") #alias("_[_:_]") #alias(sub, deprecated="Use _[_:_] instead") pub fn[T] FixedArray::view( @@ -727,7 +750,7 @@ pub fn[X] ArrayView::iter(self : ArrayView[X]) -> Iter[X] { ///| /// Return a reverse iterator over elements of this view. /// -/// Deprecated alias for reverse iteration; prefer `rev_iterator`. +/// `rev_iterator` is a deprecated alias for this function. /// /// Example: /// @@ -1360,7 +1383,7 @@ pub fn[T, U] ArrayView::map( self : ArrayView[T], f : (T) -> U raise?, ) -> Array[U] raise? { - let arr = Array::make_uninit(self.length()) + let arr = Array::unsafe_make_uninit(self.length()) for i, x in self { arr.unsafe_set(i, f(x)) } @@ -1383,7 +1406,7 @@ pub fn[T, U] ArrayView::mapi( self : ArrayView[T], f : (Int, T) -> U raise?, ) -> Array[U] raise? { - let arr = Array::make_uninit(self.length()) + let arr = Array::unsafe_make_uninit(self.length()) for i, x in self { arr.unsafe_set(i, f(i, x)) } @@ -1580,7 +1603,7 @@ pub fn[T : Compare] ArrayView::lexical_compare( /// ``` pub fn[T] ArrayView::rev(self : ArrayView[T]) -> Array[T] { let len = self.length() - let arr = Array::make_uninit(len) + let arr = Array::unsafe_make_uninit(len) for i in 0.., #| , - #| , - #| , + #| , + #| , #| ]> ), ) @@ -505,11 +505,35 @@ test "arrayview_arbitrary" { content=( #|, - #| , - #| , - #| , - #| , + #| , + #| , + #| , + #| , + #| , #| ]> ), ) @@ -520,10 +544,20 @@ test "arrayview_hash" { let arr : Array[ArrayView[Int]] = @quickcheck.samples(20) let h1 = Hasher(seed=0) h1.combine(arr[5:9]) - inspect(h1.finalize(), content="-966877954") + inspect( + h1.finalize(), + content=( + #|-1804859757 + ), + ) let h2 = Hasher(seed=0) h2.combine(arr[10:15]) - inspect(h2.finalize(), content="-951019668") + inspect( + h2.finalize(), + content=( + #|-1122600380 + ), + ) } ///| diff --git a/builtin/autoloc.mbt b/builtin/autoloc.mbt index dadc250ded..e14c7a48bf 100644 --- a/builtin/autoloc.mbt +++ b/builtin/autoloc.mbt @@ -31,8 +31,8 @@ pub(all) type SourceLoc /// * `source_location` : A source code location containing information about the /// file path, line number, and column number. /// -/// Returns a string representation of the source location, typically in the -/// format "@package:file:start_line:start_column-end_line:end_column". +/// Returns a string representation of the source location, in the format +/// "file:start_line:start_column-end_line:end_column@module". /// /// Note: This function is primarily used internally by the compiler for error /// reporting and debugging purposes. Source locations are automatically created @@ -72,7 +72,7 @@ fn SourceLocRepr::parse(repr : String) -> SourceLocRepr { (re"[[:digit:]]+" as end_column) + re"@" + (re"[^:]*$" as _module) => - { filename, start_line, start_column, end_line, end_column } + { filename, start_line, start_column, end_line, end_column, } _ => panic() } } @@ -106,15 +106,16 @@ pub impl Show for ArgsLoc with fn output(self, logger) { ///| /// Converts an array of optional source locations to its JSON string -/// representation. Each location in the array is either represented as a string -/// if present, or "null" if absent. +/// representation. Each location in the array is either represented as a JSON +/// object with `filename`, `start_line`, `start_column`, `end_line` and +/// `end_column` fields if present, or "null" if absent. /// /// Parameters: /// /// * `self` : The array of optional source locations to be converted. /// -/// Returns a JSON array string where each element is either a string -/// representation of a source location or "null". +/// Returns a JSON array string where each element is either a JSON object +/// describing a source location or "null". pub fn ArgsLoc::to_json(self : ArgsLoc) -> String { let buf = StringBuilder(size_hint=10) let ArgsLoc(self) = self diff --git a/builtin/bitstring.mbt b/builtin/bitstring.mbt index c0f1f5adf7..370c87f496 100644 --- a/builtin/bitstring.mbt +++ b/builtin/bitstring.mbt @@ -107,9 +107,27 @@ pub fn ArrayView::unsafe_extract_uint_le( bs : ArrayView[Byte], offset : Int, len : Int, +) -> UInt { + if (offset & 7) == 0 { + if len == 32 { + return bs.unsafe_extract_uint_le_aligned(offset >> 3) + } + if len == 16 { + return bs.unsafe_extract_uint16_le_aligned(offset >> 3) + } + } + bs.unsafe_extract_uint_le_slow(offset, len) +} + +///| +/// Outlined slow path of `unsafe_extract_uint_le`, kept out of the entry +/// function so the aligned fast paths above stay small enough to inline. +fn ArrayView::unsafe_extract_uint_le_slow( + bs : ArrayView[Byte], + offset : Int, + len : Int, ) -> UInt { let bytes_needed = (len + 7) / 8 - // TODO: add fast path for aligned case // non-aligned case: extract bytes using unsafe_extract_byte let b0 = bs.unsafe_extract_byte(offset, 8) match bytes_needed { @@ -145,9 +163,27 @@ pub fn ArrayView::unsafe_extract_uint_be( bs : ArrayView[Byte], offset : Int, len : Int, +) -> UInt { + if (offset & 7) == 0 { + if len == 32 { + return bs.unsafe_extract_uint_be_aligned(offset >> 3) + } + if len == 16 { + return bs.unsafe_extract_uint16_be_aligned(offset >> 3) + } + } + bs.unsafe_extract_uint_be_slow(offset, len) +} + +///| +/// Outlined slow path of `unsafe_extract_uint_be`, kept out of the entry +/// function so the aligned fast paths above stay small enough to inline. +fn ArrayView::unsafe_extract_uint_be_slow( + bs : ArrayView[Byte], + offset : Int, + len : Int, ) -> UInt { let bytes_needed = (len + 7) / 8 - // TODO: add fast path for aligned case // non-aligned case: extract bytes using unsafe_extract_byte let b0 = bs.unsafe_extract_byte(offset, 8) match bytes_needed { @@ -182,7 +218,7 @@ pub fn ArrayView::unsafe_extract_uint_be( /// # Invariants /// - It's guaranteed to have at least 5 bytes available for extraction /// - Only reads the necessary number of bytes based on the bit length (5-8 bytes) -/// - For bit lengths < 33, use unsafe_extract_int_le instead +/// - For bit lengths [9..32], use unsafe_extract_uint_le instead /// #internal(experimental, "subject to breaking change without notice") #doc(hidden) @@ -190,9 +226,22 @@ pub fn ArrayView::unsafe_extract_uint64_le( bs : ArrayView[Byte], offset : Int, len : Int, +) -> UInt64 { + if len == 64 && (offset & 7) == 0 { + return bs.unsafe_extract_uint64_le_aligned(offset >> 3) + } + bs.unsafe_extract_uint64_le_slow(offset, len) +} + +///| +/// Outlined slow path of `unsafe_extract_uint64_le`, kept out of the entry +/// function so the aligned fast paths above stay small enough to inline. +fn ArrayView::unsafe_extract_uint64_le_slow( + bs : ArrayView[Byte], + offset : Int, + len : Int, ) -> UInt64 { let bytes_needed = (len + 7) / 8 - // TODO: add fast path for aligned case // non-aligned case: extract bytes using unsafe_extract_byte let b0 = bs.unsafe_extract_byte(offset, 8).to_uint64() let b1 = bs.unsafe_extract_byte(offset + 8, 8).to_uint64() @@ -244,7 +293,7 @@ pub fn ArrayView::unsafe_extract_uint64_le( /// # Invariants /// - It's guaranteed to have at least 5 bytes available for extraction /// - Only reads the necessary number of bytes based on the bit length (5-8 bytes) -/// - For bit lengths < 33, use unsafe_extract_int_be instead +/// - For bit lengths [9..32], use unsafe_extract_uint_be instead /// #internal(experimental, "subject to breaking change without notice") #doc(hidden) @@ -252,9 +301,22 @@ pub fn ArrayView::unsafe_extract_uint64_be( bs : ArrayView[Byte], offset : Int, len : Int, +) -> UInt64 { + if len == 64 && (offset & 7) == 0 { + return bs.unsafe_extract_uint64_be_aligned(offset >> 3) + } + bs.unsafe_extract_uint64_be_slow(offset, len) +} + +///| +/// Outlined slow path of `unsafe_extract_uint64_be`, kept out of the entry +/// function so the aligned fast paths above stay small enough to inline. +fn ArrayView::unsafe_extract_uint64_be_slow( + bs : ArrayView[Byte], + offset : Int, + len : Int, ) -> UInt64 { let bytes_needed = (len + 7) / 8 - // TODO: add fast path for aligned case // non-aligned case: extract bytes using unsafe_extract_byte let b0 = bs.unsafe_extract_byte(offset, 8).to_uint64() let b1 = bs.unsafe_extract_byte(offset + 8, 8).to_uint64() @@ -983,9 +1045,27 @@ pub fn BytesView::unsafe_extract_uint_le( bs : BytesView, offset : Int, len : Int, +) -> UInt { + if (offset & 7) == 0 { + if len == 32 { + return bs.unsafe_extract_uint_le_aligned(offset >> 3) + } + if len == 16 { + return bs.unsafe_extract_uint16_le_aligned(offset >> 3) + } + } + bs.unsafe_extract_uint_le_slow(offset, len) +} + +///| +/// Outlined slow path of `unsafe_extract_uint_le`, kept out of the entry +/// function so the aligned fast paths above stay small enough to inline. +fn BytesView::unsafe_extract_uint_le_slow( + bs : BytesView, + offset : Int, + len : Int, ) -> UInt { let bytes_needed = (len + 7) / 8 - // TODO: add fast path for aligned case // non-aligned case: extract bytes using unsafe_extract_byte let b0 = bs.unsafe_extract_byte(offset, 8) match bytes_needed { @@ -1021,9 +1101,27 @@ pub fn BytesView::unsafe_extract_uint_be( bs : BytesView, offset : Int, len : Int, +) -> UInt { + if (offset & 7) == 0 { + if len == 32 { + return bs.unsafe_extract_uint_be_aligned(offset >> 3) + } + if len == 16 { + return bs.unsafe_extract_uint16_be_aligned(offset >> 3) + } + } + bs.unsafe_extract_uint_be_slow(offset, len) +} + +///| +/// Outlined slow path of `unsafe_extract_uint_be`, kept out of the entry +/// function so the aligned fast paths above stay small enough to inline. +fn BytesView::unsafe_extract_uint_be_slow( + bs : BytesView, + offset : Int, + len : Int, ) -> UInt { let bytes_needed = (len + 7) / 8 - // TODO: add fast path for aligned case // non-aligned case: extract bytes using unsafe_extract_byte let b0 = bs.unsafe_extract_byte(offset, 8) match bytes_needed { @@ -1058,7 +1156,7 @@ pub fn BytesView::unsafe_extract_uint_be( /// # Invariants /// - It's guaranteed to have at least 5 bytes available for extraction /// - Only reads the necessary number of bytes based on the bit length (5-8 bytes) -/// - For bit lengths < 33, use unsafe_extract_int_le instead +/// - For bit lengths [9..32], use unsafe_extract_uint_le instead /// #internal(experimental, "subject to breaking change without notice") #doc(hidden) @@ -1066,9 +1164,22 @@ pub fn BytesView::unsafe_extract_uint64_le( bs : BytesView, offset : Int, len : Int, +) -> UInt64 { + if len == 64 && (offset & 7) == 0 { + return bs.unsafe_extract_uint64_le_aligned(offset >> 3) + } + bs.unsafe_extract_uint64_le_slow(offset, len) +} + +///| +/// Outlined slow path of `unsafe_extract_uint64_le`, kept out of the entry +/// function so the aligned fast paths above stay small enough to inline. +fn BytesView::unsafe_extract_uint64_le_slow( + bs : BytesView, + offset : Int, + len : Int, ) -> UInt64 { let bytes_needed = (len + 7) / 8 - // TODO: add fast path for aligned case // non-aligned case: extract bytes using unsafe_extract_byte let b0 = bs.unsafe_extract_byte(offset, 8).to_uint64() let b1 = bs.unsafe_extract_byte(offset + 8, 8).to_uint64() @@ -1120,7 +1231,7 @@ pub fn BytesView::unsafe_extract_uint64_le( /// # Invariants /// - It's guaranteed to have at least 5 bytes available for extraction /// - Only reads the necessary number of bytes based on the bit length (5-8 bytes) -/// - For bit lengths < 33, use unsafe_extract_int_be instead +/// - For bit lengths [9..32], use unsafe_extract_uint_be instead /// #internal(experimental, "subject to breaking change without notice") #doc(hidden) @@ -1128,9 +1239,22 @@ pub fn BytesView::unsafe_extract_uint64_be( bs : BytesView, offset : Int, len : Int, +) -> UInt64 { + if len == 64 && (offset & 7) == 0 { + return bs.unsafe_extract_uint64_be_aligned(offset >> 3) + } + bs.unsafe_extract_uint64_be_slow(offset, len) +} + +///| +/// Outlined slow path of `unsafe_extract_uint64_be`, kept out of the entry +/// function so the aligned fast paths above stay small enough to inline. +fn BytesView::unsafe_extract_uint64_be_slow( + bs : BytesView, + offset : Int, + len : Int, ) -> UInt64 { let bytes_needed = (len + 7) / 8 - // TODO: add fast path for aligned case // non-aligned case: extract bytes using unsafe_extract_byte let b0 = bs.unsafe_extract_byte(offset, 8).to_uint64() let b1 = bs.unsafe_extract_byte(offset + 8, 8).to_uint64() diff --git a/builtin/bitstring_extract_bench_test.mbt b/builtin/bitstring_extract_bench_test.mbt new file mode 100644 index 0000000000..362f8969e3 --- /dev/null +++ b/builtin/bitstring_extract_bench_test.mbt @@ -0,0 +1,192 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Benchmarks for the generic bitstring extractors (workload shape from +// PR #3777 by @mizchi): the aligned exact-width fast paths (32/64-bit and +// 16-bit), plus the outlined slow-path cases they must not pessimize — +// unaligned offsets and aligned widths without a fixed-width helper +// (e.g. 24-bit). + +///| +let bitstring_extract_bench_size = 8192 + +///| +fn bitstring_extract_bench_bytes() -> Bytes { + Bytes::makei(bitstring_extract_bench_size, i => (i & 0xff).to_byte()) +} + +///| +fn bitstring_extract_bench_array() -> Array[Byte] { + Array::makei(bitstring_extract_bench_size, i => (i & 0xff).to_byte()) +} + +///| +test "bench BytesView uint_le aligned len=32" (it : @bench.T) { + let view = bitstring_extract_bench_bytes()[:] + let last = bitstring_extract_bench_size - 4 + it.bench(fn() { + let mut acc : UInt = 0 + for byte_offset = 0; byte_offset <= last; byte_offset = byte_offset + 4 { + acc = acc ^ view.unsafe_extract_uint_le(byte_offset * 8, 32) + } + it.keep(acc) + }) +} + +///| +test "bench BytesView uint_be aligned len=32" (it : @bench.T) { + let view = bitstring_extract_bench_bytes()[:] + let last = bitstring_extract_bench_size - 4 + it.bench(fn() { + let mut acc : UInt = 0 + for byte_offset = 0; byte_offset <= last; byte_offset = byte_offset + 4 { + acc = acc ^ view.unsafe_extract_uint_be(byte_offset * 8, 32) + } + it.keep(acc) + }) +} + +///| +test "bench ArrayView uint_le aligned len=32" (it : @bench.T) { + let arr = bitstring_extract_bench_array() + let view = arr[:] + let last = bitstring_extract_bench_size - 4 + it.bench(fn() { + let mut acc : UInt = 0 + for byte_offset = 0; byte_offset <= last; byte_offset = byte_offset + 4 { + acc = acc ^ view.unsafe_extract_uint_le(byte_offset * 8, 32) + } + it.keep(acc) + }) +} + +///| +test "bench ArrayView uint_be aligned len=32" (it : @bench.T) { + let arr = bitstring_extract_bench_array() + let view = arr[:] + let last = bitstring_extract_bench_size - 4 + it.bench(fn() { + let mut acc : UInt = 0 + for byte_offset = 0; byte_offset <= last; byte_offset = byte_offset + 4 { + acc = acc ^ view.unsafe_extract_uint_be(byte_offset * 8, 32) + } + it.keep(acc) + }) +} + +///| +test "bench BytesView uint64_le aligned len=64" (it : @bench.T) { + let view = bitstring_extract_bench_bytes()[:] + let last = bitstring_extract_bench_size - 8 + it.bench(fn() { + let mut acc : UInt64 = 0 + for byte_offset = 0; byte_offset <= last; byte_offset = byte_offset + 8 { + acc = acc ^ view.unsafe_extract_uint64_le(byte_offset * 8, 64) + } + it.keep(acc) + }) +} + +///| +test "bench BytesView uint64_be aligned len=64" (it : @bench.T) { + let view = bitstring_extract_bench_bytes()[:] + let last = bitstring_extract_bench_size - 8 + it.bench(fn() { + let mut acc : UInt64 = 0 + for byte_offset = 0; byte_offset <= last; byte_offset = byte_offset + 8 { + acc = acc ^ view.unsafe_extract_uint64_be(byte_offset * 8, 64) + } + it.keep(acc) + }) +} + +///| +test "bench ArrayView uint64_le aligned len=64" (it : @bench.T) { + let arr = bitstring_extract_bench_array() + let view = arr[:] + let last = bitstring_extract_bench_size - 8 + it.bench(fn() { + let mut acc : UInt64 = 0 + for byte_offset = 0; byte_offset <= last; byte_offset = byte_offset + 8 { + acc = acc ^ view.unsafe_extract_uint64_le(byte_offset * 8, 64) + } + it.keep(acc) + }) +} + +///| +test "bench ArrayView uint64_be aligned len=64" (it : @bench.T) { + let arr = bitstring_extract_bench_array() + let view = arr[:] + let last = bitstring_extract_bench_size - 8 + it.bench(fn() { + let mut acc : UInt64 = 0 + for byte_offset = 0; byte_offset <= last; byte_offset = byte_offset + 8 { + acc = acc ^ view.unsafe_extract_uint64_be(byte_offset * 8, 64) + } + it.keep(acc) + }) +} + +///| +test "bench BytesView uint_le unaligned len=32" (it : @bench.T) { + let view = bitstring_extract_bench_bytes()[:] + let last = bitstring_extract_bench_size - 5 + it.bench(fn() { + let mut acc : UInt = 0 + for byte_offset = 0; byte_offset <= last; byte_offset = byte_offset + 4 { + acc = acc ^ view.unsafe_extract_uint_le(byte_offset * 8 + 4, 32) + } + it.keep(acc) + }) +} + +///| +test "bench BytesView uint64_le unaligned len=64" (it : @bench.T) { + let view = bitstring_extract_bench_bytes()[:] + let last = bitstring_extract_bench_size - 9 + it.bench(fn() { + let mut acc : UInt64 = 0 + for byte_offset = 0; byte_offset <= last; byte_offset = byte_offset + 8 { + acc = acc ^ view.unsafe_extract_uint64_le(byte_offset * 8 + 4, 64) + } + it.keep(acc) + }) +} + +///| +test "bench BytesView uint_le aligned len=16" (it : @bench.T) { + let view = bitstring_extract_bench_bytes()[:] + let last = bitstring_extract_bench_size - 2 + it.bench(fn() { + let mut acc : UInt = 0 + for byte_offset = 0; byte_offset <= last; byte_offset = byte_offset + 2 { + acc = acc ^ view.unsafe_extract_uint_le(byte_offset * 8, 16) + } + it.keep(acc) + }) +} + +///| +test "bench BytesView uint_le aligned len=24" (it : @bench.T) { + let view = bitstring_extract_bench_bytes()[:] + let last = bitstring_extract_bench_size - 3 + it.bench(fn() { + let mut acc : UInt = 0 + for byte_offset = 0; byte_offset <= last; byte_offset = byte_offset + 3 { + acc = acc ^ view.unsafe_extract_uint_le(byte_offset * 8, 24) + } + it.keep(acc) + }) +} diff --git a/builtin/bitstring_extract_equiv_test.mbt b/builtin/bitstring_extract_equiv_test.mbt new file mode 100644 index 0000000000..90df7d3dd5 --- /dev/null +++ b/builtin/bitstring_extract_equiv_test.mbt @@ -0,0 +1,111 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Equivalence sweep for the generic bitstring integer extractors: every +// (offset, len) combination — aligned and unaligned, exact-width and +// partial — is checked against a byte-composition reference built from +// `unsafe_extract_byte`, so the aligned fast paths and the outlined slow +// paths must agree with the specified bit semantics everywhere. + +///| +fn extract_ref_le(read : (Int, Int) -> UInt, offset : Int, len : Int) -> UInt64 { + let n = (len + 7) / 8 + let mut acc : UInt64 = 0 + for k in 0.. UInt, offset : Int, len : Int) -> UInt64 { + let n = (len + 7) / 8 + let mut acc : UInt64 = 0 + for k in 0.. ((i * 37 + 11) & 0xff).to_byte()) + let arr = Array::makei(32, i => ((i * 37 + 11) & 0xff).to_byte()) + // both zero-origin views and a nonzero-origin subview, so the aligned + // helpers' start-offset handling is exercised too + let bv = bytes[:] + let av = arr[:] + let bv2 = bytes[3:] + let av2 = arr[3:] + let bv_read = (o : Int, l : Int) => bv.unsafe_extract_byte(o, l) + let av_read = (o : Int, l : Int) => av.unsafe_extract_byte(o, l) + let bv2_read = (o : Int, l : Int) => bv2.unsafe_extract_byte(o, l) + let av2_read = (o : Int, l : Int) => av2.unsafe_extract_byte(o, l) + for offset in 0..<=16 { + for len in 9..<=32 { + assert_eq( + bv.unsafe_extract_uint_le(offset, len).to_uint64(), + extract_ref_le(bv_read, offset, len), + ) + assert_eq( + bv.unsafe_extract_uint_be(offset, len).to_uint64(), + extract_ref_be(bv_read, offset, len), + ) + assert_eq( + av.unsafe_extract_uint_le(offset, len).to_uint64(), + extract_ref_le(av_read, offset, len), + ) + assert_eq( + av.unsafe_extract_uint_be(offset, len).to_uint64(), + extract_ref_be(av_read, offset, len), + ) + assert_eq( + bv2.unsafe_extract_uint_le(offset, len).to_uint64(), + extract_ref_le(bv2_read, offset, len), + ) + assert_eq( + av2.unsafe_extract_uint_be(offset, len).to_uint64(), + extract_ref_be(av2_read, offset, len), + ) + } + for len in 33..<=64 { + assert_eq( + bv.unsafe_extract_uint64_le(offset, len), + extract_ref_le(bv_read, offset, len), + ) + assert_eq( + bv.unsafe_extract_uint64_be(offset, len), + extract_ref_be(bv_read, offset, len), + ) + assert_eq( + av.unsafe_extract_uint64_le(offset, len), + extract_ref_le(av_read, offset, len), + ) + assert_eq( + av.unsafe_extract_uint64_be(offset, len), + extract_ref_be(av_read, offset, len), + ) + assert_eq( + bv2.unsafe_extract_uint64_le(offset, len), + extract_ref_le(bv2_read, offset, len), + ) + assert_eq( + av2.unsafe_extract_uint64_be(offset, len), + extract_ref_be(av2_read, offset, len), + ) + } + } +} diff --git a/builtin/bytes.mbt b/builtin/bytes.mbt index 2acb1a3a18..5a6fa86a6b 100644 --- a/builtin/bytes.mbt +++ b/builtin/bytes.mbt @@ -149,7 +149,7 @@ pub fn FixedArray::blit_from_string( fn unsafe_from_bytes(bytes : Bytes) -> FixedArray[Byte] = "%identity" ///| -/// Copy `length` chars from byte sequence `src`, starting at `src_offset`, +/// Copy `length` bytes from byte sequence `src`, starting at `src_offset`, /// into byte sequence `self`, starting at `bytes_offset`. pub fn FixedArray::blit_from_bytes( self : FixedArray[Byte], @@ -396,16 +396,7 @@ pub impl Compare for Bytes with fn compare(self, other) { if cmp != 0 { return cmp } - for i in 0.. Bytes { /// /// Parameters: /// -/// * `array` : A fixed-size array of bytes to be converted into a bytes +/// * `arr` : A fixed-size array of bytes to be converted into a bytes /// sequence. -/// * `length` : (Optional) The length of the resulting bytes sequence. If not +/// * `len` : (Optional) The length of the resulting bytes sequence. If not /// provided, uses the full length of the input array. /// /// Returns a new bytes sequence containing the bytes from the input array. If a @@ -596,7 +587,7 @@ pub fn BytesView::to_fixedarray(self : BytesView) -> FixedArray[Byte] { /// /// ```mbt check /// test { -/// let iter = Iter::singleton(b'h') +/// let iter = [|b'h'|] /// let bytes = Bytes::from_iter(iter) /// inspect( /// bytes, @@ -767,17 +758,18 @@ pub fn Bytes::is_empty(self : Bytes) -> Bool { } ///| -/// Retrieves a byte from the view at the specified index. +/// Retrieves a byte from the byte sequence at the specified index. /// /// Parameters: /// -/// * `self` : The bytes view to retrieve the byte from. -/// * `index` : The position in the view from which to retrieve the byte. +/// * `self` : The byte sequence to retrieve the byte from. +/// * `index` : The position in the byte sequence from which to retrieve the +/// byte. /// /// Returns the byte at the specified index, or None if the index is out of bounds. /// /// Example: -/// +/// /// ```mbt check /// test { /// let bytes = b"\x01\x02\x03" @@ -793,6 +785,7 @@ pub fn Bytes::is_empty(self : Bytes) -> Bool { /// debug_inspect(byte, content="None") /// } /// ``` +#intrinsic("%bytes.get_opt") pub fn Bytes::get(self : Bytes, index : Int) -> Byte? { guard index >= 0 && index < self.length() else { None } Some(self[index]) @@ -805,8 +798,39 @@ pub fn Bytes::get(self : Bytes, index : Int) -> Byte? { /// /// * `self` : The first bytes sequence. /// * `other` : The second bytes sequence. -/// TODO: marked as intrinsic, inline if it is constant +/// +/// Returns a new bytes sequence containing the bytes of `self` followed by the +/// bytes of `other`. +// TODO: marked as intrinsic, inline if it is constant pub impl Add for Bytes with fn add(self : Bytes, other : Bytes) -> Bytes { + self.bytes_add_impl(other) +} + +///| +#cfg(not(target="js")) +fn Bytes::bytes_add_impl(self : Bytes, other : Bytes) -> Bytes { + let len_self = self.length() + let len_other = other.length() + let rv = UninitializedArray::unsafe_make_and_blit_from_fixed( + unsafe_from_bytes(self), + len_self + len_other, + 0, + 0, + len_self, + ) + UninitializedArray::unsafe_blit_fixed( + rv, + len_self, + unsafe_from_bytes(other), + 0, + len_other, + ) + unsafe_to_bytes(buffer_to_fixedarray(rv)) +} + +///| +#cfg(target="js") +fn Bytes::bytes_add_impl(self : Bytes, other : Bytes) -> Bytes { let len_self = self.length() let len_other = other.length() let rv : FixedArray[Byte] = FixedArray::make(len_self + len_other, 0) diff --git a/builtin/bytes_add_bench_test.mbt b/builtin/bytes_add_bench_test.mbt new file mode 100644 index 0000000000..5fe4900847 --- /dev/null +++ b/builtin/bytes_add_bench_test.mbt @@ -0,0 +1,20 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +test "bench Bytes add total=1000000" (it : @bench.T) { + let bytes_add_left : Bytes = Bytes::make(500000, b'a') + let bytes_add_right : Bytes = Bytes::make(500000, b'b') + it.bench(fn() { it.keep(bytes_add_left + bytes_add_right) }) +} diff --git a/builtin/bytes_output_bench_test.mbt b/builtin/bytes_output_bench_test.mbt new file mode 100644 index 0000000000..9bfea5bb8e --- /dev/null +++ b/builtin/bytes_output_bench_test.mbt @@ -0,0 +1,26 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +let bytes_output_binary : Bytes = Bytes::makei(100000, i => i.to_byte()) + +///| +test "bench Bytes Show binary n=100000" (it : @bench.T) { + it.bench(fn() { it.keep(bytes_output_binary.to_string()) }) +} + +///| +test "bench Bytes ToJson binary n=100000" (it : @bench.T) { + it.bench(fn() { it.keep(@json.to_json(bytes_output_binary)) }) +} diff --git a/builtin/bytes_test.mbt b/builtin/bytes_test.mbt index 3d69042f4a..1d54f71394 100644 --- a/builtin/bytes_test.mbt +++ b/builtin/bytes_test.mbt @@ -26,6 +26,13 @@ test "json repr" { ) } +///| +test "Bytes output writes lowercase hex digits directly" { + let bytes = b"\x00\x09\x0a\x0f\x10\x7f\x80\xff" + inspect(bytes, content="b\"\\x00\\x09\\x0a\\x0f\\x10\\x7f\\x80\\xff\"") + @json.json_inspect(bytes, content="\\x00\\x09\\x0a\\x0f\\x10\\x7f\\x80\\xff") +} + ///| test "to_string" { @test.assert_eq(HELLO_WORLD.to_unchecked_string(), "Hello, World!") @@ -271,6 +278,19 @@ test "Bytes::add and repeat" { ) } +///| +test "Bytes add initializes the complete result" { + @test.assert_eq(b"" + b"", b"") + @test.assert_eq(b"left" + b"", b"left") + @test.assert_eq(b"" + b"right", b"right") + let left = Bytes::makei(257, i => i.to_byte()) + let right = Bytes::makei(259, i => (255 - i).to_byte()) + let joined = left + right + @test.assert_eq(joined.length(), 516) + @test.assert_eq(joined[0:257].to_owned(), left) + @test.assert_eq(joined[257:].to_owned(), right) +} + ///| test "panic Bytes::repeat negative" { let _ = b"ab".repeat(-1) @@ -416,6 +436,25 @@ test "BytesView::lexical_compare/basic" { inspect(long_lower[:].lexical_compare(short_higher), content="-1") } +///| +test "Bytes and BytesView Compare preserve shortlex ordering" { + let equal_left = Bytes::makei(64, i => i.to_byte()) + let equal_right = Bytes::makei(64, i => i.to_byte()) + inspect(equal_left.compare(equal_right), content="0") + + let lower = Bytes::makei(64, i => if i == 63 { b'\x01' } else { b'\x00' }) + let higher = Bytes::makei(64, i => if i == 63 { b'\x02' } else { b'\x00' }) + inspect(lower.compare(higher), content="-1") + inspect(higher.compare(lower), content="1") + + let padded_lower = b"x" + lower + b"y" + let padded_higher = b"z" + higher + b"w" + inspect(padded_lower[1:65].compare(padded_higher[1:65]), content="-1") + + // Shortlex compares length before contents. + inspect(b"\xff".compare(b"\x00\x00"), content="-1") +} + ///| test "Bytes find and rev_find" { let target = b"abcabc" diff --git a/builtin/bytes_unsafe.mbt b/builtin/bytes_unsafe.mbt index 413852e8ea..da50be8214 100644 --- a/builtin/bytes_unsafe.mbt +++ b/builtin/bytes_unsafe.mbt @@ -43,9 +43,14 @@ pub fn FixedArray::unsafe_write_uint64_le( index : Int, value : UInt64, ) -> Unit { - for i in 0..<=7 { - bytes.unsafe_set(i + index, (value >> (8 * i)).to_byte()) - } + bytes.unsafe_set(index, value.to_byte()) + bytes.unsafe_set(index + 1, (value >> 8).to_byte()) + bytes.unsafe_set(index + 2, (value >> 16).to_byte()) + bytes.unsafe_set(index + 3, (value >> 24).to_byte()) + bytes.unsafe_set(index + 4, (value >> 32).to_byte()) + bytes.unsafe_set(index + 5, (value >> 40).to_byte()) + bytes.unsafe_set(index + 6, (value >> 48).to_byte()) + bytes.unsafe_set(index + 7, (value >> 56).to_byte()) } ///| @@ -77,9 +82,14 @@ pub fn FixedArray::unsafe_write_uint64_be( index : Int, value : UInt64, ) -> Unit { - for i in 0..<=7 { - bytes.unsafe_set(i + index, (value >> (8 * (7 - i))).to_byte()) - } + bytes.unsafe_set(index, (value >> 56).to_byte()) + bytes.unsafe_set(index + 1, (value >> 48).to_byte()) + bytes.unsafe_set(index + 2, (value >> 40).to_byte()) + bytes.unsafe_set(index + 3, (value >> 32).to_byte()) + bytes.unsafe_set(index + 4, (value >> 24).to_byte()) + bytes.unsafe_set(index + 5, (value >> 16).to_byte()) + bytes.unsafe_set(index + 6, (value >> 8).to_byte()) + bytes.unsafe_set(index + 7, value.to_byte()) } ///| @@ -111,9 +121,10 @@ pub fn FixedArray::unsafe_write_uint32_le( index : Int, value : UInt, ) -> Unit { - for i in 0..<=3 { - bytes.unsafe_set(i + index, (value >> (8 * i)).to_byte()) - } + bytes.unsafe_set(index, value.to_byte()) + bytes.unsafe_set(index + 1, (value >> 8).to_byte()) + bytes.unsafe_set(index + 2, (value >> 16).to_byte()) + bytes.unsafe_set(index + 3, (value >> 24).to_byte()) } ///| @@ -145,9 +156,10 @@ pub fn FixedArray::unsafe_write_uint32_be( index : Int, value : UInt, ) -> Unit { - for i in 0..<=3 { - bytes.unsafe_set(i + index, (value >> (8 * (3 - i))).to_byte()) - } + bytes.unsafe_set(index, (value >> 24).to_byte()) + bytes.unsafe_set(index + 1, (value >> 16).to_byte()) + bytes.unsafe_set(index + 2, (value >> 8).to_byte()) + bytes.unsafe_set(index + 3, value.to_byte()) } ///| @@ -177,9 +189,8 @@ pub fn FixedArray::unsafe_write_uint16_le( index : Int, value : UInt16, ) -> Unit { - for i in 0..<=1 { - bytes.unsafe_set(i + index, (value >> (8 * i)).to_byte()) - } + bytes.unsafe_set(index, value.to_byte()) + bytes.unsafe_set(index + 1, (value >> 8).to_byte()) } ///| @@ -209,9 +220,8 @@ pub fn FixedArray::unsafe_write_uint16_be( index : Int, value : UInt16, ) -> Unit { - for i in 0..<=1 { - bytes.unsafe_set(i + index, (value >> (8 * (1 - i))).to_byte()) - } + bytes.unsafe_set(index, (value >> 8).to_byte()) + bytes.unsafe_set(index + 1, value.to_byte()) } // #endregion @@ -242,11 +252,14 @@ pub fn FixedArray::unsafe_write_uint16_be( #intrinsic("%bytes.unsafe_read_uint64_le") #doc(hidden) pub fn Bytes::unsafe_read_uint64_le(bytes : Bytes, index : Int) -> UInt64 { - for i in 0..<=7; result = (0 : UInt64) { - continue result | (bytes.unsafe_get(i + index).to_uint64() << (8 * i)) - } nobreak { - result - } + bytes.unsafe_get(index).to_uint64() | + (bytes.unsafe_get(index + 1).to_uint64() << 8) | + (bytes.unsafe_get(index + 2).to_uint64() << 16) | + (bytes.unsafe_get(index + 3).to_uint64() << 24) | + (bytes.unsafe_get(index + 4).to_uint64() << 32) | + (bytes.unsafe_get(index + 5).to_uint64() << 40) | + (bytes.unsafe_get(index + 6).to_uint64() << 48) | + (bytes.unsafe_get(index + 7).to_uint64() << 56) } ///| @@ -274,11 +287,14 @@ pub fn Bytes::unsafe_read_uint64_le(bytes : Bytes, index : Int) -> UInt64 { #intrinsic("%bytes.unsafe_read_uint64_be") #doc(hidden) pub fn Bytes::unsafe_read_uint64_be(bytes : Bytes, index : Int) -> UInt64 { - for i in 0..<=7; result = (0 : UInt64) { - continue result | (bytes.unsafe_get(i + index).to_uint64() << (8 * (7 - i))) - } nobreak { - result - } + (bytes.unsafe_get(index).to_uint64() << 56) | + (bytes.unsafe_get(index + 1).to_uint64() << 48) | + (bytes.unsafe_get(index + 2).to_uint64() << 40) | + (bytes.unsafe_get(index + 3).to_uint64() << 32) | + (bytes.unsafe_get(index + 4).to_uint64() << 24) | + (bytes.unsafe_get(index + 5).to_uint64() << 16) | + (bytes.unsafe_get(index + 6).to_uint64() << 8) | + bytes.unsafe_get(index + 7).to_uint64() } ///| @@ -306,11 +322,10 @@ pub fn Bytes::unsafe_read_uint64_be(bytes : Bytes, index : Int) -> UInt64 { #intrinsic("%bytes.unsafe_read_uint32_le") #doc(hidden) pub fn Bytes::unsafe_read_uint32_le(bytes : Bytes, index : Int) -> UInt { - for i in 0..<=3; result = (0 : UInt) { - continue result | (bytes.unsafe_get(i + index).to_uint() << (8 * i)) - } nobreak { - result - } + bytes.unsafe_get(index).to_uint() | + (bytes.unsafe_get(index + 1).to_uint() << 8) | + (bytes.unsafe_get(index + 2).to_uint() << 16) | + (bytes.unsafe_get(index + 3).to_uint() << 24) } ///| @@ -338,11 +353,10 @@ pub fn Bytes::unsafe_read_uint32_le(bytes : Bytes, index : Int) -> UInt { #intrinsic("%bytes.unsafe_read_uint32_be") #doc(hidden) pub fn Bytes::unsafe_read_uint32_be(bytes : Bytes, index : Int) -> UInt { - for i in 0..<=3; result = (0 : UInt) { - continue result | (bytes.unsafe_get(i + index).to_uint() << (8 * (3 - i))) - } nobreak { - result - } + (bytes.unsafe_get(index).to_uint() << 24) | + (bytes.unsafe_get(index + 1).to_uint() << 16) | + (bytes.unsafe_get(index + 2).to_uint() << 8) | + bytes.unsafe_get(index + 3).to_uint() } ///| @@ -368,11 +382,8 @@ pub fn Bytes::unsafe_read_uint32_be(bytes : Bytes, index : Int) -> UInt { #intrinsic("%bytes.unsafe_read_uint16_le") #doc(hidden) pub fn Bytes::unsafe_read_uint16_le(bytes : Bytes, index : Int) -> UInt16 { - for i in 0..<=1; result = (0 : UInt16) { - continue result | (bytes.unsafe_get(i + index).to_uint16() << (8 * i)) - } nobreak { - result - } + bytes.unsafe_get(index).to_uint16() | + (bytes.unsafe_get(index + 1).to_uint16() << 8) } ///| @@ -398,11 +409,8 @@ pub fn Bytes::unsafe_read_uint16_le(bytes : Bytes, index : Int) -> UInt16 { #intrinsic("%bytes.unsafe_read_uint16_be") #doc(hidden) pub fn Bytes::unsafe_read_uint16_be(bytes : Bytes, index : Int) -> UInt16 { - for i in 0..<=1; result = (0 : UInt16) { - continue result | (bytes.unsafe_get(i + index).to_uint16() << (8 * (1 - i))) - } nobreak { - result - } + (bytes.unsafe_get(index).to_uint16() << 8) | + bytes.unsafe_get(index + 1).to_uint16() } ///| @@ -596,66 +604,64 @@ fn buffer_to_fixedarray(buf : UninitializedArray[Byte]) -> FixedArray[Byte] = "% #borrow(bytes) #intrinsic("%bytes.unsafe_read_uint16_le") fn fixedarray_read_uint16_le(bytes : FixedArray[Byte], index : Int) -> UInt16 { - for i in 0..<=1; result = (0 : UInt16) { - continue result | (bytes.unsafe_get(i + index).to_uint16() << (8 * i)) - } nobreak { - result - } + bytes.unsafe_get(index).to_uint16() | + (bytes.unsafe_get(index + 1).to_uint16() << 8) } ///| #borrow(bytes) #intrinsic("%bytes.unsafe_read_uint16_be") fn fixedarray_read_uint16_be(bytes : FixedArray[Byte], index : Int) -> UInt16 { - for i in 0..<=1; result = (0 : UInt16) { - continue result | (bytes.unsafe_get(i + index).to_uint16() << (8 * (1 - i))) - } nobreak { - result - } + (bytes.unsafe_get(index).to_uint16() << 8) | + bytes.unsafe_get(index + 1).to_uint16() } ///| #borrow(bytes) #intrinsic("%bytes.unsafe_read_uint32_le") fn fixedarray_read_uint32_le(bytes : FixedArray[Byte], index : Int) -> UInt { - for i in 0..<=3; result = (0 : UInt) { - continue result | (bytes.unsafe_get(i + index).to_uint() << (8 * i)) - } nobreak { - result - } + bytes.unsafe_get(index).to_uint() | + (bytes.unsafe_get(index + 1).to_uint() << 8) | + (bytes.unsafe_get(index + 2).to_uint() << 16) | + (bytes.unsafe_get(index + 3).to_uint() << 24) } ///| #borrow(bytes) #intrinsic("%bytes.unsafe_read_uint32_be") fn fixedarray_read_uint32_be(bytes : FixedArray[Byte], index : Int) -> UInt { - for i in 0..<=3; result = (0 : UInt) { - continue result | (bytes.unsafe_get(i + index).to_uint() << (8 * (3 - i))) - } nobreak { - result - } + (bytes.unsafe_get(index).to_uint() << 24) | + (bytes.unsafe_get(index + 1).to_uint() << 16) | + (bytes.unsafe_get(index + 2).to_uint() << 8) | + bytes.unsafe_get(index + 3).to_uint() } ///| #borrow(bytes) #intrinsic("%bytes.unsafe_read_uint64_le") fn fixedarray_read_uint64_le(bytes : FixedArray[Byte], index : Int) -> UInt64 { - for i in 0..<=7; result = (0 : UInt64) { - continue result | (bytes.unsafe_get(i + index).to_uint64() << (8 * i)) - } nobreak { - result - } + bytes.unsafe_get(index).to_uint64() | + (bytes.unsafe_get(index + 1).to_uint64() << 8) | + (bytes.unsafe_get(index + 2).to_uint64() << 16) | + (bytes.unsafe_get(index + 3).to_uint64() << 24) | + (bytes.unsafe_get(index + 4).to_uint64() << 32) | + (bytes.unsafe_get(index + 5).to_uint64() << 40) | + (bytes.unsafe_get(index + 6).to_uint64() << 48) | + (bytes.unsafe_get(index + 7).to_uint64() << 56) } ///| #borrow(bytes) #intrinsic("%bytes.unsafe_read_uint64_be") fn fixedarray_read_uint64_be(bytes : FixedArray[Byte], index : Int) -> UInt64 { - for i in 0..<=7; result = (0 : UInt64) { - continue result | (bytes.unsafe_get(i + index).to_uint64() << (8 * (7 - i))) - } nobreak { - result - } + (bytes.unsafe_get(index).to_uint64() << 56) | + (bytes.unsafe_get(index + 1).to_uint64() << 48) | + (bytes.unsafe_get(index + 2).to_uint64() << 40) | + (bytes.unsafe_get(index + 3).to_uint64() << 32) | + (bytes.unsafe_get(index + 4).to_uint64() << 24) | + (bytes.unsafe_get(index + 5).to_uint64() << 16) | + (bytes.unsafe_get(index + 6).to_uint64() << 8) | + bytes.unsafe_get(index + 7).to_uint64() } // #endregion diff --git a/builtin/bytes_unsafe_bench_test.mbt b/builtin/bytes_unsafe_bench_test.mbt new file mode 100644 index 0000000000..bcd977c891 --- /dev/null +++ b/builtin/bytes_unsafe_bench_test.mbt @@ -0,0 +1,97 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Benchmarks for the `#intrinsic` fallback bodies in `bytes_unsafe.mbt`. +// +// These are lowered at the call site on native, so these numbers are expected +// to be flat there; on wasm-gc and js the fallback body is what actually runs. +// Only the little-endian variants are measured — the big-endian ones differ +// solely in the constant shift amounts and compile to the same shape. + +///| +let bytes_unsafe_bench_len = 4096 + +///| +fn bytes_unsafe_bench_data() -> Bytes { + Bytes::makei(bytes_unsafe_bench_len, i => ((i * 37 + 11) & 0xff).to_byte()) +} + +///| +test "bench Bytes::unsafe_read_uint16_le n=4096" (it : @bench.T) { + let data = bytes_unsafe_bench_data() + it.bench(fn() { + let mut acc = (0 : UInt16) + for i in 0..<(bytes_unsafe_bench_len - 1) { + acc = acc + data.unsafe_read_uint16_le(i) + } + it.keep(acc) + }) +} + +///| +test "bench Bytes::unsafe_read_uint32_le n=4096" (it : @bench.T) { + let data = bytes_unsafe_bench_data() + it.bench(fn() { + let mut acc = 0U + for i in 0..<(bytes_unsafe_bench_len - 3) { + acc = acc + data.unsafe_read_uint32_le(i) + } + it.keep(acc) + }) +} + +///| +test "bench Bytes::unsafe_read_uint64_le n=4096" (it : @bench.T) { + let data = bytes_unsafe_bench_data() + it.bench(fn() { + let mut acc = 0UL + for i in 0..<(bytes_unsafe_bench_len - 7) { + acc = acc + data.unsafe_read_uint64_le(i) + } + it.keep(acc) + }) +} + +///| +test "bench FixedArray::unsafe_write_uint16_le n=4096" (it : @bench.T) { + let buf = FixedArray::make(bytes_unsafe_bench_len, b'\x00') + it.bench(fn() { + for i in 0..<(bytes_unsafe_bench_len - 1) { + buf.unsafe_write_uint16_le(i, (i & 0xffff).to_uint16()) + } + it.keep(buf[0]) + }) +} + +///| +test "bench FixedArray::unsafe_write_uint32_le n=4096" (it : @bench.T) { + let buf = FixedArray::make(bytes_unsafe_bench_len, b'\x00') + it.bench(fn() { + for i in 0..<(bytes_unsafe_bench_len - 3) { + buf.unsafe_write_uint32_le(i, i.reinterpret_as_uint()) + } + it.keep(buf[0]) + }) +} + +///| +test "bench FixedArray::unsafe_write_uint64_le n=4096" (it : @bench.T) { + let buf = FixedArray::make(bytes_unsafe_bench_len, b'\x00') + it.bench(fn() { + for i in 0..<(bytes_unsafe_bench_len - 7) { + buf.unsafe_write_uint64_le(i, i.to_uint64()) + } + it.keep(buf[0]) + }) +} diff --git a/builtin/bytes_unsafe_unroll_wbtest.mbt b/builtin/bytes_unsafe_unroll_wbtest.mbt new file mode 100644 index 0000000000..a0877d394b --- /dev/null +++ b/builtin/bytes_unsafe_unroll_wbtest.mbt @@ -0,0 +1,212 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Equivalence tests for the unrolled `#intrinsic` fallback bodies in +// `bytes_unsafe.mbt`. Each `ref_*` function below is the loop form those +// bodies used to have, kept here as an independent oracle: the unrolled +// implementations must agree with it at every offset, for every sample value. +// +// These matter because the fallbacks are dead code on native (the intrinsics +// are lowered at the call site) but are the real implementation on wasm-gc and +// js, so a byte-order slip would only surface on two of three backends. + +///| +/// `builtin` cannot depend on `@test`, so `assert_eq` is not available here; +/// `assert_true` is the only assertion in scope. +fn[T : Eq] check(actual : T, expected : T) -> Unit raise { + assert_true(actual == expected) +} + +///| +fn ref_read_u16_le(b : Bytes, index : Int) -> UInt16 { + for i in 0..<=1; result = (0 : UInt16) { + continue result | (b[i + index].to_uint16() << (8 * i)) + } nobreak { + result + } +} + +///| +fn ref_read_u16_be(b : Bytes, index : Int) -> UInt16 { + for i in 0..<=1; result = (0 : UInt16) { + continue result | (b[i + index].to_uint16() << (8 * (1 - i))) + } nobreak { + result + } +} + +///| +fn ref_read_u32_le(b : Bytes, index : Int) -> UInt { + for i in 0..<=3; result = (0 : UInt) { + continue result | (b[i + index].to_uint() << (8 * i)) + } nobreak { + result + } +} + +///| +fn ref_read_u32_be(b : Bytes, index : Int) -> UInt { + for i in 0..<=3; result = (0 : UInt) { + continue result | (b[i + index].to_uint() << (8 * (3 - i))) + } nobreak { + result + } +} + +///| +fn ref_read_u64_le(b : Bytes, index : Int) -> UInt64 { + for i in 0..<=7; result = (0 : UInt64) { + continue result | (b[i + index].to_uint64() << (8 * i)) + } nobreak { + result + } +} + +///| +fn ref_read_u64_be(b : Bytes, index : Int) -> UInt64 { + for i in 0..<=7; result = (0 : UInt64) { + continue result | (b[i + index].to_uint64() << (8 * (7 - i))) + } nobreak { + result + } +} + +///| +/// A byte pattern with every bit position exercised, plus 0x00 and 0xFF runs so +/// sign-extension and zero-extension slips show up. +fn sample_bytes(len : Int) -> Bytes { + Bytes::makei(len, i => { + match i % 8 { + 0 => b'\x00' + 1 => b'\xFF' + 2 => b'\x80' + 3 => b'\x01' + 4 => b'\x7F' + 5 => b'\xAA' + 6 => b'\x55' + _ => (i * 31 + 7).to_byte() + } + }) +} + +///| +test "unrolled Bytes reads agree with the loop form at every offset" { + let len = 64 + let b = sample_bytes(len) + for i in 0..<(len - 1) { + check(b.unsafe_read_uint16_le(i), ref_read_u16_le(b, i)) + check(b.unsafe_read_uint16_be(i), ref_read_u16_be(b, i)) + } + for i in 0..<(len - 3) { + check(b.unsafe_read_uint32_le(i), ref_read_u32_le(b, i)) + check(b.unsafe_read_uint32_be(i), ref_read_u32_be(b, i)) + } + for i in 0..<(len - 7) { + check(b.unsafe_read_uint64_le(i), ref_read_u64_le(b, i)) + check(b.unsafe_read_uint64_be(i), ref_read_u64_be(b, i)) + } +} + +///| +/// The private `fixedarray_read_*` helpers carry their own copies of the same +/// unrolled bodies, so they need their own check. +test "unrolled FixedArray reads agree with the loop form at every offset" { + let len = 64 + let b = sample_bytes(len) + let fa = FixedArray::makei(len, i => b[i]) + for i in 0..<(len - 1) { + check(fixedarray_read_uint16_le(fa, i), ref_read_u16_le(b, i)) + check(fixedarray_read_uint16_be(fa, i), ref_read_u16_be(b, i)) + } + for i in 0..<(len - 3) { + check(fixedarray_read_uint32_le(fa, i), ref_read_u32_le(b, i)) + check(fixedarray_read_uint32_be(fa, i), ref_read_u32_be(b, i)) + } + for i in 0..<(len - 7) { + check(fixedarray_read_uint64_le(fa, i), ref_read_u64_le(b, i)) + check(fixedarray_read_uint64_be(fa, i), ref_read_u64_be(b, i)) + } +} + +///| +let u64_samples : Array[UInt64] = [ + 0, 1, 0xFF, 0x100, 0x7FFF_FFFF, 0x8000_0000, 0xFFFF_FFFF, 0x1_0000_0000, 0x0123_4567_89AB_CDEF, + 0xFEDC_BA98_7654_3210, 0x7FFF_FFFF_FFFF_FFFF, 0x8000_0000_0000_0000, 0xFFFF_FFFF_FFFF_FFFF, +] + +///| +/// Writes go through the unrolled `unsafe_write_*`; reading them back with the +/// independent `ref_read_*` oracle pins the byte order in both directions. +test "unrolled writes round-trip through the loop-form readers" { + let len = 32 + for value in u64_samples { + let v32 = value.to_uint() + let v16 = value.to_uint16() + for i in 0..<(len - 7) { + let fa = FixedArray::make(len, b'\x00') + fa.unsafe_write_uint64_le(i, value) + check(ref_read_u64_le(unsafe_to_bytes(fa), i), value) + let fa = FixedArray::make(len, b'\x00') + fa.unsafe_write_uint64_be(i, value) + check(ref_read_u64_be(unsafe_to_bytes(fa), i), value) + } + for i in 0..<(len - 3) { + let fa = FixedArray::make(len, b'\x00') + fa.unsafe_write_uint32_le(i, v32) + check(ref_read_u32_le(unsafe_to_bytes(fa), i), v32) + let fa = FixedArray::make(len, b'\x00') + fa.unsafe_write_uint32_be(i, v32) + check(ref_read_u32_be(unsafe_to_bytes(fa), i), v32) + } + for i in 0..<(len - 1) { + let fa = FixedArray::make(len, b'\x00') + fa.unsafe_write_uint16_le(i, v16) + check(ref_read_u16_le(unsafe_to_bytes(fa), i), v16) + let fa = FixedArray::make(len, b'\x00') + fa.unsafe_write_uint16_be(i, v16) + check(ref_read_u16_be(unsafe_to_bytes(fa), i), v16) + } + } +} + +///| +/// A write must touch exactly its own bytes and leave the neighbours alone. +/// All six writers are covered, since each carries its own unrolled body. +test "unrolled writes do not disturb neighbouring bytes" { + let len = 32 + let fill = b'\x3C' + fn check_span( + width : Int, + write : (FixedArray[Byte], Int) -> Unit, + ) -> Unit raise { + for i in 0..<(len - width + 1) { + let fa = FixedArray::make(len, fill) + write(fa, i) + for j in 0..= i + width { + check(fa[j], fill) + } else { + check(fa[j], b'\xFF') + } + } + } + } + + check_span(8, (fa, i) => fa.unsafe_write_uint64_le(i, 0xFFFF_FFFF_FFFF_FFFF)) + check_span(8, (fa, i) => fa.unsafe_write_uint64_be(i, 0xFFFF_FFFF_FFFF_FFFF)) + check_span(4, (fa, i) => fa.unsafe_write_uint32_le(i, 0xFFFF_FFFF)) + check_span(4, (fa, i) => fa.unsafe_write_uint32_be(i, 0xFFFF_FFFF)) + check_span(2, (fa, i) => fa.unsafe_write_uint16_le(i, 0xFFFF)) + check_span(2, (fa, i) => fa.unsafe_write_uint16_be(i, 0xFFFF)) +} diff --git a/builtin/bytesview.mbt b/builtin/bytesview.mbt index 7f8a8a07c3..cdb496fef3 100644 --- a/builtin/bytesview.mbt +++ b/builtin/bytesview.mbt @@ -72,7 +72,10 @@ pub fn BytesView::is_empty(self : BytesView) -> Bool { /// * `self` : The bytes view to retrieve the byte from. /// * `index` : The position in the view from which to retrieve the byte. /// -/// Returns the byte at the specified index if the index is valid. +/// Returns the byte at the specified index. +/// +/// Throws a runtime error if the index is out of bounds (less than 0 or greater +/// than or equal to the length of the view). /// /// Example: /// @@ -121,6 +124,7 @@ pub fn BytesView::at(self : BytesView, index : Int) -> Byte { /// debug_inspect(result, content="None") /// } /// ``` +#intrinsic("%bytesview.get_opt") pub fn BytesView::get(self : BytesView, index : Int) -> Byte? { guard index >= 0 && index < self.length() else { None } Some(self.bytes().unsafe_get(self.start() + index)) @@ -173,8 +177,9 @@ pub fn BytesView::unsafe_get(self : BytesView, index : Int) -> Byte { /// @test.assert_eq(bv[2], b'\x03') /// } /// ``` +#intrinsic("%bytes.view") #alias("_[_:_]") -#alias(sub, deprecated="Use _[_:_ instead") +#alias(sub, deprecated="Use _[_:_] instead") pub fn Bytes::view(self : Bytes, start? : Int = 0, end? : Int) -> BytesView { let len = self.length() let end = match end { @@ -246,6 +251,7 @@ pub fn BytesView::get_view( /// @test.assert_eq(bv2[1], b'\x02') /// } /// ``` +#intrinsic("%bytesview.view") #alias("_[_:_]") #alias(sub, deprecated="Use _[_:_] instead") pub fn BytesView::view( @@ -325,286 +331,18 @@ pub fn BytesView::iter2(self : BytesView) -> Iter2[Int, Byte] { } ///| -/// Converts a 4-byte sequence to an unsigned 32-bit integer using big-endian -/// byte order. The first byte is treated as the most significant byte, and the -/// last byte as the least significant byte. -/// -/// Parameters: -/// -/// * `self` : A byte view containing exactly 4 bytes to be converted. -/// -/// Returns an unsigned 32-bit integer representing the byte sequence. -/// -/// Example: -/// -/// ```mbt check -/// test { -/// let bytes = b"\x12\x34\x56\x78" -/// guard! bytes is [u32be(x), ..] -/// inspect(x, content="305419896") // 0x12345678 -/// } -/// ``` -#deprecated("Use bits pattern directly") -#doc(hidden) -pub fn BytesView::to_uint_be(self : BytesView) -> UInt { - (self[0].to_uint() << 24) + - (self[1].to_uint() << 16) + - (self[2].to_uint() << 8) + - self[3].to_uint() -} - -///| -/// Converts a sequence of 4 bytes into an unsigned 32-bit integer using -/// little-endian byte order. Each byte in the view contributes 8 bits to the -/// final integer, with the least significant byte at index 0. -/// -/// Parameters: -/// -/// * `view` : A `View` containing exactly 4 bytes to be interpreted as a -/// little-endian unsigned integer. -/// -/// Returns an unsigned 32-bit integer (`UInt`) formed by interpreting the bytes -/// in little-endian order. -/// -/// Throws a panic if the view does not contain exactly 4 bytes. -/// -/// Example: -/// -/// ```mbt check -/// test { -/// let bytes = b"\x01\x02\x03\x04" -/// guard! bytes is [u32le(x), ..] -/// inspect(x, content="67305985") // 0x04030201 -/// } -/// ``` -#deprecated("Use bits pattern directly") -#doc(hidden) -pub fn BytesView::to_uint_le(self : BytesView) -> UInt { - self[0].to_uint() + - (self[1].to_uint() << 8) + - (self[2].to_uint() << 16) + - (self[3].to_uint() << 24) -} - -///| -/// Converts a sequence of 8 bytes into a 64-bit unsigned integer using -/// big-endian byte order. The most significant byte is at index 0, and the least -/// significant byte is at index 7. -/// -/// Parameters: -/// -/// * `bytes` : A view into a byte sequence that must be at least 8 bytes long. -/// The bytes are interpreted in big-endian order, where the first byte is the -/// most significant byte. -/// -/// Returns a 64-bit unsigned integer constructed by concatenating the bytes in -/// big-endian order. -/// -/// Throws a runtime error if the byte sequence view is less than 8 bytes long or -/// if attempting to access an index beyond the view's bounds. -/// -/// Example: -/// -/// ```mbt check -/// test { -/// let bytes = b"\x01\x23\x45\x67\x89\xAB\xCD\xEF" -/// guard! bytes is [u64be(x), ..] -/// inspect(x, content="81985529216486895") -/// } -/// ``` -#deprecated("Use bits pattern directly") -#doc(hidden) -pub fn BytesView::to_uint64_be(self : BytesView) -> UInt64 { - (self[0].to_uint().to_uint64() << 56) + - (self[1].to_uint().to_uint64() << 48) + - (self[2].to_uint().to_uint64() << 40) + - (self[3].to_uint().to_uint64() << 32) + - (self[4].to_uint().to_uint64() << 24) + - (self[5].to_uint().to_uint64() << 16) + - (self[6].to_uint().to_uint64() << 8) + - self[7].to_uint().to_uint64() -} - -///| -/// Converts an 8-byte sequence to an unsigned 64-bit integer using little-endian -/// byte order. Each byte in the view is treated as an 8-bit unsigned integer and -/// combined to form the final 64-bit value, with the least significant byte -/// first. -/// -/// Parameters: -/// -/// * `bytes_view` : A view into a byte sequence that must be exactly 8 bytes -/// long. Each byte represents one byte of the resulting 64-bit integer, with the -/// first byte being the least significant. -/// -/// Returns an unsigned 64-bit integer assembled from the bytes in little-endian -/// order. -/// -/// Throws a panic if the View is less than 8 bytes long or if trying to -/// access a byte beyond the view's bounds. -/// -/// Example: -/// -/// ```mbt check -/// test { -/// let bytes = b"\x01\x02\x03\x04\x05\x06\x07\x08" -/// guard! bytes is [u64le(x), ..] -/// inspect(x, content="578437695752307201") -/// } -/// ``` -#deprecated("Use bits pattern directly") -#doc(hidden) -pub fn BytesView::to_uint64_le(self : BytesView) -> UInt64 { - self[0].to_uint().to_uint64() + - (self[1].to_uint().to_uint64() << 8) + - (self[2].to_uint().to_uint64() << 16) + - (self[3].to_uint().to_uint64() << 24) + - (self[4].to_uint().to_uint64() << 32) + - (self[5].to_uint().to_uint64() << 40) + - (self[6].to_uint().to_uint64() << 48) + - (self[7].to_uint().to_uint64() << 56) -} - -///| -/// Interpret the first 4 bytes as a big-endian signed `Int`. -/// -/// Deprecated: prefer bit-pattern matching (`u32be`) directly. -/// -/// Example: -/// -/// ```mbt check -/// test { -/// let bytes = b"\x00\x00\x00\x2A" -/// guard! bytes is [u32be(u), ..] -/// inspect(u.reinterpret_as_int(), content="42") -/// } -/// ``` -#deprecated -#doc(hidden) -pub fn BytesView::to_int_be(self : BytesView) -> Int { - guard! self is [u32be(u32), ..] - u32.reinterpret_as_int() -} - -///| -/// Interpret the first 4 bytes as a little-endian signed `Int`. -/// -/// Deprecated: prefer bit-pattern matching (`u32le`) directly. -/// -/// Example: -/// -/// ```mbt check -/// test { -/// let bytes = b"\x2A\x00\x00\x00" -/// guard! bytes is [u32le(u), ..] -/// inspect(u.reinterpret_as_int(), content="42") -/// } -/// ``` -#deprecated -#doc(hidden) -pub fn BytesView::to_int_le(self : BytesView) -> Int { - guard! self is [u32le(u32), ..] - u32.reinterpret_as_int() -} - -///| -/// Interpret the first 8 bytes as a big-endian signed `Int64`. -/// -/// Deprecated: prefer bit-pattern matching (`u64be`) directly. -/// -/// Example: -/// -/// ```mbt check -/// test { -/// let bytes = b"\x00\x00\x00\x00\x00\x00\x00\x2A" -/// guard! bytes is [u64be(u), ..] -/// inspect(u.reinterpret_as_int64(), content="42") -/// } -/// ``` -#deprecated -#doc(hidden) -pub fn BytesView::to_int64_be(self : BytesView) -> Int64 { - guard! self is [u64be(u64), ..] - u64.reinterpret_as_int64() -} - -///| -/// Interpret the first 8 bytes as a little-endian signed `Int64`. -/// -/// Deprecated: prefer bit-pattern matching (`u64le`) directly. -/// -/// Example: -/// -/// ```mbt check -/// test { -/// let bytes = b"\x2A\x00\x00\x00\x00\x00\x00\x00" -/// guard! bytes is [u64le(u), ..] -/// inspect(u.reinterpret_as_int64(), content="42") -/// } -/// ``` -#deprecated -#doc(hidden) -pub fn BytesView::to_int64_le(self : BytesView) -> Int64 { - guard! self is [u64le(u64), ..] - u64.reinterpret_as_int64() -} - -///| -/// Converts the bytes in a byte view to a double-precision floating-point number -/// using big-endian byte order. The byte view must contain exactly 8 bytes, -/// which represent the IEEE 754 double-precision format. -/// -/// Parameters: -/// -/// * `byte_view` : The byte view containing exactly 8 bytes to be interpreted as -/// a double-precision floating-point number in big-endian order. -/// -/// Returns a double-precision floating-point number reconstructed from the -/// bytes. -/// -/// Example: -/// -/// ```mbt check -/// test { -/// // Bytes representing 1.0 in IEEE 754 double-precision format (big-endian) -/// let bytes = b"\x3F\xF0\x00\x00\x00\x00\x00\x00" -/// guard! bytes is [u64be(bits), ..] -/// inspect(bits.reinterpret_as_double(), content="1") -/// } -/// ``` -#deprecated("Use bits pattern directly") -#doc(hidden) -pub fn BytesView::to_double_be(self : BytesView) -> Double { - guard! self is [u64be(u64), ..] - u64.reinterpret_as_double() -} +fn write_byte_hex(logger : &Logger, byte : Byte) -> Unit { + fn hex_digit(value : Int) -> Char { + if value < 10 { + (value + '0'.to_int()).unsafe_to_char() + } else { + (value - 10 + 'a'.to_int()).unsafe_to_char() + } + } -///| -/// Converts the bytes in the view to a double-precision floating-point number -/// using little-endian byte order. Interprets the first 8 bytes as a IEEE 754 -/// double-precision binary floating-point format (binary64) value. -/// -/// Parameters: -/// -/// * `bytes` : The byte view to be converted. Must contain at least 8 bytes. -/// -/// Returns a `Double` value representing the bytes interpreted in little-endian -/// order. -/// -/// Example: -/// -/// ```mbt check -/// test { -/// let bytes = b"\x00\x00\x00\x00\x00\x00\xF0\x3F" // represents 1.0 in little-endian -/// guard! bytes is [u64le(bits), ..] -/// inspect(bits.reinterpret_as_double(), content="1") -/// } -/// ``` -#deprecated("Use bits pattern directly") -#doc(hidden) -pub fn BytesView::to_double_le(self : BytesView) -> Double { - guard! self is [u64le(u64), ..] - u64.reinterpret_as_double() + let value = byte.to_int() + logger.write_char(hex_digit(value >> 4)) + logger.write_char(hex_digit(value & 0x0f)) } ///| @@ -615,7 +353,7 @@ pub impl Show for BytesView with fn output(self, logger) { logger.write_char(byte.to_char()) } else { logger.write_string("\\x") - logger.write_string(byte.to_hex()) + write_byte_hex(logger, byte) } } logger.write_string("\"") @@ -718,12 +456,7 @@ pub impl Compare for BytesView with fn compare(self, other) -> Int { let other_len = other.length() let cmp = self_len.compare(other_len) guard cmp == 0 else { return cmp } - for i, b1 in self { - let b2 = other.unsafe_get(i) - let cmp = b1.compare(b2) - guard cmp == 0 else { return cmp } - } - 0 + self.lexical_compare(other) } ///| @@ -865,7 +598,7 @@ pub impl ToJson for BytesView with fn to_json(self) -> Json { sb.write_char(byte.to_char()) } else { sb.write_string("\\x") - sb.write_string(byte.to_hex()) + write_byte_hex(sb, byte) } } Json::string(sb.to_string()) diff --git a/builtin/bytesview_compare_bench_test.mbt b/builtin/bytesview_compare_bench_test.mbt new file mode 100644 index 0000000000..8bc280e853 --- /dev/null +++ b/builtin/bytesview_compare_bench_test.mbt @@ -0,0 +1,33 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +test "bench BytesView Compare equal n=1000000" (it : @bench.T) { + let left = Bytes::makei(1000000, i => i.to_byte())[:] + let right = Bytes::makei(1000000, i => i.to_byte())[:] + it.bench(fn() { it.keep(left.compare(right)) }) +} + +///| +test "bench BytesView Compare last-diff n=1000000" (it : @bench.T) { + let left = Bytes::makei(1000000, i => i.to_byte())[:] + let right = Bytes::makei(1000000, i => { + if i == 999999 { + b'\xff' + } else { + i.to_byte() + } + })[:] + it.bench(fn() { it.keep(left.compare(right)) }) +} diff --git a/builtin/char.mbt b/builtin/char.mbt index 8fbdca49ab..964fd6db44 100644 --- a/builtin/char.mbt +++ b/builtin/char.mbt @@ -136,7 +136,7 @@ pub fn Char::is_ascii_uppercase(self : Self) -> Bool { /// Checks if the value is an ASCII whitespace character: /// U+0020 SPACE, U+0009 HORIZONTAL TAB, U+000A LINE FEED, U+000B VERTICAL TAB, U+000C FORM FEED, or U+000D CARRIAGE RETURN. pub fn Char::is_ascii_whitespace(self : Self) -> Bool { - self is ('\u{20}' | '\u{09}' | '\u{0A}' | '\u{0B}' | '\u{0C}' | '\u{0D}') + self is ('\u{09}'..='\u{0D}' | ' ') } ///| @@ -164,10 +164,11 @@ pub fn Char::is_digit(self : Self, radix : UInt) -> Bool { ///| /// Returns true if this char has the White_Space property. pub fn Char::is_whitespace(self : Self) -> Bool { + if self <= '\u{20}' { + return self is ('\u{09}'..='\u{0D}' | ' ') + } self - is ('\u0009'..='\u000D' - | '\u0020' - | '\u0085' + is ('\u0085' | '\u00A0' | '\u1680' | '\u2000'..='\u200A' @@ -181,9 +182,14 @@ pub fn Char::is_whitespace(self : Self) -> Bool { ///| /// Returns true if this char has one of the general categories for numbers. pub fn Char::is_numeric(self : Self) -> Bool { + if self is ('0'..='9') { + return true + } + if self < '\u{B2}' { + return false + } self - is ('\u0030'..='\u0039' - | '\u00B2' + is ('\u00B2' | '\u00B3' | '\u00B9' | '\u00BC' @@ -342,7 +348,7 @@ pub fn Char::is_numeric(self : Self) -> Bool { /// - Format characters (Cf) /// - Line/paragraph separators (Zl, Zp) /// - Private use (Co) -/// - Unassigned (Cn) +/// - Noncharacters (U+FDD0-U+FDEF and the U+nFFFE/U+nFFFF pairs) /// - Surrogates (Cs) pub fn Char::is_printable(self : Self) -> Bool { // Check for control characters (Cc) diff --git a/builtin/console.mbt b/builtin/console.mbt index 8d36334e30..745a0ec11f 100644 --- a/builtin/console.mbt +++ b/builtin/console.mbt @@ -49,7 +49,7 @@ pub fn[T : Show] println(input : T) -> Unit { /// ```mbt check /// test { /// let x : Int = 42 -/// inspect(x, content="42") // Raises InspectError with detailed failure message +/// inspect(x, content="42") // A mismatch would raise InspectError with a detailed message /// } /// ``` pub(all) suberror InspectError { @@ -81,8 +81,7 @@ fn base64_encode(data : FixedArray[Byte]) -> String { let x1 = base64[(b0 & 0x03) << 4] buf.write_char(x0.to_char()) buf.write_char(x1.to_char()) - buf.write_char('=') - buf.write_char('=') + buf <+ "==" } else if rem == 2 { let b0 = data[len - 2].to_int() let b1 = data[len - 1].to_int() diff --git a/builtin/deprecated.mbt b/builtin/deprecated.mbt index 391b496b43..2fe5f554df 100644 --- a/builtin/deprecated.mbt +++ b/builtin/deprecated.mbt @@ -65,13 +65,13 @@ pub fn[K, V] Map::new(capacity? : Int = default_init_capacity) -> Map[K, V] { } ///| +/// Shifts the bits of a `Byte` value to the left by the given number of bit /// positions. /// /// Parameters: /// -/// - `byte_value` : The `Byte` value whose bits are to be shifted. -/// - `shift_count` : The number of bit positions to shift the `byte_value` to -/// the left. +/// - `self` : The `Byte` value whose bits are to be shifted. +/// - `count` : The number of bit positions to shift `self` to the left. /// /// Returns the resulting `Byte` value after the bitwise left shift operation. /// @@ -82,12 +82,13 @@ pub fn Byte::lsl(self : Byte, count : Int) -> Byte { } ///| -/// bits. +/// Performs a logical (zero-filling) right shift of a `Byte` value by the +/// given number of bits. /// /// Parameters: /// -/// - `value` : The `Byte` value to be shifted. -/// - `count` : The number of bits to shift the `value` to the right. +/// - `self` : The `Byte` value to be shifted. +/// - `count` : The number of bits to shift `self` to the right. /// /// Returns the result of the logical shift right operation as a `Byte`. /// @@ -109,3 +110,12 @@ pub fn String::unsafe_char_at(self : String, index : Int) -> Char { c1.unsafe_to_char() } } + +///| +/// Creates a new empty array with an optional initial capacity. +/// +/// Deprecated: use `Array(capacity=...)` instead. +#deprecated("Use `Array(capacity=...)` instead") +pub fn[T] Array::new(capacity? : Int = 0) -> Array[T] { + Array(capacity~) +} diff --git a/builtin/double.mbt b/builtin/double.mbt index b6795b3d31..82fe70ebd5 100644 --- a/builtin/double.mbt +++ b/builtin/double.mbt @@ -55,8 +55,8 @@ pub fn Double::from_int(i : Int) -> Double { /// * `value` : The double-precision floating-point number to compute the /// absolute value of. /// -/// Returns the absolute value of the input number. For any input `x`, the result -/// is equivalent to `if x < 0.0 { -x } else { x }`. +/// Returns the absolute value of the input number by clearing its sign bit, so +/// the result is never negative. In particular, `(-0.0).abs()` is `0.0`. /// /// Example: /// @@ -111,6 +111,7 @@ pub fn Double::max(self : Double, other : Double) -> Double { /// * `max` : The upper bound of the range. /// /// Returns `min` if `self < min`, `max` if `self > max`, and `self` otherwise. +/// Aborts if `min` is greater than `max`. /// /// Example: /// @@ -319,7 +320,8 @@ pub impl Show for Double with fn to_string(self) { /// /// Returns whether the two numbers are considered approximately equal. Returns /// `true` if the numbers are exactly equal or if they are within either the -/// relative or absolute tolerance. Returns `false` if either number is infinite. +/// relative or absolute tolerance. Returns `false` if the numbers are not +/// exactly equal and either of them is infinite. /// /// Example: /// diff --git a/builtin/double_ryu_int_wbtest.mbt b/builtin/double_ryu_int_wbtest.mbt new file mode 100644 index 0000000000..9c14ca6e1a --- /dev/null +++ b/builtin/double_ryu_int_wbtest.mbt @@ -0,0 +1,109 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +/// The integral fast path must be indistinguishable from the full algorithm. +/// The oracle is `Int::to_string`, which is the definition of the shortest +/// decimal form for an integer; for values outside the fast path the expected +/// strings are pinned so a widened guard cannot silently change them. +test "ryu integral fast path agrees with Int::to_string over a dense band" { + for i in -3000..<=3000 { + assert_true(ryu_to_string(i.to_double()) == i.to_string()) + } +} + +///| +test "ryu integral fast path at the Int range boundary" { + // Inside the guard, both signs. + inspect(ryu_to_string(2147483646.0), content="2147483646") + inspect(ryu_to_string(2147483647.0), content="2147483647") + inspect(ryu_to_string(-2147483647.0), content="-2147483647") + inspect(ryu_to_string(-2147483648.0), content="-2147483648") + // Just outside Int range: now served by the Int64 tier, same rendering. + inspect(ryu_to_string(2147483648.0), content="2147483648") + inspect(ryu_to_string(2147483649.0), content="2147483649") + inspect(ryu_to_string(-2147483649.0), content="-2147483649") + inspect(ryu_to_string(-2147483650.0), content="-2147483650") +} + +///| +test "ryu integral fast path never captures special or fractional values" { + // -0.0 is handled by the existing zero check before the fast path. + inspect(ryu_to_string(-0.0), content="0") + inspect(ryu_to_string(0.0 / 0.0), content="NaN") + inspect(ryu_to_string(1.0 / 0.0), content="Infinity") + inspect(ryu_to_string(-1.0 / 0.0), content="-Infinity") + // Fractional values whose truncation is in range must not take the path. + inspect(ryu_to_string(0.5), content="0.5") + inspect(ryu_to_string(-0.5), content="-0.5") + inspect(ryu_to_string(2147483646.5), content="2147483646.5") + inspect(ryu_to_string(-2147483647.5), content="-2147483647.5") + // The next representable double below 1.0 and above 1.0: integrality test + // must reject both. + inspect(ryu_to_string(0.9999999999999999), content="0.9999999999999999") + inspect(ryu_to_string(1.0000000000000002), content="1.0000000000000002") +} + +///| +test "ryu integral fast path across power-of-two neighbourhoods" { + // Exact powers of two and neighbours on both sides of the guard, both signs. + for p in 0..<=53 { + let v = (1L << p).to_double() + for d in -2L..<=2L { + let x = v + d.to_double() + let expected = x.to_int64().to_string() + assert_true(ryu_to_string(x) == expected) + let nx = -v + d.to_double() + let nexpected = if nx == 0.0 { "0" } else { nx.to_int64().to_string() } + assert_true(ryu_to_string(nx) == nexpected) + } + } +} + +///| +test "ryu integral fast path at the 2^53 boundary" { + // 2^53 is the inclusive bound of MAX_EXACTLY_REPRESENTABLE_INT. + inspect(ryu_to_string(9007199254740992.0), content="9007199254740992") + inspect(ryu_to_string(-9007199254740992.0), content="-9007199254740992") + inspect(ryu_to_string(9007199254740991.0), content="9007199254740991") + inspect(ryu_to_string(-9007199254740991.0), content="-9007199254740991") + // 2^53 + 2 is the first representable integer past the bound: it must fall + // through to the full algorithm and render identically to before. + inspect(ryu_to_string(9007199254740994.0), content="9007199254740994") + inspect(ryu_to_string(-9007199254740994.0), content="-9007199254740994") + // Values between Int and Int64 tiers: a millisecond timestamp. + inspect(ryu_to_string(1700000000000.0), content="1700000000000") + inspect(ryu_to_string(-1700000000000.0), content="-1700000000000") +} + +///| +test "ryu integral fast path rejects fractional values in the Int64 tier" { + // These sit between the Int tier and the 2^53 bound; truncation is exact + // but the values are not integral, so they must use the full algorithm. + inspect(ryu_to_string(1700000000000.5), content="1700000000000.5") + inspect(ryu_to_string(-1700000000000.5), content="-1700000000000.5") + inspect(ryu_to_string(4503599627370495.5), content="4503599627370495.5") +} + +///| +test "ryu bound is load-bearing above 2^53" { + // 2^62 is exactly representable and round-trips through Int64, but its + // SHORTEST decimal form is not its exact integer digits (4611686018427387904): + // doubles are spaced 512 apart here, so a shorter decimal round-trips. + // Widening MAX_EXACTLY_REPRESENTABLE_INT past 2^53 would break this. + inspect(ryu_to_string(4611686018427387904.0), content="4611686018427388000") + inspect(ryu_to_string(-4611686018427387904.0), content="-4611686018427388000") + // 2^60: spacing 128; its shortest form is also shortened, not exact. + inspect(ryu_to_string(1152921504606846976.0), content="1152921504606847000") +} diff --git a/builtin/double_ryu_nonjs.mbt b/builtin/double_ryu_nonjs.mbt index 22b0ce0446..0139ee470b 100644 --- a/builtin/double_ryu_nonjs.mbt +++ b/builtin/double_ryu_nonjs.mbt @@ -522,7 +522,7 @@ fn d2d(ieeeMantissa : UInt64, ieeeExponent : UInt) -> FloatingDecimal64 { output = vr + (vr == vm || roundUp).to_uint64() } let exp : Int = e10 + removed - let fd : FloatingDecimal64 = { mantissa: output, exponent: exp } + let fd : FloatingDecimal64 = { mantissa: output, exponent: exp, } fd } @@ -651,15 +651,46 @@ fn d2d_small_int( if fraction != 0UL { return None } - Some({ mantissa: m2 >> -e2, exponent: 0 }) + Some({ mantissa: m2 >> -e2, exponent: 0, }) } +///| +/// The largest double below which EVERY integer is exactly representable as +/// binary64: 2^53. Individual larger integers (2^62, say) still round-trip +/// through Int64, but their shortest decimal form is no longer their exact +/// digits, so they must not take the integer fast path. (JavaScript's +/// MAX_SAFE_INTEGER is 2^53 - 1; the bound here includes 2^53 itself, which +/// is exactly representable and renders identically either way.) +const MAX_EXACTLY_REPRESENTABLE_INT : Double = 9_007_199_254_740_992 + ///| /// TODO: ryu_to_logger[T:Logger](Double/Float, T) -> Unit fn ryu_to_string(val : Double) -> String { if val == 0.0 { return "0" } + // Integral values up to 2^53 have the same shortest decimal form as the + // corresponding integer (every integer in this range is exactly + // representable, so no shorter decimal can round-trip to it), and integer + // formatting is far cheaper than the full shortest-representation search + // below. NaN and the infinities fail the range comparisons; -0.0 is handled + // by the zero check above; fractional values fail the round-trip check. + if val >= -MAX_EXACTLY_REPRESENTABLE_INT && + val <= MAX_EXACTLY_REPRESENTABLE_INT { + // Two tiers: Int formatting beats Int64 formatting for the values that + // dominate real workloads, so take it when the value fits. + if val >= -2147483648.0 && val <= 2147483647.0 { + let i = val.to_int() + if i.to_double() == val { + return i.to_string() + } + } else { + let i = val.to_int64() + if i.to_double() == val { + return i.to_string() + } + } + } // Step 1: Decode the floating-point number, and unify normalized and subnormal cases. let bits : UInt64 = val.reinterpret_as_uint64() @@ -683,7 +714,7 @@ fn ryu_to_string(val : Double) -> String { if r != 0 { break x } - continue { mantissa: q, exponent: x.exponent + 1 } + continue { mantissa: q, exponent: x.exponent + 1, } } None => d2d(ieeeMantissa, ieeeExponent.reinterpret_as_uint()) } diff --git a/builtin/double_to_int64_js_wasm.mbt b/builtin/double_to_int64_js_wasm.mbt index 516fb8f72b..c044369e61 100644 --- a/builtin/double_to_int64_js_wasm.mbt +++ b/builtin/double_to_int64_js_wasm.mbt @@ -53,8 +53,8 @@ pub fn Double::to_int64(self : Double) -> Int64 = "%f64_to_i64_saturate" /// Returns an unsigned 64-bit integer value according to the following rules: /// /// * Returns 0 if the input is NaN -/// * Returns `UInt64::max_value` (18446744073709551615UL) if the input is -/// greater than or equal to `UInt64::max_value` +/// * Returns `@uint64.MAX_VALUE` (18446744073709551615UL) if the input is +/// greater than or equal to `@uint64.MAX_VALUE` /// * Returns 0UL if the input is less than or equal to 0 /// * Otherwise returns the integer part of the input by truncating towards zero /// diff --git a/builtin/extends.mbt b/builtin/extends.mbt index dc0a1ab4cc..70e511f100 100644 --- a/builtin/extends.mbt +++ b/builtin/extends.mbt @@ -1083,11 +1083,6 @@ pub extend Int64 with Show::{output} #doc(hidden) pub extend Iter with Show::{to_string, output} -///| -#deprecated("Use `@debug.Debug` instead of `Show` for collections", skip_current_package=true) -#doc(hidden) -pub extend Iter2 with Show::{to_string, output} - ///| #deprecated("Use `Default::default` instead", skip_current_package=true) #doc(hidden) diff --git a/builtin/failure.mbt b/builtin/failure.mbt index 12ebd07d17..94710abbfa 100644 --- a/builtin/failure.mbt +++ b/builtin/failure.mbt @@ -34,6 +34,8 @@ /// @json.json_inspect(err, content=["Failure", "Test assertion failed"]) /// } /// ``` +// TODO(future): `derive(Show)` is deprecated syntax — switch to `derive(Debug)` +// or a manual `Show` implementation, then remove this annotation. #warnings("-deprecated_syntax") pub(all) suberror Failure { Failure(String) diff --git a/builtin/feature_test.mbt b/builtin/feature_test.mbt index fb38a33b7c..e11d57b0df 100644 --- a/builtin/feature_test.mbt +++ b/builtin/feature_test.mbt @@ -29,12 +29,12 @@ trait Range { ///| impl Range for Int with fn range(lo, hi) { - { lo, hi } + { lo, hi, } } ///| impl Range for String with fn range(lo, hi) { - { lo, hi } + { lo, hi, } } ///| diff --git a/builtin/fixedarray.mbt b/builtin/fixedarray.mbt index 23108f7a8d..2403f31a37 100644 --- a/builtin/fixedarray.mbt +++ b/builtin/fixedarray.mbt @@ -33,6 +33,7 @@ /// debug_inspect(arr.get(3), content="None") /// } /// ``` +#intrinsic("%fixedarray.get_opt") pub fn[T] FixedArray::get(self : FixedArray[T], idx : Int) -> T? { let len = self.length() guard idx >= 0 && idx < len else { None } @@ -774,9 +775,8 @@ pub fn[A, B] FixedArray::rev_foldi( init~ : B, f : (Int, B, A) -> B raise?, ) -> B raise? { - let len = self.length() - for i in len>..0; acc = init { - continue f(len - i - 1, acc, self.unsafe_get(i)) + for i in self.length()>..0; index = 0, acc = init { + continue index + 1, f(index, acc, self.unsafe_get(i)) } nobreak { acc } @@ -797,6 +797,16 @@ test "rev_foldi" { inspect(sum, content="25") } +///| +test "rev_foldi preserves reversed index element pairs" { + let trace = ([10, 20, 30] : FixedArray[_]).rev_foldi(init=0, ( + index, + acc, + elem, + ) => acc * 1000 + index * 100 + elem) + inspect(trace, content="30120210") +} + ///| /// Reverses the array in place by swapping elements from both ends until /// reaching the middle. @@ -1387,8 +1397,7 @@ test "iter" { let exb = StringBuilder() let mut i = 0 iter.each(x => { - exb.write_string(x.to_string()) - exb.write_char('\n') + exb <+ "\{x}\n" i += 1 }) assert_true(i == arr.length()) @@ -1555,7 +1564,7 @@ test "FixedArray::join" { ///| /// Return an iterator over elements of the fixed array. /// -/// Deprecated alias; prefer `iterator`. +/// `iterator` is a deprecated alias for this function. /// /// Example: /// @@ -1575,7 +1584,7 @@ pub fn[X] FixedArray::iter(self : FixedArray[X]) -> Iter[X] { ///| /// Return an index-value iterator over the fixed array. /// -/// Deprecated alias; prefer `iterator2`. +/// `iterator2` is a deprecated alias for this function. /// /// Example: /// diff --git a/builtin/fixedarray_test.mbt b/builtin/fixedarray_test.mbt index 044211e0e3..094c0fced9 100644 --- a/builtin/fixedarray_test.mbt +++ b/builtin/fixedarray_test.mbt @@ -175,12 +175,12 @@ struct TestStruct2 { ///| test "fixedarray_binary_search_by_test" { let arr : FixedArray[TestStruct2] = [ - { num2: 10 }, - { num2: 22 }, - { num2: 35 }, - { num2: 48 }, + { num2: 10, }, + { num2: 22, }, + { num2: 35, }, + { num2: 48, }, ] - let mut target : TestStruct2 = { num2: 22 } + let mut target : TestStruct2 = { num2: 22, } fn cmp(val : TestStruct2) { if val.num2 < target.num2 { -1 @@ -192,11 +192,11 @@ test "fixedarray_binary_search_by_test" { } @test.assert_eq(arr.binary_search_by(cmp), Ok(1)) - target = { num2: 48 } + target = { num2: 48, } @test.assert_eq(arr.binary_search_by(cmp), Ok(3)) - target = { num2: -8 } + target = { num2: -8, } @test.assert_eq(arr.binary_search_by(cmp), Err(0)) - target = { num2: 49 } + target = { num2: 49, } @test.assert_eq(arr.binary_search_by(cmp), Err(4)) } @@ -423,21 +423,21 @@ test "FixedArray::from_array with array of different type" { ///| test "FixedArray::from_iter with multiple elements iterator" { - let iter = [1, 2, 3, 4, 5].iter() + let iter = [|1, 2, 3, 4, 5|] let result = FixedArray::from_iter(iter) @test.assert_eq(result, [1, 2, 3, 4, 5]) } ///| test "FixedArray::from_iter with single element iterator" { - let iter = [1].iter() + let iter = [|1|] let result = FixedArray::from_iter(iter) @test.assert_eq(result, [1]) } ///| test "FixedArray::from_iter with empty iterator" { - let iter : Iter[Int] = Iter::empty() + let iter : Iter[Int] = [||] let result = FixedArray::from_iter(iter) @test.assert_eq(result, []) } @@ -471,13 +471,13 @@ test "FixedArray::arbitrary" { #| , #| , #| , - #| , - #| , + #| , + #| , #| , #| , - #| , - #| , - #| , + #| , + #| , + #| , #| ]> ), ) @@ -486,11 +486,35 @@ test "FixedArray::arbitrary" { content=( #|, - #| , - #| , - #| , - #| , + #| , + #| , + #| , + #| , + #| , #| ]> ), ) diff --git a/builtin/hasher.mbt b/builtin/hasher.mbt index eb320929c6..8702fdd4d7 100644 --- a/builtin/hasher.mbt +++ b/builtin/hasher.mbt @@ -57,7 +57,9 @@ struct Hasher { /// Parameters: /// /// * `seed` : An integer value used to initialize the hasher's internal state. -/// Defaults to 0. +/// When omitted, a randomly chosen process-wide seed is used on native, LLVM, +/// and JavaScript targets. Wasm targets default to 0. Pass `0` explicitly when +/// deterministic output is required. /// /// Returns a new `Hasher` instance initialized with the given seed value. /// @@ -77,11 +79,23 @@ struct Hasher { /// `Hasher::new` remains available as a deprecated alias. #alias(new, deprecated="Use `Hasher()` instead") pub fn Hasher::Hasher(seed? : Int = seed) -> Hasher { - { acc: seed.reinterpret_as_uint() + GPRIME5 } + { acc: seed.reinterpret_as_uint() + GPRIME5, } } ///| -#cfg(not(target="js")) +// `builtin` cannot depend on `env`, so bind the runtime entropy source here. +#cfg(any(target="native", target="llvm")) +let seed : Int = { + let bytes : FixedArray[Byte] = FixedArray::make(4, b'\x00') + if moonbit_rt_get_random_for_hash_seed(bytes) == 0 { + fixedarray_read_uint32_le(bytes, 0).reinterpret_as_int() + } else { + 0 + } +} + +///| +#cfg(any(target="wasm", target="wasm-gc")) let seed : Int = 0 ///| @@ -101,6 +115,13 @@ extern "js" fn random_seed() -> Int = #| } #|} +///| +#cfg(any(target="native", target="llvm")) +#borrow(bytes) +extern "c" fn moonbit_rt_get_random_for_hash_seed( + bytes : FixedArray[Byte], +) -> Int = "moonbit_rt_get_random" + ///| /// Combines a hashable value with the current state of the hasher. This is /// typically used to incrementally build a hash value from multiple components. @@ -358,15 +379,22 @@ pub fn Hasher::combine_byte(self : Hasher, value : Byte) -> Unit { /// } /// ``` pub fn Hasher::combine_bytes(self : Hasher, value : Bytes) -> Unit { - let (cur, remain) = for cur = 0, remain = value.length(); remain >= 4; { - self.consume4(endian32(value, cur)) - continue cur + 4, remain - 4 - } nobreak { - (cur, remain) - } - for cur = cur, remain = remain; remain >= 1; { - self.consume1(value[cur]) - continue cur + 1, remain - 1 + for data = value.view() { + if data is [u32le(x), .. rest] { + self.consume4(x) + continue rest + } else { + if data is [b0, .. data] { + self.consume1(b0) + if data is [b1, .. data] { + self.consume1(b1) + if data is [b2, ..] { + self.consume1(b2) + } + } + } + break + } } } @@ -476,16 +504,6 @@ fn rotl(x : UInt, r : Int) -> UInt { (x << r) | (x >> (32 - r)) } -///| -fn endian32(input : Bytes, cur : Int) -> UInt { - input[cur + 0].to_uint() | - ( - (input[cur + 1].to_uint() << 8) | - (input[cur + 2].to_uint() << 16) | - (input[cur + 3].to_uint() << 24) - ) -} - ///| /// Implements the `Hash` trait for `String` type, providing a method to combine /// a string's hash value with a hasher's state. @@ -735,20 +753,22 @@ pub impl Hash for BytesView with fn hash_combine( self : BytesView, hasher : Hasher, ) { - let data = self.bytes() - let (start, rest) = for start = self.start(), rest = self.len(); rest >= 4; { - let result = for i in 0..<=3; result = (0 : UInt) { - continue result | (data.unsafe_get(i + start).to_uint() << (8 * i)) - } nobreak { - result + for data = self { + if data is [u32le(x), .. rest] { + hasher.combine_uint(x) + continue rest + } else { + // only 0-3 bytes left + if data is [b0, .. data] { + hasher.combine_byte(b0) + if data is [b1, .. data] { + hasher.combine_byte(b1) + if data is [b2, ..] { + hasher.combine_byte(b2) + } + } + } + break } - hasher.combine_uint(result) - continue start + 4, rest - 4 - } nobreak { - (start, rest) - } - for start = start, rest = rest; rest >= 1; { - hasher.combine_byte(data.unsafe_get(start)) - continue start + 1, rest - 1 } } diff --git a/builtin/int.mbt b/builtin/int.mbt index 6262a76b5d..74de583e71 100644 --- a/builtin/int.mbt +++ b/builtin/int.mbt @@ -94,7 +94,8 @@ pub fn Int::max(self : Int, other : Int) -> Int { } ///| -/// Clamps the value `self` between `min` and `max`. +/// Clamps the value `self` between `min` and `max`. Aborts if `min` is greater +/// than `max`. /// /// Example: /// ```mbt check @@ -175,7 +176,9 @@ pub fn Int::is_surrogate(self : Int) -> Bool { /// /// * `self` : The integer whose absolute value is to be computed. /// -/// Returns the absolute value of the integer. +/// Returns the absolute value of the integer. When the input is +/// `@int.min_value` (-2147483648), returns `@int.min_value` itself, since its +/// absolute value is not representable as an `Int`. /// /// Example: /// diff --git a/builtin/int64.mbt b/builtin/int64.mbt index 6b6841a820..41951d65d6 100644 --- a/builtin/int64.mbt +++ b/builtin/int64.mbt @@ -52,7 +52,9 @@ pub fn Int64::from_int(i : Int) -> Int64 { /// /// * `self` : The 64-bit integer whose absolute value is to be computed. /// -/// Returns the absolute value of the input integer. +/// Returns the absolute value of the input integer. When the input is +/// `@int64.MIN_VALUE` (-9223372036854775808), returns `@int64.MIN_VALUE` +/// itself, since its absolute value is not representable as an `Int64`. /// /// Example: /// @@ -72,7 +74,24 @@ pub fn Int64::abs(self : Int64) -> Int64 { } ///| -/// Returns the minimum of two 64-bit signed integers. +/// Returns the smaller of two 64-bit signed integers. +/// +/// Parameters: +/// +/// * `self` : The first integer to compare. +/// * `other` : The second integer to compare. +/// +/// Returns `self` if it is not greater than `other`, `other` otherwise. +/// +/// Example: +/// +/// ```mbt check +/// test { +/// inspect(1L.min(2L), content="1") +/// inspect(2L.min(1L), content="1") +/// inspect((-1L).min(0L), content="-1") +/// } +/// ``` pub fn Int64::min(self : Int64, other : Int64) -> Int64 { if self < other { self @@ -82,7 +101,24 @@ pub fn Int64::min(self : Int64, other : Int64) -> Int64 { } ///| -/// Returns the maximum of two 64-bit signed integers. +/// Returns the larger of two 64-bit signed integers. +/// +/// Parameters: +/// +/// * `self` : The first integer to compare. +/// * `other` : The second integer to compare. +/// +/// Returns `self` if it is not less than `other`, `other` otherwise. +/// +/// Example: +/// +/// ```mbt check +/// test { +/// inspect(1L.max(2L), content="2") +/// inspect(2L.max(1L), content="2") +/// inspect((-1L).max(0L), content="0") +/// } +/// ``` pub fn Int64::max(self : Int64, other : Int64) -> Int64 { if self > other { self @@ -92,7 +128,26 @@ pub fn Int64::max(self : Int64, other : Int64) -> Int64 { } ///| -/// Clamps the value `self` between `min` and `max`. +/// Clamps a 64-bit signed integer into the inclusive range [`min`, `max`]. +/// +/// Parameters: +/// +/// * `self` : The value to clamp. +/// * `min` : The lower bound of the range. +/// * `max` : The upper bound of the range. +/// +/// Returns `min` if `self` is less than `min`, `max` if `self` is greater +/// than `max`, and `self` otherwise. Aborts if `min` is greater than `max`. +/// +/// Example: +/// +/// ```mbt check +/// test { +/// inspect(5L.clamp(min=0L, max=10L), content="5") +/// inspect((-5L).clamp(min=0L, max=10L), content="0") +/// inspect(15L.clamp(min=0L, max=10L), content="10") +/// } +/// ``` pub fn Int64::clamp(self : Int64, min~ : Int64, max~ : Int64) -> Int64 { guard! min <= max if self < min { @@ -601,7 +656,7 @@ pub fn Int64::clz(self : Int64) -> Int = "%i64_clz" /// /// ```mbt check /// test { -/// let x = 0x7000_0001_1F00_100FL // 0111000000000000000000000001000111110000000100001111 +/// let x = 0x7000_0001_1F00_100FL // Binary: 0111 0000 ... 0001 1111 0000 0000 0001 0000 0000 1111 /// inspect(x.popcnt(), content="14") /// } /// ``` @@ -801,7 +856,7 @@ pub fn Int64::reinterpret_as_double(self : Int64) -> Double = "%i64_to_f64_reint /// /// ```mbt check /// test { -/// // 0x4045000000000000 represents 42.0 in IEEE 754 double format +/// // 0x4059000000000000 represents 100.0 in IEEE 754 double format /// let n = 4636737291354636288UL /// inspect(n.reinterpret_as_double(), content="100") /// } diff --git a/builtin/intrinsics.mbt b/builtin/intrinsics.mbt index 770978f6ba..bed44a6563 100644 --- a/builtin/intrinsics.mbt +++ b/builtin/intrinsics.mbt @@ -29,7 +29,7 @@ /// let x = 42 /// ignore(x) // Explicitly ignore the value /// let mut sum = 0 -/// ignore([1, 2, 3].iter().each(x => sum += x)) // Ignore the Unit return value of each() +/// ignore([|1, 2, 3|].each(x => sum += x)) // Ignore the Unit return value of each() /// inspect(sum, content="6") /// } /// ``` @@ -199,9 +199,9 @@ pub impl Default for Bool with fn default() = "%bool_default" /// * `self` : The integer value to negate. /// /// Returns the negation of the input value. For all inputs except -/// `Int::min_value()`, returns the value with opposite sign. When the input is -/// `Int::min_value()`, returns `Int::min_value()` due to two's complement -/// representation. +/// `@int.min_value` (-2147483648), returns the value with opposite sign. When +/// the input is `@int.min_value`, returns `@int.min_value` due to two's +/// complement representation. /// /// Example: /// @@ -209,7 +209,7 @@ pub impl Default for Bool with fn default() = "%bool_default" /// test { /// inspect(-42, content="-42") /// inspect(42, content="42") -/// inspect(2147483647, content="2147483647") // negating near min value +/// inspect(2147483647, content="2147483647") // Int maximum value /// } /// ``` pub impl Neg for Int with fn neg(self) = "%i32_neg" @@ -647,7 +647,29 @@ pub fn Int::shr(self : Int, other : Int) -> Int = "%i32_shr" pub fn Int::ctz(self : Int) -> Int = "%i32_ctz" ///| -/// Count leading zero bits in a 32-bit integer. +/// Counts the number of consecutive zero bits at the most significant end of +/// the integer's binary representation. +/// +/// Parameters: +/// +/// * `self` : The integer value whose leading zeros are to be counted. +/// +/// Returns the number of leading zero bits (0 to 32). For example, returns 0 if +/// the value is negative (the sign bit is 1), returns 32 if the value is 0 +/// (all bits are zeros). +/// +/// Example: +/// +/// ```mbt check +/// test { +/// let x = 0 +/// inspect(x.clz(), content="32") // All bits are zero +/// let y = -1 +/// inspect(y.clz(), content="0") // The sign bit is set +/// let z = 16 +/// inspect(z.clz(), content="27") // Binary: ...00010000 +/// } +/// ``` pub fn Int::clz(self : Int) -> Int = "%i32_clz" ///| @@ -753,7 +775,26 @@ pub impl Compare for Int with fn op_gt(x, y) = "%i32.gt" pub impl Compare for Int with fn op_ge(x, y) = "%i32.ge" ///| -/// Return `true` if integer is strictly positive. +/// Tests whether an integer is strictly positive. +/// +/// Parameters: +/// +/// * `self` : The integer to test. +/// +/// Returns `true` if the integer is strictly positive, `false` otherwise. +/// +/// Example: +/// +/// ```mbt check +/// test { +/// let neg = -42 +/// let zero = 0 +/// let pos = 42 +/// inspect(neg.is_pos(), content="false") +/// inspect(zero.is_pos(), content="false") +/// inspect(pos.is_pos(), content="true") +/// } +/// ``` pub fn Int::is_pos(self : Int) -> Bool = "%i32_is_pos" ///| @@ -780,11 +821,51 @@ pub fn Int::is_pos(self : Int) -> Bool = "%i32_is_pos" pub fn Int::is_neg(self : Int) -> Bool = "%i32_is_neg" ///| -/// Return `true` if integer is non-positive (`<= 0`). +/// Tests whether an integer is non-positive (less than or equal to zero). +/// +/// Parameters: +/// +/// * `self` : The integer to test. +/// +/// Returns `true` if the integer is less than or equal to zero, `false` +/// otherwise. +/// +/// Example: +/// +/// ```mbt check +/// test { +/// let neg = -42 +/// let zero = 0 +/// let pos = 42 +/// inspect(neg.is_non_pos(), content="true") +/// inspect(zero.is_non_pos(), content="true") +/// inspect(pos.is_non_pos(), content="false") +/// } +/// ``` pub fn Int::is_non_pos(self : Int) -> Bool = "%i32_is_non_pos" ///| -/// Return `true` if integer is non-negative (`>= 0`). +/// Tests whether an integer is non-negative (greater than or equal to zero). +/// +/// Parameters: +/// +/// * `self` : The integer to test. +/// +/// Returns `true` if the integer is greater than or equal to zero, `false` +/// otherwise. +/// +/// Example: +/// +/// ```mbt check +/// test { +/// let neg = -42 +/// let zero = 0 +/// let pos = 42 +/// inspect(neg.is_non_neg(), content="false") +/// inspect(zero.is_non_neg(), content="true") +/// inspect(pos.is_non_neg(), content="true") +/// } +/// ``` pub fn Int::is_non_neg(self : Int) -> Bool = "%i32_is_non_neg" ///| @@ -1066,9 +1147,8 @@ pub fn Double::sqrt(self : Double) -> Double = "%f64_sqrt" ///| /// Compares two double-precision floating-point numbers for equality following -/// IEEE 754 rules. Returns `true` if both numbers are equal, including when both -/// are `NaN`. Note that this differs from the standard IEEE 754 behavior where -/// `NaN` is not equal to any value, including itself. +/// IEEE 754 rules. Returns `true` if both numbers are equal. `NaN` is not equal +/// to any value, including itself, so `nan == nan` evaluates to `false`. /// /// Parameters: /// @@ -1449,7 +1529,8 @@ pub fn Bytes::length(self : Bytes) -> Int = "%bytes_length" /// /// Parameters: /// -/// * `length` : The length of the byte sequence to create. Must be non-negative. +/// * `length` : The length of the byte sequence to create. A negative value +/// produces an empty byte sequence. /// * `initial_value` : The byte value used to initialize each position in the /// sequence. /// @@ -2622,7 +2703,8 @@ pub fn Int::to_int16(self : Int) -> Int16 = "%i32_to_i16" ///| /// Converts a byte value to a 16-bit signed integer. The byte value is -/// sign-extended to 16 bits during the conversion. +/// zero-extended to 16 bits during the conversion, so the result is always in +/// the range [0, 255]. /// /// Parameters: /// diff --git a/builtin/iter_test.mbt b/builtin/iter_test.mbt index ea81c9be84..c5e4fa5032 100644 --- a/builtin/iter_test.mbt +++ b/builtin/iter_test.mbt @@ -14,7 +14,9 @@ ///| test "empty" { - let iter = Iter::empty() + // the `[||]` literal is preferred at call sites, but `Iter::empty` is still + // public API and needs its own coverage + let iter : Iter[Char] = Iter::empty() let exb = StringBuilder(size_hint=0) iter.each(x => exb.write_char(x)) inspect(exb) @@ -22,6 +24,7 @@ test "empty" { ///| test "singleton" { + // likewise `Iter::singleton`, the constructor behind `[|x|]` let iter = Iter::singleton('1') let exb = StringBuilder(size_hint=0) iter.each(x => exb.write_char(x)) @@ -52,7 +55,7 @@ test "count_if" { @test.assert_eq(iter.count_if(x => x % 2 == 0), 3) let iter = test_from_array([1, 2, 3]) @test.assert_eq(iter.count_if(x => x > 99), 0) - let iter : Iter[Int] = Iter::empty() + let iter : Iter[Int] = [||] @test.assert_eq(iter.count_if(_ => true), 0) } @@ -113,10 +116,7 @@ test "take_while" { test "take_while2" { let iter = test_from_array(['1', '2', '3', '4', '5']) let exb = StringBuilder(size_hint=0) - iter - .take_while(x => x != '4') - .concat(Iter::singleton('6')) - .each(x => exb.write_char(x)) + iter.take_while(x => x != '4').concat([|'6'|]).each(x => exb.write_char(x)) inspect(exb, content="1236") } @@ -125,7 +125,7 @@ test "take_while3" { let iter = test_from_array([1, 2, 3]) let res = iter .take_while(x => x != 4) - .concat(Iter::singleton(4)) + .concat([|4|]) .find_first(x => x % 2 == 0) debug_inspect(res, content="Some(2)") } @@ -150,7 +150,7 @@ test "map_while1" { let exb = StringBuilder(size_hint=0) iter .map_while(x => if x != 4 { Some(x) } else { None }) - .each(x => exb.write_string("\{x}\n")) + .each(x => exb <+ "\{x}\n") inspect( exb, content=( @@ -168,8 +168,8 @@ test "map_while2" { let exb = StringBuilder(size_hint=0) iter .map_while(x => if x != 4 { Some(x) } else { None }) - .concat(Iter::singleton(6)) - .each(x => exb.write_string("\{x}\n")) + .concat([|6|]) + .each(x => exb <+ "\{x}\n") inspect( exb, content=( @@ -187,7 +187,7 @@ test "map_while3" { let iter = test_from_array([1, 2, 3]) let res = iter .map_while(x => if x != 4 { Some(x) } else { None }) - .concat(Iter::singleton(4)) + .concat([|4|]) .find_first(x => x % 2 == 0) debug_inspect(res, content="Some(2)") } @@ -240,7 +240,7 @@ test "drop_while2" { let iter = test_from_array([1, 2, 3, 4, 5]) let res = iter .drop_while(x => x <= 3) - .concat(Iter::singleton(6)) + .concat([|6|]) .find_first(x => x % 3 == 0) debug_inspect(res, content="Some(6)") } @@ -251,7 +251,7 @@ test "drop_while3" { let exb = StringBuilder(size_hint=0) let res = iter .drop_while(x => x < 3) - .concat(Iter::singleton(6)) + .concat([|6|]) .find_first(x => { exb.write_char('x') x % 3 == 0 @@ -266,7 +266,7 @@ test "drop_while4" { let iter = test_from_array([1, 2, 3, 4, 5]) let res = iter .drop_while(x => x <= 3) - .concat(Iter::singleton(6)) + .concat([|6|]) .drop(3) .find_first(x => x % 3 == 0) debug_inspect(res, content="None") @@ -292,57 +292,47 @@ test "map" { ///| test "size_hint" { - let iter = [1, 2, 3].iter() + // an `Iter` literal and `Array::iter` report the same hint + debug_inspect([1, 2, 3].iter().size_hint(), content="Some(3)") + let iter = [|1, 2, 3|] debug_inspect(iter.size_hint(), content="Some(3)") debug_inspect(iter.next(), content="Some(1)") debug_inspect(iter.size_hint(), content="Some(2)") debug_inspect(iter.collect(), content="[2, 3]") debug_inspect(iter.size_hint(), content="Some(0)") - debug_inspect([1, 2, 3].iter().map(x => x + 1).size_hint(), content="Some(3)") + debug_inspect([|1, 2, 3|].map(x => x + 1).size_hint(), content="Some(3)") debug_inspect( - [1, 2, 3].iter().mapi((i, x) => i + x).size_hint(), + [|1, 2, 3|].mapi((i, x) => i + x).size_hint(), content="Some(3)", ) - debug_inspect([1, 2, 3].iter().tap(_ => ()).size_hint(), content="Some(3)") - debug_inspect([1, 2, 3].iter().take(2).size_hint(), content="Some(2)") - debug_inspect([1, 2, 3].iter().take(5).size_hint(), content="Some(3)") - debug_inspect([1, 2, 3].iter().take(0).size_hint(), content="Some(0)") - debug_inspect([1, 2, 3].iter().drop(0).size_hint(), content="Some(3)") - debug_inspect([1, 2, 3].iter().drop(1).size_hint(), content="Some(2)") - debug_inspect([1, 2, 3].iter().drop(5).size_hint(), content="Some(0)") - debug_inspect( - [1, 2, 3].iter().concat([4, 5].iter()).size_hint(), - content="Some(5)", - ) - debug_inspect( - [1, 2].iter().zip([3, 4, 5].iter()).size_hint(), - content="Some(2)", - ) - debug_inspect( - [1, 2, 3].iter().zip([4, 5].iter()).size_hint(), - content="Some(2)", - ) - let empty_left : Iter[Int] = Iter::empty() + debug_inspect([|1, 2, 3|].tap(_ => ()).size_hint(), content="Some(3)") + debug_inspect([|1, 2, 3|].take(2).size_hint(), content="Some(2)") + debug_inspect([|1, 2, 3|].take(5).size_hint(), content="Some(3)") + debug_inspect([|1, 2, 3|].take(0).size_hint(), content="Some(0)") + debug_inspect([|1, 2, 3|].drop(0).size_hint(), content="Some(3)") + debug_inspect([|1, 2, 3|].drop(1).size_hint(), content="Some(2)") + debug_inspect([|1, 2, 3|].drop(5).size_hint(), content="Some(0)") + debug_inspect([|1, 2, 3|].concat([|4, 5|]).size_hint(), content="Some(5)") + debug_inspect([|1, 2|].zip([|3, 4, 5|]).size_hint(), content="Some(2)") + debug_inspect([|1, 2, 3|].zip([|4, 5|]).size_hint(), content="Some(2)") + let empty_left : Iter[Int] = [||] debug_inspect( empty_left.zip(Iter::new(() => Some(1))).size_hint(), content="Some(0)", ) - let empty_right : Iter[Int] = Iter::empty() + let empty_right : Iter[Int] = [||] debug_inspect( Iter::new(() => Some(1)).zip(empty_right).size_hint(), content="Some(0)", ) - debug_inspect( - Iter::new(() => Some(1)).zip([1].iter()).size_hint(), - content="None", - ) - debug_inspect([1, 2, 3].iter().intersperse(0).size_hint(), content="Some(5)") + debug_inspect(Iter::new(() => Some(1)).zip([|1|]).size_hint(), content="None") + debug_inspect([|1, 2, 3|].intersperse(0).size_hint(), content="Some(5)") debug_inspect( Iter::new(() => Some(1)).intersperse(0).size_hint(), content="None", ) - debug_inspect([1, 2, 3].iter()[1:3].size_hint(), content="Some(2)") - debug_inspect([1, 2, 3].iter()[3:5].size_hint(), content="Some(0)") + debug_inspect([|1, 2, 3|][1:3].size_hint(), content="Some(2)") + debug_inspect([|1, 2, 3|][3:5].size_hint(), content="Some(0)") debug_inspect( Iter::new(() => Some(1)).view(start=2, end=1).size_hint(), content="Some(0)", @@ -351,18 +341,12 @@ test "size_hint" { Iter::new(() => Some(1)).view(start=1, end=3).size_hint(), content="None", ) - debug_inspect([1, 2, 3].iter().filter(x => x > 1).size_hint(), content="None") - debug_inspect( - [1, 2, 3] - .iter() - .filter_map(x => if x > 1 { Some(x) } else { None }) - .size_hint(), - content="None", - ) + debug_inspect([|1, 2, 3|].filter(x => x > 1).size_hint(), content="None") debug_inspect( - [1, 2, 3].iter().take_while(x => x < 3).size_hint(), + [|1, 2, 3|].filter_map(x => if x > 1 { Some(x) } else { None }).size_hint(), content="None", ) + debug_inspect([|1, 2, 3|].take_while(x => x < 3).size_hint(), content="None") let unknown : Iter[Int] = Iter::new(() => None) let known_empty : Iter[Int] = Iter::new(() => None, size_hint=0) debug_inspect(unknown.size_hint(), content="None") @@ -408,24 +392,26 @@ test "filter_map" { debug_inspect(r1, content="[3, 4, 5]") let r2 : Array[Unit] = arr.iter().filter_map(_x => None).collect() debug_inspect(r2, content="[]") - let r3 : Array[Unit] = [].iter().filter_map(x => Some(x)).collect() + let r3 : Array[Unit] = [||].filter_map(x => Some(x)).collect() debug_inspect(r3, content="[]") // Test using next() directly to ensure the closure is executed - let it1 = [1, 2, 3, 4, 5] - .iter() - .filter_map(x => if x % 2 == 0 { Some(x * 10) } else { None }) + let it1 = [|1, 2, 3, 4, 5|].filter_map(x => { + if x % 2 == 0 { + Some(x * 10) + } else { + None + } + }) debug_inspect(it1.next(), content="Some(20)") debug_inspect(it1.next(), content="Some(40)") debug_inspect(it1.next(), content="None") // Test None branch inside while loop - let it2 = [1, 2, 3] - .iter() - .filter_map(x => if x == 2 { None } else { Some(x) }) + let it2 = [|1, 2, 3|].filter_map(x => if x == 2 { None } else { Some(x) }) debug_inspect(it2.next(), content="Some(1)") debug_inspect(it2.next(), content="Some(3)") debug_inspect(it2.next(), content="None") // Test iter exhaustion (else branch) - let it3 : Iter[Unit] = [1].iter().filter_map(_ => None) + let it3 : Iter[Unit] = [|1|].filter_map(_ => None) debug_inspect(it3.next(), content="None") } @@ -441,7 +427,7 @@ test "flat_map" { test "flat_map2" { let iter = test_from_array(['1', '2', '3', '4', '5']) let exb = StringBuilder(size_hint=0) - iter.flat_map(x => Iter::singleton(x)).each(x => exb.write_char(x)) + iter.flat_map(x => [|x|]).each(x => exb.write_char(x)) inspect(exb, content="12345") } @@ -489,7 +475,7 @@ test "concat" { ///| test "zip" { let numbers = (1).until(6) - let letters = ["a", "b", "c"].iter() + let letters = [|"a", "b", "c"|] debug_inspect( numbers.zip(letters).collect(), content=( @@ -501,7 +487,7 @@ test "zip" { ///| test "zip alias combine" { let numbers = (1).until(4) - let letters = ["x", "y", "z", "w"].iter() + let letters = [|"x", "y", "z", "w"|] debug_inspect( numbers.combine(letters).collect(), content=( @@ -515,7 +501,7 @@ test "zip short-circuits when either side ends" { let lhs_evaluated = [] let rhs_evaluated = [] let lhs = (1).until(10).tap(x => lhs_evaluated.push(x)) - let rhs = [10, 20].iter().tap(x => rhs_evaluated.push(x)) + let rhs = [|10, 20|].tap(x => rhs_evaluated.push(x)) debug_inspect(lhs.zip(rhs).collect(), content="[(1, 10), (2, 20)]") debug_inspect(lhs_evaluated, content="[1, 2, 3]") debug_inspect(rhs_evaluated, content="[10, 20]") @@ -590,7 +576,7 @@ test "until" { debug_inspect( (@int.MAX_VALUE - 1) .until(@int.MAX_VALUE, inclusive=true) - .concat([0].iter()) + .concat([|0|]) .to_array(), content=( #|[2147483646, 2147483647, 0] @@ -778,7 +764,7 @@ test "intersperse early end" { ///| test "intersperse stops on separator" { - let iter = [1, 2].iter().intersperse(0) + let iter = [|1, 2|].intersperse(0) let seen = [] for x in iter { seen.push(x) @@ -859,25 +845,25 @@ test "tree" { ///| test "Iter::intersperse" { debug_inspect( - [1, 2, 3].iter().intersperse(0).to_array(), + [|1, 2, 3|].intersperse(0).to_array(), content=( #|[1, 0, 2, 0, 3] ), ) debug_inspect( - [1, 2, 3, 4, 5, 6, 7, 8, 9].iter().intersperse(0).to_array(), + [|1, 2, 3, 4, 5, 6, 7, 8, 9|].intersperse(0).to_array(), content=( #|[1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6, 0, 7, 0, 8, 0, 9] ), ) debug_inspect( - ([] : Array[Int]).iter().intersperse(0).to_array(), + ([||] : Iter[Int]).intersperse(0).to_array(), content=( #|[] ), ) debug_inspect( - [1].iter().intersperse(0).to_array(), + [|1|].intersperse(0).to_array(), content=( #|[1] ), @@ -886,40 +872,40 @@ test "Iter::intersperse" { ///| test "Iter::last" { - debug_inspect([1, 2, 3].iter().last(), content="Some(3)") - debug_inspect([1].iter().last(), content="Some(1)") - debug_inspect(([] : Array[Int]).iter().last(), content="None") + debug_inspect([|1, 2, 3|].last(), content="Some(3)") + debug_inspect([|1|].last(), content="Some(1)") + debug_inspect(([||] : Iter[Int]).last(), content="None") } ///| test "Iter::head" { - debug_inspect([1, 2, 3].iter().head(), content="Some(1)") - debug_inspect([1].iter().head(), content="Some(1)") - debug_inspect(([] : Array[Int]).iter().head(), content="None") + debug_inspect([|1, 2, 3|].head(), content="Some(1)") + debug_inspect([|1|].head(), content="Some(1)") + debug_inspect(([||] : Iter[Int]).head(), content="None") } ///| test "Iter::as_view" { debug_inspect( - [1, 2, 3].iter()[1:2].to_array(), + [|1, 2, 3|][1:2].to_array(), content=( #|[2] ), ) debug_inspect( - [1, 2, 3].iter()[1:].to_array(), + [|1, 2, 3|][1:].to_array(), content=( #|[2, 3] ), ) debug_inspect( - [1, 2, 3].iter()[1:].to_array(), + [|1, 2, 3|][1:].to_array(), content=( #|[2, 3] ), ) debug_inspect( - [1, 2, 3].iter()[:].to_array(), + [|1, 2, 3|][:].to_array(), content=( #|[1, 2, 3] ), @@ -938,69 +924,69 @@ test "Iter::enumerate" { ///| test "next function - basic functionality" { - let iter = Iter::singleton(42) + let iter = [|42|] debug_inspect(iter.next(), content="Some(42)") } ///| test "next function - empty iter" { - let iter : Iter[Int] = Iter::empty() + let iter : Iter[Int] = [||] debug_inspect(iter.next(), content="None") } ///| test "next function - multiple elements" { - let iter = [1, 2, 3].iter() + let iter = [|1, 2, 3|] debug_inspect(iter.next(), content="Some(1)") } ///| test "next function - random cases" { - let iter1 = [10, 20, 30].iter() + let iter1 = [|10, 20, 30|] debug_inspect(iter1.next(), content="Some(10)") - let iter2 = [-5, 0, 5].iter() + let iter2 = [|-5, 0, 5|] debug_inspect(iter2.next(), content="Some(-5)") - let iter3 = [100, 200, 300, 400].iter() + let iter3 = [|100, 200, 300, 400|] debug_inspect(iter3.next(), content="Some(100)") - let iter4 = [-10, -20, -30].iter() + let iter4 = [|-10, -20, -30|] debug_inspect(iter4.next(), content="Some(-10)") - let iter5 = [0, 0, 0].iter() + let iter5 = [|0, 0, 0|] debug_inspect(iter5.next(), content="Some(0)") } ///| test "@builtin.join/empty_iter" { - let empty_iter : Iter[String] = Iter::empty() + let empty_iter : Iter[String] = [||] inspect(empty_iter.join(""), content="") } ///| test "@builtin.join/single_element" { - let single_elem_iter : Iter[String] = Iter::singleton("Test") + let single_elem_iter : Iter[String] = [|"Test"|] inspect(single_elem_iter.join(""), content="Test") } ///| test "@builtin.join/multiple_elements_with_separator" { - let iter : Iter[String] = ["A", "B", "C"].iter() + let iter : Iter[String] = [|"A", "B", "C"|] inspect(iter.join(","), content="A,B,C") } ///| test "@builtin.join/multiple_elements_without_separator" { - let iter : Iter[String] = ["A", "B", "C"].iter() + let iter : Iter[String] = [|"A", "B", "C"|] inspect(iter.join(""), content="ABC") } ///| test "@builtin.join/any_to_string_view_element" { - let iter : Iter[StringView] = ["a"[:], "b", "c"].iter() + let iter : Iter[StringView] = [|"a", "b", "c"|] inspect(iter.join("-"), content="a-b-c") } ///| test "Iter::nth" { - let it = () => [1, 2, 3, 4, 5].iter() + let it = () => [|1, 2, 3, 4, 5|] debug_inspect(it().nth(2), content="Some(3)") debug_inspect(it().nth(4), content="Some(5)") debug_inspect(it().nth(5), content="None") @@ -1016,63 +1002,63 @@ test "Iter::nth" { ///| test "@builtin.Iter::maximum" { // Basic functionality with integers - debug_inspect([1, 2, 3, 4, 5].iter().maximum(), content="Some(5)") + debug_inspect([|1, 2, 3, 4, 5|].maximum(), content="Some(5)") // With negative numbers - debug_inspect([-5, -3, -1, -10].iter().maximum(), content="Some(-1)") + debug_inspect([|-5, -3, -1, -10|].maximum(), content="Some(-1)") // Single element - debug_inspect([42].iter().maximum(), content="Some(42)") + debug_inspect([|42|].maximum(), content="Some(42)") // Empty iter should return None - let empty_iter : Iter[Int] = Iter::empty() + let empty_iter : Iter[Int] = [||] debug_inspect(empty_iter.maximum(), content="None") } ///| test "@builtin.Iter::minimum" { // Test with normal sequence - let arr = [3, 1, 4, 1, 5].iter() + let arr = [|3, 1, 4, 1, 5|] debug_inspect(arr.minimum(), content="Some(1)") // Test with single element - let single = [42].iter() + let single = [|42|] debug_inspect(single.minimum(), content="Some(42)") // Test with empty sequence - let empty : Iter[Int] = Iter::empty() + let empty : Iter[Int] = [||] debug_inspect(empty.minimum(), content="None") } ///| test "Iter::intersperse with early termination" { - let iter = [1, 2, 3].iter() + let iter = [|1, 2, 3|] let result = iter.intersperse(0).take(3).collect() debug_inspect(result, content="[1, 0, 2]") } ///| test "Iter::iter method" { - let original = [1, 2, 3].iter() + let original = [|1, 2, 3|] let result = original.iter().collect() debug_inspect(result, content="[1, 2, 3]") } ///| test "Iter::contains method" { - let iter = [1, 2, 3, 4, 5].iter() + let iter = [|1, 2, 3, 4, 5|] inspect(iter.contains(3), content="true") inspect(iter.contains(6), content="false") } ///| test "Iter::flatten method" { - let nested = [[1, 2], [3, 4], [5, 6]].iter().map(x => x.iter()) + let nested = [|[1, 2], [3, 4], [5, 6]|].map(x => x.iter()) let flattened = nested.flatten().collect() debug_inspect(flattened, content="[1, 2, 3, 4, 5, 6]") } ///| test "Iter::add operator" { - let iter1 = [1, 2, 3].iter() - let iter2 = [4, 5, 6].iter() + let iter1 = [|1, 2, 3|] + let iter2 = [|4, 5, 6|] let result = (iter1 + iter2).collect() debug_inspect(result, content="[1, 2, 3, 4, 5, 6]") } @@ -1128,7 +1114,7 @@ test "Float::until negative step" { ///| // test "group_by with consecutive identical elements" { -// let iter = [1, 1, 2, 2, 3, 3].iter() +// let iter = [|1, 1, 2, 2, 3, 3|] // let grouped = iter.group_by((x) => { x }) // @test.assert_eq(grouped.get(1), Some([1, 1])) // @test.assert_eq(grouped.get(2), Some([2, 2])) @@ -1137,7 +1123,7 @@ test "Float::until negative step" { ///| // test "group_by with non-consecutive identical elements" { -// let iter = [1, 2, 1, 3, 2, 1].iter() +// let iter = [|1, 2, 1, 3, 2, 1|] // let grouped = iter.group_by((x) => { x }) // @test.assert_eq(grouped.get(1), Some([1, 1, 1])) // @test.assert_eq(grouped.get(2), Some([2, 2])) @@ -1146,21 +1132,21 @@ test "Float::until negative step" { ///| // test "group_by with empty input" { -// let iter : Iter[Int] = Iter::empty() +// let iter : Iter[Int] = [||] // let grouped = iter.group_by((x) => { x }) // @test.assert_eq(grouped.length(), 0) // } ///| // test "group_by with single element input" { -// let iter = [42].iter() +// let iter = [|42|] // let grouped = iter.group_by((x) => { x }) // @test.assert_eq(grouped.get(42), Some([42])) // } ///| // test "group_by with custom key function" { -// let iter = [1, 2, 3, 4].iter() +// let iter = [|1, 2, 3, 4|] // let grouped = iter.group_by((x) => { x % 2 }) // @test.assert_eq(grouped.get(0), Some([2, 4])) // @test.assert_eq(grouped.get(1), Some([1, 3])) @@ -1168,7 +1154,7 @@ test "Float::until negative step" { ///| // test "group_by with strings" { -// let iter = ["apple", "avocado", "banana", "cherry", "blueberry"].iter() +// let iter = [|"apple", "avocado", "banana", "cherry", "blueberry"|] // let grouped = iter.group_by((s) => { s.charcode_at(0) }) // @test.assert_eq(grouped.get('a'), Some(["apple", "avocado"])) // @test.assert_eq(grouped.get('b'), Some(["banana", "blueberry"])) @@ -1181,13 +1167,13 @@ test "Float::until negative step" { // name : String // age : Int // } -// let people = [ +// let people = [| // Person::{ name: "Alice", age: 25 }, // Person::{ name: "Bob", age: 25 }, // Person::{ name: "Charlie", age: 30 }, // Person::{ name: "Dave", age: 35 }, // Person::{ name: "Eve", age: 30 }, -// ].iter() +// |] // let grouped = people.group_by((p) => { p.age }) // let groups = grouped.values().map((a) => { a.map((p) => { p.name }) }).collect() // @test.assert_eq(groups, [["Alice", "Bob"], ["Charlie", "Eve"], ["Dave"]]) @@ -1195,15 +1181,15 @@ test "Float::until negative step" { ///| test "iter2" { - let iter : Iter[Int] = [].iter() + let iter : Iter[Int] = [||] for _, _ in iter.iter2() { assert_true(false) } - let iter = [0, 1, 2].iter().iter2() + let iter = [|0, 1, 2|].iter2() while iter.next() is Some((i, x)) { @test.assert_eq(i, x) } - let iter = [0, 1, 2].iter() + let iter = [|0, 1, 2|] for i, x in iter.iter2() { @test.assert_eq(i, x) } @@ -1211,7 +1197,7 @@ test "iter2" { ///| test "Iter::iter2" { - let iter = [0, 1, 2].iter() + let iter = [|0, 1, 2|] for i, x in iter.iter2() { @test.assert_eq(i, x) } @@ -1222,41 +1208,41 @@ test "Iter::iter2" { ///| test "Iter::to_json and any on empty" { - let iter = [1, 2, 3].iter() + let iter = [|1, 2, 3|] @json.json_inspect(ToJson::to_json(iter), content=[1, 2, 3]) - let empty : Iter[Int] = Iter::empty() + let empty : Iter[Int] = [||] inspect(empty.any(_ => true), content="false") } ///| test "Iter::map_while empty and last" { - let empty : Iter[Int] = Iter::empty() + let empty : Iter[Int] = [||] debug_inspect(empty.map_while(_ => Some(1)).collect(), content="[]") - debug_inspect([1, 2, 3].iter().last(), content="Some(3)") - let empty_last : Iter[Int] = Iter::empty() + debug_inspect([|1, 2, 3|].last(), content="Some(3)") + let empty_last : Iter[Int] = [||] debug_inspect(empty_last.last(), content="None") } ///| test "Iter::maximum" { - debug_inspect([1, 3, 2].iter().maximum(), content="Some(3)") - let empty : Iter[Int] = Iter::empty() + debug_inspect([|1, 3, 2|].maximum(), content="Some(3)") + let empty : Iter[Int] = [||] debug_inspect(empty.maximum(), content="None") } ///| test "Iter::view variants" { - debug_inspect([1, 2, 3].iter().view(start=-1).collect(), content="[1, 2, 3]") - debug_inspect([1, 2, 3].iter()[:2].collect(), content="[1, 2]") - debug_inspect([1, 2, 3].iter()[1:].collect(), content="[2, 3]") - debug_inspect([1, 2, 3, 4].iter()[1:3].collect(), content="[2, 3]") - debug_inspect([1, 2, 3].iter()[2:2].collect(), content="[]") - debug_inspect([1, 2, 3].iter()[1:0].collect(), content="[]") + debug_inspect([|1, 2, 3|].view(start=-1).collect(), content="[1, 2, 3]") + debug_inspect([|1, 2, 3|][:2].collect(), content="[1, 2]") + debug_inspect([|1, 2, 3|][1:].collect(), content="[2, 3]") + debug_inspect([|1, 2, 3, 4|][1:3].collect(), content="[2, 3]") + debug_inspect([|1, 2, 3|][2:2].collect(), content="[]") + debug_inspect([|1, 2, 3|][1:0].collect(), content="[]") } ///| test "Iter::iter and Iter2 conversions" { - debug_inspect([1, 2].iter().iter().collect(), content="[1, 2]") + debug_inspect([|1, 2|].iter().collect(), content="[1, 2]") let pairs = ['a', 'b'] .iter2() .iter() diff --git a/builtin/iterator.mbt b/builtin/iterator.mbt index eff999309e..2336b5e202 100644 --- a/builtin/iterator.mbt +++ b/builtin/iterator.mbt @@ -63,15 +63,14 @@ pub impl[X : Show] Show for Iter[X] ///| pub impl[X : Show] Show for Iter[X] with fn output(self, logger) { - logger.write_string("[") + logger <+ "[" if self.next() is Some(x) { logger.write_object(x) while self.next() is Some(x) { - logger.write_string(", ") - logger.write_object(x) + logger <+ ", \{cb => cb.write_object(x)}" } } - logger.write_string("]") + logger <+ "]" } ///| @@ -245,12 +244,14 @@ pub fn[X] Iter::new(f : () -> X?, size_hint? : Int) -> Iter[X] { Some(_) => Some(0) None => None } - { f, size_hint } + { f, size_hint, } } ///| /// Creates an empty iterator. /// +/// Prefer the iterator literal `[||]`, which is equivalent and shorter. +/// /// # Type Parameters /// /// - `X`: The type of the elements in the iterator. @@ -265,6 +266,8 @@ pub fn[X] Iter::empty() -> Iter[X] { ///| /// Creates an iterator that contains a single element. /// +/// Prefer the iterator literal `[|elem|]`, which is equivalent and shorter. +/// /// # Type Parameters /// /// - `X`: The type of the element in the iterator. @@ -275,7 +278,7 @@ pub fn[X] Iter::empty() -> Iter[X] { /// /// # Returns /// -/// Returns an iterator of type `Iter[X]` that contains the single element `a`. +/// Returns an iterator of type `Iter[X]` that contains the single element `elem`. pub fn[X] Iter::singleton(elem : X) -> Iter[X] { let mut consumed = false Iter::new( @@ -323,7 +326,7 @@ pub fn[X] Iter::repeat(x : X) -> Iter[X] { /// /// # Returns /// -/// A new iterator that only contains the elements for which the predicate function returns `IterContinue`. +/// A new iterator that only contains the elements for which the predicate function returns `true`. /// /// # Note /// The old iterator `self` must not be used again after calling `filter`. @@ -443,7 +446,7 @@ pub fn[X, Y] Iter::filter_map(self : Iter[X], f : (X) -> Y?) -> Iter[Y] { /// The old iterator `self` and the iterators returned by `f` /// must not be used again after calling `flat_map`. pub fn[X, Y] Iter::flat_map(self : Iter[X], f : (X) -> Iter[Y]) -> Iter[Y] { - let mut current_iter = Some(Iter::empty()) + let mut current_iter = Some([||]) Iter::new(fn() { guard current_iter is Some(iter) else { None } for x = iter.next() { @@ -721,7 +724,7 @@ pub fn[X] Iter::find_first(self : Iter[X], f : (X) -> Bool) -> X? { /// Returns a new iterator that contains the elements of `self` followed by the elements of `other`. /// /// # Note -/// The old iterator `self` and `other` must not be used again after calling `tap`. +/// The old iterators `self` and `other` must not be used again after calling `concat`. pub fn[X] Iter::concat(self : Iter[X], other : Iter[X]) -> Iter[X] { let mut in_first = true let size_hint = match (self.size_hint, other.size_hint) { @@ -771,7 +774,7 @@ pub fn[X] Iter::concat(self : Iter[X], other : Iter[X]) -> Iter[X] { /// ```mbt check /// test { /// let numbers = (1).until(5) -/// let letters = ["a", "b", "c"].iter() +/// let letters = [|"a", "b", "c"|] /// debug_inspect( /// numbers.zip(letters).collect(), /// content="[(1, \"a\"), (2, \"b\"), (3, \"c\")]", @@ -809,7 +812,7 @@ pub impl[T] Add for Iter[T] with fn add(self, other) { #alias(collect) pub fn[X] Iter::to_array(self : Iter[X]) -> Array[X] { let result = match self.size_hint { - Some(n) => Array::new(capacity=n) + Some(n) => Array::Array(capacity=n) None => [] } while self.next() is Some(x) { @@ -868,7 +871,7 @@ pub fn[X] Iter::last(self : Iter[X]) -> X? { /// ```mbt check /// test { /// let arr = [] -/// [1, 2, 3].iter().intersperse(0).each(i => arr.push(i)) +/// [|1, 2, 3|].intersperse(0).each(i => arr.push(i)) /// @test.assert_eq(arr, [1, 0, 2, 0, 3]) /// } /// ``` @@ -968,10 +971,10 @@ pub fn[X] Iter::view(self : Iter[X], start? : Int = 0, end? : Int) -> Iter[X] { /// /// ```mbt check /// test { -/// let iter = [1, 2, 3, 4, 5].iter() +/// let iter = [|1, 2, 3, 4, 5|] /// inspect(iter.contains(3), content="true") /// inspect(iter.contains(6), content="false") -/// let iter = Iter::empty() +/// let iter = [||] /// inspect(iter.contains(1), content="false") /// } /// ``` @@ -989,8 +992,8 @@ pub fn[X : Eq] Iter::contains(self : Iter[X], value : X) -> Bool { } ///| -/// Returns the nth element of the iterator, or `None` if the iterator is -/// shorter than `n` elements. +/// Returns the `n`-th element of the iterator, counting from zero, or `None` if +/// `n` is negative or the iterator has fewer than `n + 1` elements. /// The iterator `self` will advance past the returned element. pub fn[X] Iter::nth(self : Iter[X], n : Int) -> X? { guard n >= 0 else { None } @@ -1044,7 +1047,7 @@ pub fn[X, Y] Iter2::new(f : () -> (X, Y)?, size_hint? : Int) -> Iter2[X, Y] { Some(_) => Some(0) None => None } - Iter2({ f, size_hint }) + Iter2({ f, size_hint, }) } ///| @@ -1069,16 +1072,6 @@ pub fn[X, Y] Iter2::next(self : Iter2[X, Y]) -> (X, Y)? { self.0.next() } -///| -#deprecated("Use Debug instead of Show for debugging purposes. See https://github.com/moonbitlang/core/blob/main/debug/README.mbt.md") -pub impl[X : Show, Y : Show] Show for Iter2[X, Y] - -///| -#warnings("-deprecated") -pub impl[X : Show, Y : Show] Show for Iter2[X, Y] with fn output(self, logger) { - self.0.output(logger) -} - ///| /// Apply callback to each pair. pub fn[X, Y] Iter2::each(self : Iter2[X, Y], f : (X, Y) -> Unit) -> Unit { diff --git a/builtin/json.mbt b/builtin/json.mbt index f55d863afb..888836ad49 100644 --- a/builtin/json.mbt +++ b/builtin/json.mbt @@ -36,9 +36,7 @@ pub enum Json { ///| pub impl Eq for Json with fn equal(a, b) { match (a, b) { - (Null, Null) => true - (True, True) => true - (False, False) => true + (Null, Null) | (True, True) | (False, False) => true (Number(a_num, ..), Number(b_num, ..)) => a_num == b_num (String(a_str), String(b_str)) => a_str == b_str (Array(a_arr), Array(b_arr)) => a_arr == b_arr @@ -293,7 +291,7 @@ pub impl[X : ToJson] ToJson for FixedArray[X] with fn to_json(self) { if len == 0 { return [] } - let res = Array::make_uninit(self.length()) + let res = Array::unsafe_make_uninit(self.length()) for i, x in self { res.unsafe_set(i, ToJson::to_json(x)) } @@ -306,7 +304,7 @@ pub impl[X : ToJson] ToJson for ArrayView[X] with fn to_json(self) { if len == 0 { return [] } - let res = Array::make_uninit(self.length()) + let res = Array::unsafe_make_uninit(self.length()) for i, x in self { res.unsafe_set(i, ToJson::to_json(x)) } diff --git a/builtin/linked_hash_map.mbt b/builtin/linked_hash_map.mbt index 815518b7cb..63628f82b2 100644 --- a/builtin/linked_hash_map.mbt +++ b/builtin/linked_hash_map.mbt @@ -51,6 +51,29 @@ struct Map[K, V] { // Implementations +// SAFETY NOTE for the unchecked probe accesses in this file. +// +// `capacity` is always a power of two and at least 1, `capacity_mask` is +// `capacity - 1`, and `entries.length()` is `capacity`. A probe index is +// therefore always in bounds: it starts as `hash & capacity_mask` and each +// step re-masks with `(idx + 1) & capacity_mask`. `grow` installs the new +// array, capacity and mask together before any rehash probe runs, and +// `set_with_hash` re-masks from scratch after growing. +// +// Only those probe indices use `unsafe_get` / `unsafe_set`. `Map` also +// stores slot indices in the list itself -- `prev`, `tail`, and the index +// `retain` destructures out of an entry -- and every access through one of +// those keeps the checked form. They are in bounds too, being former probe +// indices in a table that never shrinks, but that argument depends on the +// list being maintained correctly rather than on arithmetic alone, so it is +// not one to build unchecked access on. +// +// `shift_back` is the exception, and deliberately so: it is reachable from +// `retain` with a stored index, but `retain` performs its own checked read +// immediately before calling, so `shift_back` has a local caller-based proof +// that does not depend on the list invariant. It carries that proof at its +// definition, and it is what makes its `set_entry` call sound. + ///| let default_init_capacity = 8 @@ -140,8 +163,9 @@ fn[K : Eq, V] Map::set_with_hash( hash : Int, ) -> Unit { // Only grow when actually inserting a new entry, not when updating existing + // SAFETY: masked probe index; see the note at the top of this file. for psl = 0, idx = hash & self.capacity_mask { - match self.entries[idx] { + match self.entries.unsafe_get(idx) { None => { // Need to insert new entry - check if grow is needed first if self.size >= self.grow_at { @@ -149,7 +173,7 @@ fn[K : Eq, V] Map::set_with_hash( // Restart search with new capacity_mask continue 0, hash & self.capacity_mask } - let entry = { prev: self.tail, next: None, psl, key, value, hash } + let entry = { prev: self.tail, next: None, psl, key, value, hash, } self.add_entry_to_tail(idx, entry) return } @@ -167,7 +191,7 @@ fn[K : Eq, V] Map::set_with_hash( continue 0, hash & self.capacity_mask } self.push_away(idx, curr_entry) - let entry = { prev: self.tail, next: None, psl, key, value, hash } + let entry = { prev: self.tail, next: None, psl, key, value, hash, } self.add_entry_to_tail(idx, entry) return } @@ -184,8 +208,9 @@ fn[K, V] Map::push_away( idx : Int, entry : Entry[K, V], ) -> Unit { + // SAFETY: masked probe index; see the note at the top of this file. for psl = entry.psl + 1, idx = (idx + 1) & self.capacity_mask, entry = entry { - match self.entries[idx] { + match self.entries.unsafe_get(idx) { None => { entry.psl = psl self.set_entry(entry, idx) @@ -219,7 +244,7 @@ fn[K, V] Map::set_entry( None => self.tail = new_idx Some(next) => next.prev = new_idx } - self.entries[new_idx] = Some(entry) + self.entries.unsafe_set(new_idx, Some(entry)) } ///| @@ -243,8 +268,9 @@ fn[K, V] Map::set_entry( /// ``` pub fn[K : Hash + Eq, V] Map::get(self : Map[K, V], key : K) -> V? { let hash = Hash::hash(key) + // SAFETY: masked probe index; see the note at the top of this file. for i = 0, idx = hash & self.capacity_mask { - guard self.entries[idx] is Some(entry) else { break None } + guard self.entries.unsafe_get(idx) is Some(entry) else { break None } if entry.hash == hash && entry.key == key { break Some(entry.value) } @@ -260,8 +286,9 @@ pub fn[K : Hash + Eq, V] Map::get(self : Map[K, V], key : K) -> V? { #alias("_[_]") pub fn[K : Hash + Eq, V] Map::at(self : Map[K, V], key : K) -> V { let hash = Hash::hash(key) + // SAFETY: masked probe index; see the note at the top of this file. for i = 0, idx = hash & self.capacity_mask { - guard! self.entries[idx] is Some(entry) + guard! self.entries.unsafe_get(idx) is Some(entry) if entry.hash == hash && entry.key == key { return entry.value } @@ -299,8 +326,9 @@ pub fn[K : Hash + Eq, V] Map::get_or_default( default : V, ) -> V { let hash = Hash::hash(key) + // SAFETY: masked probe index; see the note at the top of this file. for i = 0, idx = hash & self.capacity_mask { - match self.entries[idx] { + match self.entries.unsafe_get(idx) { Some(entry) => { if entry.hash == hash && entry.key == key { break entry.value @@ -323,9 +351,10 @@ pub fn[K : Hash + Eq, V] Map::get_or_init( default : () -> V, ) -> V { let hash = Hash::hash(key) + // SAFETY: masked probe index; see the note at the top of this file. let (idx, psl, new_value, push_away) = for psl = 0, idx = hash & self.capacity_mask { - match self.entries[idx] { + match self.entries.unsafe_get(idx) { Some(entry) => { if entry.hash == hash && entry.key == key { return entry.value @@ -389,8 +418,9 @@ pub fn[K : Hash + Eq, V] Map::update_or_default( f : (V) -> V, ) -> Unit { let hash = Hash::hash(key) + // SAFETY: masked probe index; see the note at the top of this file. let (idx, psl, push_away) = for psl = 0, idx = hash & self.capacity_mask { - match self.entries[idx] { + match self.entries.unsafe_get(idx) { Some(entry) => { if entry.hash == hash && entry.key == key { entry.value = f(entry.value) @@ -411,7 +441,7 @@ pub fn[K : Hash + Eq, V] Map::update_or_default( if push_away is Some(entry) { self.push_away(idx, entry) } - let entry = { prev: self.tail, next: None, psl, hash, key, value: default } + let entry = { prev: self.tail, next: None, psl, hash, key, value: default, } self.add_entry_to_tail(idx, entry) } } @@ -421,8 +451,9 @@ pub fn[K : Hash + Eq, V] Map::update_or_default( pub fn[K : Hash + Eq, V] Map::contains(self : Map[K, V], key : K) -> Bool { // inline Map::get to avoid boxing let hash = Hash::hash(key) + // SAFETY: masked probe index; see the note at the top of this file. for i = 0, idx = hash & self.capacity_mask { - guard self.entries[idx] is Some(entry) else { break false } + guard self.entries.unsafe_get(idx) is Some(entry) else { break false } if entry.hash == hash && entry.key == key { break true } @@ -462,8 +493,9 @@ pub fn[K : Hash + Eq, V : Eq] Map::contains_kv( ) -> Bool { // inline Map::get to avoid boxing let hash = Hash::hash(key) + // SAFETY: masked probe index; see the note at the top of this file. for i = 0, idx = hash & self.capacity_mask { - guard self.entries[idx] is Some(entry) else { break false } + guard self.entries.unsafe_get(idx) is Some(entry) else { break false } if entry.hash == hash && entry.key == key && entry.value == value { break true } @@ -505,8 +537,9 @@ fn[K : Eq, V] Map::remove_with_hash( key : K, hash : Int, ) -> Unit { + // SAFETY: masked probe index; see the note at the top of this file. for i = 0, idx = hash & self.capacity_mask { - guard self.entries[idx] is Some(entry) else { break } + guard self.entries.unsafe_get(idx) is Some(entry) else { break } if entry.hash == hash && entry.key == key { self.remove_entry(entry) self.shift_back(idx) @@ -532,7 +565,7 @@ fn[K, V] Map::add_entry_to_tail( tail => self.entries[tail].unwrap().next = Some(entry) } self.tail = idx - self.entries[idx] = Some(entry) + self.entries.unsafe_set(idx, Some(entry)) self.size += 1 } @@ -550,11 +583,17 @@ fn[K, V] Map::remove_entry(self : Map[K, V], entry : Entry[K, V]) -> Unit { ///| fn[K, V] Map::shift_back(self : Map[K, V], idx : Int) -> Unit { + // SAFETY: the initial `cur` is in bounds by every route its callers take, + // and none of them requires trusting the list invariant: `remove` and + // `update` pass a masked probe index, and `retain` performs its own + // checked `entries[idx]` read immediately before calling. `next` is + // re-masked each step and later `cur` values are previous `next` values. + // This is also what makes the `set_entry` call below sound. for cur = idx { let next = (cur + 1) & self.capacity_mask - match self.entries[next] { + match self.entries.unsafe_get(next) { None | Some({ psl: 0, .. }) => { - self.entries[cur] = None + self.entries.unsafe_set(cur, None) break } Some(entry) => { @@ -594,8 +633,9 @@ fn[K, V] Map::grow(self : Map[K, V]) -> Unit { #owned(outer) fn[K, V] Map::rehash_place_entry(self : Map[K, V], outer : Entry[K, V]) -> Unit { let hash = outer.hash + // SAFETY: masked probe index; see the note at the top of this file. for psl = 0, idx = hash & self.capacity_mask { - match self.entries[idx] { + match self.entries.unsafe_get(idx) { None => { outer.psl = psl outer.prev = self.tail @@ -629,17 +669,16 @@ pub impl[K : Show, V : Show] Show for Map[K, V] ///| pub impl[K : Show, V : Show] Show for Map[K, V] with fn output(self, logger) { - logger.write_string("{") + logger <+ "{" for x = 0, y = self.head { match (x, y) { - (_, None) => break logger.write_string("}") + (_, None) => break logger <+ "}" (i, Some({ key, value, next, .. })) => { if i > 0 { - logger.write_string(", ") + logger <+ ", " } - logger.write_object(key) - logger.write_string(": ") - logger.write_object(value) + logger <+ + "\{cb => cb.write_object(key)}: \{cb => cb.write_object(value)}" continue i + 1, next } } @@ -776,7 +815,7 @@ pub fn[K, V] Map::values(self : Map[K, V]) -> Iter[V] { ///| /// Converts the hash map to an array. pub fn[K, V] Map::to_array(self : Map[K, V]) -> Array[(K, V)] { - let arr = Array::make_uninit(self.size) + let arr = Array::unsafe_make_uninit(self.size) let mut i = 0 for x = self.head { match x { @@ -853,7 +892,7 @@ pub fn[K, V, V2] Map::map(self : Map[K, V], f : (K, V) -> V2) -> Map[K, V2] { for entry = last, idx = self.tail, next = (None : Entry[K, V2]?) { let { prev, psl, hash, key, value, .. } = entry let new_value = f(key, value) - let new_entry = { prev, next, psl, hash, key, value: new_value } + let new_entry = { prev, next, psl, hash, key, value: new_value, } other.entries[idx] = Some(new_entry) if prev != -1 { continue self.entries[prev].unwrap(), prev, Some(new_entry) @@ -885,7 +924,7 @@ pub fn[K, V] Map::copy(self : Map[K, V]) -> Map[K, V] { guard! self.entries[self.tail] is Some(last) for entry = last, idx = self.tail, next = (None : Entry[K, V]?) { let { prev, psl, hash, key, value, .. } = entry - let new_entry = { prev, next, psl, hash, key, value } + let new_entry = { prev, next, psl, hash, key, value, } other.entries[idx] = Some(new_entry) if prev != -1 { continue self.entries[prev].unwrap(), prev, Some(new_entry) @@ -1094,9 +1133,10 @@ pub fn[K : Hash + Eq, V] Map::update( f : (V?) -> V?, ) -> Unit { let hash = Hash::hash(key) + // SAFETY: masked probe index; see the note at the top of this file. let (idx, psl, new_value, push_away) = for psl = 0, idx = hash & self.capacity_mask { - match self.entries[idx] { + match self.entries.unsafe_get(idx) { Some(entry) => { if entry.hash == hash && entry.key == key { // Found the entry, update its value @@ -1169,8 +1209,9 @@ pub fn[K : Hash + Eq, V] Map::update( /// ``` pub fn[V] Map::get_from_bytes(map : Self[Bytes, V], key : BytesView) -> V? { let hash = key.hash() + // SAFETY: masked probe index; see the note at the top of this file. for i = 0, idx = hash & map.capacity_mask { - guard map.entries[idx] is Some(entry) else { break None } + guard map.entries.unsafe_get(idx) is Some(entry) else { break None } if entry.hash == hash && key.equal_to_bytes(entry.key) { break Some(entry.value) } @@ -1206,8 +1247,9 @@ pub fn[V] Map::get_from_bytes(map : Self[Bytes, V], key : BytesView) -> V? { /// ``` pub fn[V] Map::get_from_string(map : Self[String, V], key : StringView) -> V? { let hash = key.hash() + // SAFETY: masked probe index; see the note at the top of this file. for i = 0, idx = hash & map.capacity_mask { - guard map.entries[idx] is Some(entry) else { break None } + guard map.entries.unsafe_get(idx) is Some(entry) else { break None } if entry.hash == hash && key.equal_to_string(entry.key) { break Some(entry.value) } diff --git a/builtin/linked_hash_map_bench_test.mbt b/builtin/linked_hash_map_bench_test.mbt new file mode 100644 index 0000000000..5e4d81e2c5 --- /dev/null +++ b/builtin/linked_hash_map_bench_test.mbt @@ -0,0 +1,93 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +let map_bench_n = 50000 + +///| +test "bench Map::set n=50000" (it : @bench.T) { + it.bench(fn() { + let m : Map[Int, Int] = Map([]) + for i in 0.. total = total + v) + it.keep(total) + }) +} diff --git a/builtin/linked_hash_map_test.mbt b/builtin/linked_hash_map_test.mbt index 9fc5537ccf..e2b247f601 100644 --- a/builtin/linked_hash_map_test.mbt +++ b/builtin/linked_hash_map_test.mbt @@ -102,7 +102,7 @@ test "Map constructor" { ///| test "Map::from_iter" { - let iter = [("a", 1), ("b", 2), ("c", 3)].iter() + let iter = [|("a", 1), ("b", 2), ("c", 3)|] let map = Map::from_iter(iter) debug_inspect( map, diff --git a/builtin/linked_hash_map_wbtest.mbt b/builtin/linked_hash_map_wbtest.mbt index a279b2c606..79b026a45e 100644 --- a/builtin/linked_hash_map_wbtest.mbt +++ b/builtin/linked_hash_map_wbtest.mbt @@ -108,9 +108,9 @@ test "get_or_default" { ///| test "get_or_init" { let m : Map[String, Array[Int]] = Map([]) - m.get_or_init("a", () => Array::new()).push(1) - m.get_or_init("b", () => Array::new()).push(2) - m.get_or_init("a", () => Array::new()).push(3) + m.get_or_init("a", () => Array()).push(1) + m.get_or_init("b", () => Array()).push(2) + m.get_or_init("a", () => Array()).push(3) assert_true(m.get("a") == Some([1, 3])) assert_true(m.get("b") == Some([2])) assert_true(m.length() == 2) @@ -120,10 +120,10 @@ test "get_or_init" { test "get_or_init on push_back" { let m : Map[MyString, Array[Int]] = Map([], capacity=4) inspect(m.grow_at, content="3") - m.set("x", Array::new()) - m.set("xx", Array::new()) + m.set("x", Array()) + m.set("xx", Array()) // inspect(m._debug_entries(), content="_,(0,x,[]),(0,xx,[]),_") - m.get_or_init("a", () => Array::new()).push(1) + m.get_or_init("a", () => Array()).push(1) // inspect(m._debug_entries(), content="_,(0,x,[]),(1,a,[1]),(1,xx,[])") } @@ -328,7 +328,7 @@ test "iter" { .iter() .each(e => { let (k, v) = e - buf.write_string("[\{k}-\{v}]") + buf <+ "[\{k}-\{v}]" }) inspect(buf, content="[1-one][2-two][3-three]") buf.reset() @@ -337,7 +337,7 @@ test "iter" { .take(2) .each(e => { let (k, v) = e - buf.write_string("[\{k}-\{v}]") + buf <+ "[\{k}-\{v}]" }) inspect(buf, content="[1-one][2-two]") } @@ -348,7 +348,7 @@ test "iter order" { m["three"] = 3 m["two"] = 2 let buf = StringBuilder(size_hint=0) - m.each((k, v) => buf.write_string("[\{k}-\{v}]")) + m.each((k, v) => buf <+ "[\{k}-\{v}]") inspect(buf.to_string(), content="[one-1][three-3][two-2]") } @@ -641,8 +641,7 @@ fn[K : Show, V : Show] Map::_debug_entries(self : Map[K, V]) -> String { } match entry { None => buf.write_char('_') - Some({ psl, key, value, .. }) => - buf.write_string("(\{psl},\{key},\{value})") + Some({ psl, key, value, .. }) => buf <+ "(\{psl},\{key},\{value})" } } buf.to_string() diff --git a/builtin/logger_test.mbt b/builtin/logger_test.mbt index 73af967a4b..c08f32d477 100644 --- a/builtin/logger_test.mbt +++ b/builtin/logger_test.mbt @@ -19,13 +19,7 @@ test "logger trait object calls" { Logger::write_string(logger, "Hello, ") Logger::write_char(logger, 'w') &Logger::write_string(logger, "orld!") - &Logger::write_iter( - logger, - [1, 2, 3].iter(), - prefix="[", - suffix="]", - sep="; ", - ) + &Logger::write_iter(logger, [|1, 2, 3|], prefix="[", suffix="]", sep="; ") inspect( sb, content=( diff --git a/builtin/mutarrayview.mbt b/builtin/mutarrayview.mbt index 9fd5376299..b18dc4d0e5 100644 --- a/builtin/mutarrayview.mbt +++ b/builtin/mutarrayview.mbt @@ -244,6 +244,7 @@ pub fn[T] MutArrayView::unsafe_set( /// inspect(view.length(), content="3") // View contains 3 elements /// } /// ``` +#intrinsic("%array.mut_view") pub fn[T] Array::mut_view( self : Array[T], start? : Int = 0, @@ -291,6 +292,7 @@ pub fn[T] Array::mut_view( /// } /// ``` // TODO: rename does not work with docstring test +#intrinsic("%mutarrayview.mut_view") pub fn[T] MutArrayView::mut_view( self : MutArrayView[T], start? : Int = 0, @@ -334,6 +336,7 @@ pub fn[T] MutArrayView::mut_view( /// inspect(view[0], content="2") /// } /// ``` +#intrinsic("%fixedarray.mut_view") pub fn[T] FixedArray::mut_view( self : FixedArray[T], start? : Int = 0, @@ -359,9 +362,11 @@ pub fn[T] FixedArray::mut_view( /// Parameters: /// /// * `self` : The mutable array view to create a new view from. -/// * `start` : The starting index in the array (inclusive). Defaults to 0. -/// * `end` : The ending index in the array (exclusive). Defaults to the -/// length of the array. +/// * `start` : The starting index in the current view (inclusive). Defaults to +/// 0. +/// * `end` : The ending index in the current view (exclusive). Defaults to the +/// length of the current view. +#intrinsic("%mutarrayview.view") #alias("_[_:_]") #alias(sub, deprecated="Use _[_:_] instead") pub fn[T] MutArrayView::view( diff --git a/builtin/option.mbt b/builtin/option.mbt index 13d498b822..398e9eeadc 100644 --- a/builtin/option.mbt +++ b/builtin/option.mbt @@ -92,10 +92,8 @@ pub fn[T, Err : Error] Option::unwrap_or_error( self : T?, err : Err, ) -> T raise Err { - match self { - Some(v) => v - None => raise err - } + guard self is Some(v) else { raise err } + v } ///| @@ -109,8 +107,8 @@ pub impl[X] Default for X? with fn default() { #alias(iterator, deprecated) pub fn[T] Option::iter(self : T?) -> Iter[T] { match self { - Some(v) => Iter::singleton(v) - None => Iter::empty() + Some(v) => [|v|] + None => [||] } } diff --git a/builtin/panic_nonjs_test.mbt b/builtin/panic_nonjs_test.mbt index 0ffa5f7414..c41d287868 100644 --- a/builtin/panic_nonjs_test.mbt +++ b/builtin/panic_nonjs_test.mbt @@ -14,6 +14,6 @@ ///| test "panic array_pop_exn_empty" { - let arr : Array[Int] = Array::new() + let arr : Array[Int] = Array() arr.unsafe_pop() |> ignore // This should panic } diff --git a/builtin/panic_test.mbt b/builtin/panic_test.mbt index 90dc556dcf..d3f33c4eab 100644 --- a/builtin/panic_test.mbt +++ b/builtin/panic_test.mbt @@ -158,11 +158,6 @@ test "panic to_octets coverage for negative number than required" { (-123456789N).to_octets() |> ignore } -///| -test "panic from_octets coverage for empty octets" { - BigInt::from_octets(b"") |> ignore -} - ///| test "panic sub_string with invalid byte_length" { let bytes = b"Hello, World!" diff --git a/builtin/physical_equal_nan_test.mbt b/builtin/physical_equal_nan_test.mbt new file mode 100644 index 0000000000..2fbbfe9d55 --- /dev/null +++ b/builtin/physical_equal_nan_test.mbt @@ -0,0 +1,111 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// These tests *document* what `physical_equal` currently does with NaN; they do +// not specify it. `physical_equal` is explicitly a performance hint whose result +// "may not be consistent across different backends and/or different compiler +// optimization settings", so nothing outside this file should depend on these +// answers. They are pinned here so that a change in any backend shows up as a +// visible diff rather than as a silent behavior change. +// +// As of this commit every case below produces the same answer on all four +// targets (native, js, wasm, wasm-gc), so no per-target expectations are needed. + +///| +/// On an unboxed float, `%refeq` lowers to an IEEE-754 value comparison, so +/// `physical_equal` inherits the fact that NaN is not equal to itself: it is +/// *not* reflexive on NaN, not even when both arguments are the very same +/// binding. +test "physical_equal is not reflexive on Double NaN" { + let nan = @double.not_a_number + inspect(physical_equal(nan, nan), content="false") + // For reference, the value comparison it agrees with: + inspect(nan == nan, content="false") +} + +///| +/// Same for `Float`. +test "physical_equal is not reflexive on Float NaN" { + let nan = @float.not_a_number + inspect(physical_equal(nan, nan), content="false") + inspect(nan == nan, content="false") +} + +///| +/// NaN payloads are not distinguished either: two NaNs with *different* bit +/// patterns compare `false`, exactly like two NaNs with the same bit pattern. +/// So `physical_equal` gives no way to tell NaNs apart. +test "physical_equal on NaNs with different bit patterns" { + let quiet_nan = @double.not_a_number + let payload_nan = 0x7FF8_0000_0000_0DEFUL.reinterpret_as_double() + let negative_nan = -@double.not_a_number + assert_true(payload_nan.is_nan()) + assert_true(negative_nan.is_nan()) + inspect(physical_equal(quiet_nan, payload_nan), content="false") + inspect(physical_equal(quiet_nan, negative_nan), content="false") + inspect(physical_equal(payload_nan, payload_nan), content="false") +} + +///| +/// NaN is the only float for which reflexivity fails. Ordinary values -- and +/// infinities -- are physically equal whenever they are equal by value, even +/// when they come from unrelated computations, because there is no identity to +/// compare in the first place. +test "physical_equal on non-NaN Doubles is value equality" { + let one = 1.0 + let also_one = 0.5 + 0.5 + inspect(physical_equal(one, also_one), content="true") + inspect(physical_equal(@double.infinity, @double.infinity), content="true") + inspect(physical_equal(0.0, -0.0), content="true") + inspect(0.0 == -0.0, content="true") +} + +///| +/// Reading NaN back out of a container does not change the answer: what is +/// compared is still the loaded floating-point value. +test "physical_equal on NaN elements of an array" { + let nan = @double.not_a_number + let arr : ReadOnlyArray[Double] = [nan, nan] + inspect(physical_equal(arr[0], arr[0]), content="false") + inspect(physical_equal(arr[1], arr[0]), content="false") + // The array itself is a heap value, so it *is* physically equal to itself. + inspect(physical_equal(arr, arr), content="true") +} + +///| +/// Boxing a NaN restores reflexivity, since the comparison is then between the +/// boxes and never reaches the float. Two separately allocated boxes holding +/// NaN remain distinct, as they would for any other payload. +test "physical_equal on boxed NaN compares the box" { + let nan = @double.not_a_number + let boxed = Some(nan) + let other_box = Some(nan) + inspect(physical_equal(boxed, boxed), content="true") + inspect(physical_equal(boxed, other_box), content="false") +} + +///| +fn[T] physical_equal_via_generic(a : T, b : T) -> Bool { + physical_equal(a, b) +} + +///| +/// Going through a polymorphic call site -- where a backend might box the +/// argument -- does not change any of the above. +test "physical_equal on NaN through a generic call site" { + let nan = @double.not_a_number + let boxed = Some(nan) + inspect(physical_equal_via_generic(nan, nan), content="false") + inspect(physical_equal_via_generic(boxed, boxed), content="true") +} diff --git a/builtin/pkg.generated.mbti b/builtin/pkg.generated.mbti index 254d09e035..ff222a7225 100644 --- a/builtin/pkg.generated.mbti +++ b/builtin/pkg.generated.mbti @@ -64,6 +64,7 @@ pub fn ArgsLoc::to_string(Self) -> String pub impl Show for ArgsLoc type Array[T] +pub fn[T] Array::Array(capacity? : Int) -> Self[T] pub fn[T] Array::add(Self[T], Self[T]) -> Self[T] #alias(every) pub fn[T] Array::all(Self[T], (T) -> Bool raise?) -> Bool raise? @@ -128,10 +129,12 @@ pub fn[T, U] Array::mapi(Self[T], (Int, T) -> U raise?) -> Self[U] raise? #alias(mapi_inplace, deprecated) pub fn[T] Array::mapi_in_place(Self[T], (Int, T) -> T raise?) -> Unit raise? pub fn[T] Array::mut_view(Self[T], start? : Int, end? : Int) -> MutArrayView[T] +#deprecated pub fn[T] Array::new(capacity? : Int) -> Self[T] pub fn[T] Array::pop(Self[T]) -> T? pub fn[T] Array::push(Self[T], T) -> Unit pub fn[T] Array::push_iter(Self[T], Iter[T]) -> Unit +pub fn[T] Array::release_unused(Self[T], placeholder~ : T) -> Unit pub fn[T] Array::remove(Self[T], Int) -> T pub fn[T] Array::repeat(Self[T], Int) -> Self[T] pub fn[T] Array::reserve_capacity(Self[T], Int) -> Unit @@ -343,8 +346,6 @@ pub fn[X, Y] Iter2::iter2(Self[X, Y]) -> Self[X, Y] pub fn[X, Y] Iter2::new(() -> (X, Y)?, size_hint? : Int) -> Self[X, Y] pub fn[X, Y] Iter2::next(Self[X, Y]) -> (X, Y)? pub fn[X, Y] Iter2::to_array(Self[X, Y]) -> Array[(X, Y)] -#deprecated -pub impl[X : Show, Y : Show] Show for Iter2[X, Y] pub enum Json { Null @@ -459,7 +460,7 @@ pub fn SourceLoc::to_string(Self) -> String pub impl Show for SourceLoc type StringBuilder -#alias(new) +#alias(new, deprecated) pub fn StringBuilder::StringBuilder(size_hint? : Int) -> Self pub fn StringBuilder::is_empty(Self) -> Bool pub fn StringBuilder::reset(Self) -> Unit diff --git a/builtin/range.mbt b/builtin/range.mbt index b8e898cf44..0bbbf5a777 100644 --- a/builtin/range.mbt +++ b/builtin/range.mbt @@ -24,7 +24,7 @@ fn[T : Add + Compare + Default] until_impl( ) -> Iter[T] { let zero = Default::default() if step == zero { - return Iter::empty() + return [||] } let mut i = start let mut done = false diff --git a/builtin/readonlyarray.mbt b/builtin/readonlyarray.mbt index be660f5aa2..840ced8b80 100644 --- a/builtin/readonlyarray.mbt +++ b/builtin/readonlyarray.mbt @@ -61,7 +61,7 @@ pub fn[T] ReadOnlyArray::from_array(array : ArrayView[T]) -> ReadOnlyArray[T] { /// # Example /// ```mbt check /// test { -/// let iter = [1, 2, 3].iter() +/// let iter = [|1, 2, 3|] /// let immut_array = ReadOnlyArray::from_iter(iter) /// inspect(immut_array[0], content="1") /// } @@ -100,6 +100,7 @@ pub fn[T] ReadOnlyArray::makei( /// debug_inspect(arr.get(5), content="None") /// } /// ``` +#intrinsic("%readonlyarray.get_opt") pub fn[T] ReadOnlyArray::get(self : ReadOnlyArray[T], index : Int) -> T? { self.unsafe_reinterpret_to_fixed_array().get(index) } @@ -457,7 +458,7 @@ pub fn[A, B] ReadOnlyArray::foldi( /// test { /// let arr : ReadOnlyArray[Int] = [2, 3] /// let sum = arr.rev_foldi(init=0, fn(i, acc, x) { acc + i * x }) -/// inspect(sum, content="2") // 0 + (1*3) + (0*2) = 3 +/// inspect(sum, content="2") // 0 + (0*3) + (1*2) = 2 /// } /// ``` pub fn[A, B] ReadOnlyArray::rev_foldi( @@ -866,6 +867,7 @@ pub fn[T : Compare] ReadOnlyArray::lexical_compare( /// inspect(view[2], content="4") /// } /// ``` +#intrinsic("%readonlyarray.view") #alias("_[_:_]") #alias(sub, deprecated="Use _[_:_] instead") pub fn[T] ReadOnlyArray::view( diff --git a/builtin/readonlyarray_test.mbt b/builtin/readonlyarray_test.mbt index d027153a20..933a199552 100644 --- a/builtin/readonlyarray_test.mbt +++ b/builtin/readonlyarray_test.mbt @@ -20,7 +20,7 @@ test "ReadOnlyArray constructors" { inspect(arr1[2], content="3") // Test from_iter - let arr2 = ReadOnlyArray::from_iter([4, 5, 6].iter()) + let arr2 = ReadOnlyArray::from_iter([|4, 5, 6|]) inspect(arr2[1], content="5") // Test makei diff --git a/builtin/simd.mbt b/builtin/simd.mbt index 1bb82a4583..a3a6d59853 100644 --- a/builtin/simd.mbt +++ b/builtin/simd.mbt @@ -126,6 +126,13 @@ fn v128_and(a : V128, b : V128) -> V128 { v128_make(v128_lo(a) & v128_lo(b), v128_hi(a) & v128_hi(b)) } +///| +#cfg(any(target="native", target="wasm")) +#intrinsic("%v128.v128_or") +fn v128_or(a : V128, b : V128) -> V128 { + v128_make(v128_lo(a) | v128_lo(b), v128_hi(a) | v128_hi(b)) +} + ///| #cfg(any(target="native", target="wasm")) #intrinsic("%v128.v128_any_true") diff --git a/builtin/string.mbt b/builtin/string.mbt index 6b0659090a..31145b7885 100644 --- a/builtin/string.mbt +++ b/builtin/string.mbt @@ -55,7 +55,7 @@ fn code_point_of_surrogate_pair(leading : Int, trailing : Int) -> Char { /// let s = "Hello🤣" /// inspect(s.char_length(), content="6") // 6 actual characters /// inspect(s.length(), content="7") -/// } // 5 ASCII chars + 2 surrogate pairs +/// } // 5 ASCII chars + 1 surrogate pair (2 code units) /// ``` #alias(codepoint_length, deprecated) pub fn String::char_length( @@ -257,7 +257,7 @@ fn unsafe_to_bytes(array : FixedArray[Byte]) -> Bytes = "%identity" pub fn String::to_array(self : String) -> Array[Char] { self .iter() - .fold(init=Array::new(capacity=self.length()), (rv, c) => { + .fold(init=Array(capacity=self.length()), (rv, c) => { rv.push(c) rv }) @@ -277,7 +277,7 @@ pub fn String::code_units(self : String) -> ArrayView[UInt16] { /// Returns an iterator over the Unicode characters in the string. /// /// Note: This iterator yields Unicode characters, not Utf16 code units. -/// As a result, the count of characters returned by `iterator().count()` may not be equal to the length of the string returned by `length()`. +/// As a result, the count of characters returned by `iter().count()` may not be equal to the length of the string returned by `length()`. /// /// ```mbt check /// test { @@ -375,7 +375,7 @@ pub fn String::any(self : String, f : (Char) -> Bool raise?) -> Bool raise? { /// - The function iterates over the string in reverse order. /// - If a trailing surrogate is encountered, it checks for a preceding leading surrogate to form a complete Unicode code point. /// - Yields each character or combined code point to the iterator. -/// - Stops iteration if the `yield_` function returns `IterEnd`. +/// - Stops once the beginning of the string is reached, after which the iterator yields `None`. /// /// # Examples /// diff --git a/builtin/string_char_set_bench_test.mbt b/builtin/string_char_set_bench_test.mbt new file mode 100644 index 0000000000..93094464f5 --- /dev/null +++ b/builtin/string_char_set_bench_test.mbt @@ -0,0 +1,117 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +let string_char_set_bench_size = 100_000 + +///| +let string_char_set_bench_ascii_set = "z \t\n\r" + +///| +let string_char_set_bench_contains_miss : String = "a".repeat( + string_char_set_bench_size, +) + +///| +let string_char_set_bench_contains_match_at_end : String = "a".repeat( + string_char_set_bench_size - 1, + ) + + "z" + +///| +let string_char_set_bench_trim_start : String = " ".repeat( + string_char_set_bench_size, + ) + + "x" + +///| +let string_char_set_bench_trim_end : String = "x" + + " ".repeat(string_char_set_bench_size) + +///| +test "bench StringView::contains_any ASCII miss n=100000" (it : @bench.T) { + it.bench(fn() { + it.keep( + string_char_set_bench_contains_miss.contains_any( + chars=string_char_set_bench_ascii_set, + ), + ) + }) +} + +///| +test "bench StringView::contains_any ASCII match at end n=100000" ( + it : @bench.T, +) { + it.bench(fn() { + it.keep( + string_char_set_bench_contains_match_at_end.contains_any( + chars=string_char_set_bench_ascii_set, + ), + ) + }) +} + +///| +test "bench StringView::trim_start ASCII n=100000" (it : @bench.T) { + it.bench(fn() { + it.keep(string_char_set_bench_trim_start.trim_start().length()) + }) +} + +///| +test "bench StringView::trim_end ASCII n=100000" (it : @bench.T) { + it.bench(fn() { it.keep(string_char_set_bench_trim_end.trim_end().length()) }) +} + +///| +test "bench StringView::trim ASCII n=100000" (it : @bench.T) { + let input = string_char_set_bench_trim_start + + " ".repeat(string_char_set_bench_size) + it.bench(fn() { it.keep(input.trim().length()) }) +} + +///| +let string_char_set_short_trim_inputs : Array[String] = [ + " hello ", "x", "", " ", "no_trim_needed", "\t indented line \n", +] + +///| +let string_char_set_short_contains_inputs : Array[String] = [ + "hello,world", "a=b&c=d", "plain", "", +] + +///| +test "bench StringView::trim short inputs" (it : @bench.T) { + it.bench(fn() { + let mut total = 0 + for s in string_char_set_short_trim_inputs { + total += s.trim().length() + } + it.keep(total) + }) +} + +///| +test "bench StringView::contains_any short inputs" (it : @bench.T) { + it.bench(fn() { + let mut hits = 0 + for s in string_char_set_short_contains_inputs { + if s.contains_any(chars=",&= ") { + hits += 1 + } + } + it.keep(hits) + }) +} diff --git a/builtin/string_char_set_quickcheck_test.mbt b/builtin/string_char_set_quickcheck_test.mbt new file mode 100644 index 0000000000..1ed7f11aeb --- /dev/null +++ b/builtin/string_char_set_quickcheck_test.mbt @@ -0,0 +1,226 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Property-based tests for the character-set paths of `contains_any` and +// `trim`/`trim_start`/`trim_end`: the bitmap and SIMD scans must agree with a +// straightforward character-by-character model, on strings and on views with +// non-zero offsets, for ASCII sets of every size class (SIMD-broadcast, +// bitmap-only, and the non-ASCII fallback). + +///| +/// Characters a haystack is built from: mostly ASCII (including every member +/// of the generated sets and both sides of the 0x20 boundary), plus non-ASCII +/// BMP and non-BMP characters so surrogate pairs appear. +fn haystack_char(seed : Int) -> Char { + match seed & 0xF { + 0 => ' ' + 1 => '\t' + 2 => '\n' + 3 => '\r' + 4 => ',' + 5 => ';' + 6 => 'z' + 7 => 'a' + 8 => '\u{00}' + 9 => '\u{1F}' + 10 => '\u{7F}' + 11 => '中' + 12 => '😀' + _ => (0x20 + ((seed >> 4) & 0x3F)).unsafe_to_char() + } +} + +///| +/// Set members: mostly ASCII delimiters; occasionally non-ASCII, which sends +/// the whole set down the fallback path. +fn set_char(seed : Int) -> Char { + match seed & 0xF { + 0 => ' ' + 1 => '\t' + 2 => '\n' + 3 => '\r' + 4 => ',' + 5 => ';' + 6 => '/' + 7 => 'z' + 8 => '0' + 9 => '\u{00}' + 10 => '\u{7F}' + 11 => '中' + 12 => '😀' + _ => 'a' + } +} + +///| +fn chars_to_string(chars : Array[Char]) -> String { + let buf = StringBuilder(size_hint=chars.length()) + for c in chars { + buf.write_char(c) + } + buf.to_string() +} + +///| +fn model_member(chars : String, c : Char) -> Bool { + for d in chars { + if c == d { + return true + } + } + false +} + +///| +fn model_contains_any(s : String, chars : String) -> Bool { + for c in s { + if model_member(chars, c) { + return true + } + } + false +} + +///| +fn model_trim_start(s : String, chars : String) -> String { + let arr = s.to_array() + let mut start = 0 + while start < arr.length() && model_member(chars, arr[start]) { + start += 1 + } + chars_to_string(arr[start:].to_owned()) +} + +///| +fn model_trim_end(s : String, chars : String) -> String { + let arr = s.to_array() + let mut end = arr.length() + while end > 0 && model_member(chars, arr[end - 1]) { + end -= 1 + } + chars_to_string(arr[:end].to_owned()) +} + +///| +/// Embeds `middle` between `prefix`/`suffix` and returns the view covering +/// exactly `middle`, so the scans see non-zero view offsets. +fn embedded_view( + prefix : String, + middle : String, + suffix : String, +) -> StringView { + let full = prefix + middle + suffix + full[prefix.length():prefix.length() + middle.length()] +} + +///| +test "quickcheck: contains_any agrees with the character model" { + @quickcheck.check(count=300, (input : (Array[Int], Array[Int])) => { + let (hay_seeds, set_seeds) = input + let hay = chars_to_string(hay_seeds.map(haystack_char)) + let set = chars_to_string(set_seeds.map(set_char)) + // The set is passed as a view with non-zero offsets, so a scan that read + // beyond the set view would see the sentinel characters instead. + let set_view = embedded_view("Q", set, "Q") + let expected = model_contains_any(hay, set) + guard hay.contains_any(chars=set_view) == expected else { return false } + // The same scan through a haystack view with non-zero offsets. + embedded_view("ab", hay, "yz").contains_any(chars=set_view) == expected + }) +} + +///| +test "quickcheck: trims agree with the character model" { + @quickcheck.check(count=300, (input : (Array[Int], Array[Int])) => { + let (hay_seeds, set_seeds) = input + let hay = chars_to_string(hay_seeds.map(haystack_char)) + let set = chars_to_string(set_seeds.map(set_char)) + let view = embedded_view(" 😀", hay, "😀 ") + let set_view = embedded_view("Q", set, "Q") + guard view.trim_start(chars=set_view).to_owned() == + model_trim_start(hay, set) else { + return false + } + guard view.trim_end(chars=set_view).to_owned() == model_trim_end(hay, set) else { + return false + } + // The fused trim must agree with composing the two one-sided trims. + view.trim(chars=set_view).to_owned() == + model_trim_end(model_trim_start(hay, set), set) + }) +} + +///| +/// Exhaustively places a set member at every position of an otherwise clean +/// string for every length spanning several 8-unit SIMD blocks, for a +/// SIMD-broadcast-sized set, a bitmap-only-sized set, and the default +/// whitespace set. +test "char set boundary sweep" { + let sets = [" \t\n\r", ",;:.!?", "abcdefghij"] + for set in sets { + let probe = set[0:1].to_owned() + for len in 0..<=24 { + let clean = "x".repeat(len) + assert_false(clean.contains_any(chars=set)) + assert_eq(clean.trim(chars=set).to_owned(), clean) + for pos in 0.. String { + String::make(half, 'a') + "Z" + String::make(half - 1, 'a') +} + +///| +test "bench find adversary m=64 n=4096" (it : @bench.T) { + let haystack = String::make(4096, 'a') + let needle = string_find_adversary_needle(32) + it.bench(fn() { it.keep(haystack.find(needle)) }) +} + +///| +test "bench rev_find adversary m=64 n=4096" (it : @bench.T) { + let haystack = String::make(4096, 'a') + let needle = string_find_adversary_needle(32) + it.bench(fn() { it.keep(haystack.rev_find(needle)) }) +} + +///| +test "bench find adversary m=512 n=65536" (it : @bench.T) { + let haystack = String::make(65536, 'a') + let needle = string_find_adversary_needle(256) + it.bench(fn() { it.keep(haystack.find(needle)) }) +} + +///| +test "bench find dense miss fast path n=4096" (it : @bench.T) { + let haystack = String::make(4096, 'a') + it.bench(fn() { it.keep(haystack.find("aaaaZ")) }) +} + +///| +test "bench find rare hit end fast path n=4096" (it : @bench.T) { + let haystack = String::make(4092, 'a') + "Zabc" + it.bench(fn() { it.keep(haystack.find("Zabc")) }) +} diff --git a/builtin/string_find_code_unit.mbt b/builtin/string_find_code_unit.mbt index a66252a5b2..e48515ddab 100644 --- a/builtin/string_find_code_unit.mbt +++ b/builtin/string_find_code_unit.mbt @@ -211,7 +211,7 @@ fn find_by_two_anchors(target : StringView, pattern : StringView) -> Int? { let failures = failures + 1 let scanned = found - target_start if two_anchor_should_fallback(failures, scanned) { - break find_pattern_scalar_from(target, pattern, scanned + 1) + break find_pattern_kmp_from(target, pattern, scanned + 1) } continue found + 1, failures } nobreak { @@ -259,7 +259,7 @@ fn rev_find_by_two_anchors(target : StringView, pattern : StringView) -> Int? { let failures = failures + 1 let scanned = last_candidate - candidate if two_anchor_should_fallback(failures, scanned) { - break rev_find_pattern_scalar_before(target, pattern, candidate) + break rev_find_pattern_kmp_before(target, pattern, candidate) } continue found, failures } nobreak { @@ -269,80 +269,133 @@ fn rev_find_by_two_anchors(target : StringView, pattern : StringView) -> Int? { ///| // Dense first/last-anchor matches make repeated SIMD candidate scans more -// expensive than direct comparison. Cut over after enough false candidates, -// following the guarded-scanner strategy used by Bytes search. +// expensive than direct comparison. Cut over to the guaranteed-linear KMP +// fallback either early, when failures pile up relative to progress +// (`failures > 4 + scanned / 8` — as soon as the 5th failure for +// candidates packed near the scan start), or at the hard cap +// (`failures > 64`, i.e. the 65th failed verification). Either way the +// failure count at cutover is at most 65, keeping pre-cutover +// verification work at O(pattern), so find/rev_find stay +// O(target + pattern) even on adversarial inputs. #inline fn two_anchor_should_fallback(failures : Int, scanned : Int) -> Bool { failures > 64 || failures > 4 + scanned / 8 } ///| -// Direct forward fallback used after the two-anchor filter encounters dense -// false positives. It checks the middle first because the cutover has already -// established that first/last matches are not selective. `start` is relative -// to `target`. -fn find_pattern_scalar_from( +// Longest-proper-border table for the KMP fallbacks: `table[i]` is the +// length of the longest proper prefix of `pattern[0..=i]` that is also a +// suffix of it. Only built after the two-anchor filter cuts over, so the +// O(pattern) allocation is paid exclusively on pathological inputs. +fn kmp_failure_table(pattern : StringView) -> FixedArray[Int] { + let m = pattern.length() + let table = FixedArray::make(m, 0) + let mut k = 0 + for i in 1.. 0 && c != pattern.unsafe_get(k) { + k = table[k - 1] + } + if c == pattern.unsafe_get(k) { + k += 1 + } + table[i] = k + } + table +} + +///| +// Guaranteed-linear forward fallback used after the two-anchor filter +// encounters dense false positives: KMP over the remaining candidates, so +// the whole search stays O(target + pattern) even when both anchors and +// long pattern prefixes recur throughout the target. Returns the first +// occurrence starting at a target-relative position >= `start`. +fn find_pattern_kmp_from( target : StringView, pattern : StringView, start : Int, ) -> Int? { - let pattern_len = pattern.length() - let last_offset = pattern_len - 1 - let first = pattern.unsafe_get(0) - let last = pattern.unsafe_get(last_offset) - let last_candidate = target.length() - pattern_len - for candidate in start..<=last_candidate { - let middle_matches = for i in 1.. 0 && c != pattern.unsafe_get(k) { + k = table[k - 1] } - if middle_matches && - target.unsafe_get(candidate) == first && - target.unsafe_get(candidate + last_offset) == last { - break Some(candidate) + if c == pattern.unsafe_get(k) { + k += 1 + } + if k == m { + return Some(i - m + 1) } - } nobreak { - None } + None } ///| -// Direct reverse fallback used after the two-anchor filter encounters dense -// false positives. It checks the middle first because the cutover has already -// established that first/last matches are not selective. `candidate_end` is -// target-relative and exclusive. -fn rev_find_pattern_scalar_before( +// Guaranteed-linear reverse fallback used after the two-anchor filter +// encounters dense false positives: forward KMP over the prefix that can +// still contain a hit, keeping the rightmost match, so the whole search +// stays O(target + pattern). Returns the last occurrence starting at a +// target-relative position strictly below `candidate_end` (exclusive). +// The caller must ensure +// `candidate_end <= target.length() - pattern.length() + 1`, so that the +// scan below stays in bounds. +fn rev_find_pattern_kmp_before( target : StringView, pattern : StringView, candidate_end : Int, ) -> Int? { guard candidate_end > 0 else { return None } - let pattern_len = pattern.length() - let last_offset = pattern_len - 1 - let first = pattern.unsafe_get(0) - let last = pattern.unsafe_get(last_offset) - for candidate = candidate_end - 1; candidate >= 0; { - let middle_matches = for i in 1.. 0 && c != pattern.unsafe_get(k) { + k = table[k - 1] } - if middle_matches && - target.unsafe_get(candidate) == first && - target.unsafe_get(candidate + last_offset) == last { - break Some(candidate) + if c == pattern.unsafe_get(k) { + k += 1 } - continue candidate - 1 - } nobreak { + if k == m { + best = i - m + 1 + // keep scanning: a later (more rightward) overlapping match wins + k = table[k - 1] + } + } + if best >= 0 { + Some(best) + } else { None } } +///| +test "kmp fallbacks handle periodic and overlapping patterns" { + // failure table borders matter for periodic patterns + let t : StringView = "aaaaaa" + assert_true(find_pattern_kmp_from(t, "aaa", 0) is Some(0)) + assert_true(find_pattern_kmp_from(t, "aaa", 2) is Some(2)) + assert_true(find_pattern_kmp_from(t, "aaa", 4) is None) + assert_true(rev_find_pattern_kmp_before(t, "aaa", 4) is Some(3)) + assert_true(rev_find_pattern_kmp_before(t, "aaa", 1) is Some(0)) + let u : StringView = "ababcababab" + assert_true(find_pattern_kmp_from(u, "abab", 0) is Some(0)) + assert_true(find_pattern_kmp_from(u, "abab", 1) is Some(5)) + assert_true(rev_find_pattern_kmp_before(u, "abab", 8) is Some(7)) + assert_true(rev_find_pattern_kmp_before(u, "ababc", 7) is Some(0)) + assert_true(find_pattern_kmp_from(u, "abcabc", 0) is None) + // start beyond any match and empty prefix bound + assert_true(rev_find_pattern_kmp_before(u, "abab", 0) is None) +} + ///| // Compares two raw UTF-16 ranges. The caller must ensure both ranges are in // bounds; using backing strings directly also permits isolated surrogate code diff --git a/builtin/string_find_fallback_test.mbt b/builtin/string_find_fallback_test.mbt new file mode 100644 index 0000000000..cd879021de --- /dev/null +++ b/builtin/string_find_fallback_test.mbt @@ -0,0 +1,161 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// End-to-end coverage for the guaranteed-linear KMP fallback behind the +// SIMD two-anchor substring search. The cutover has two triggers: an +// early ratio check (`failures > 4 + scanned / 8`) and a hard cap +// (`failures > 64`). The decoy constructions here space their false +// candidates one period apart, which keeps the ratio check quiet, so +// these tests deterministically force the HARD-CAP path by providing +// more than 65 positions where both anchor code units match but +// verification fails, in the direction under test, before the +// interesting region — misses, hits beyond the cutover point, +// overlapping hits resolved through the fallback, and naive-reference +// cross-checks. + +///| +fn adversarial_needle() -> String { + // both anchors are 'a' and a 32-unit prefix of the needle recurs at every + // position of an all-'a' haystack: every candidate passes the anchor test + // and fails only deep inside verification + String::make(32, 'a') + "Z" + String::make(31, 'a') +} + +///| +test "dense-anchor miss stays correct through the kmp fallback" { + let haystack = String::make(4096, 'a') + debug_inspect(haystack.find(adversarial_needle()), content="None") + debug_inspect(haystack.rev_find(adversarial_needle()), content="None") +} + +///| +test "hit located after the cutover point is found" { + let needle = adversarial_needle() + let haystack = String::make(2000, 'a') + needle + String::make(50, 'a') + debug_inspect(haystack.find(needle), content="Some(2000)") + debug_inspect(haystack.rev_find(needle), content="Some(2000)") + // a second occurrence: find returns the first, rev_find the last + let doubled = String::make(1000, 'a') + + needle + + String::make(1000, 'a') + + needle + debug_inspect(doubled.find(needle), content="Some(1000)") + debug_inspect(doubled.rev_find(needle), content="Some(2064)") +} + +///| +// One decoy block per repetition: for the periodic needle used below +// (anchors 'a' and 'b', length 82), each block contributes exactly one +// position whose anchors match but whose interior verification fails. +fn decoy_blocks(count : Int) -> String { + (String::make(81, 'a') + "b").repeat(count) +} + +///| +// 40a b 40a b 40a b: contains the 82-unit needle 40a b 40a b at +// offsets 0 and 41 — two overlapping occurrences. +fn overlapping_hits_block() -> String { + let half = String::make(40, 'a') + "b" + half + half + half +} + +///| +test "forward cutover then kmp finds the first hit" { + let needle = String::make(40, 'a') + "b" + String::make(40, 'a') + "b" + // 70 false candidates precede the hits, exhausting the 65-failure budget; + // the first genuine occurrence straddles the last decoy block and the + // hits block (40 decoy 'a's, the decoy 'b' at 5739, 40 hit 'a's, and the + // hit 'b' at 5780), so kmp must report 5699, not the hits-block start + let haystack = decoy_blocks(70) + overlapping_hits_block() + debug_inspect(haystack.find(needle), content="Some(5699)") +} + +///| +test "reverse cutover then kmp keeps the rightmost overlapping hit" { + let needle = String::make(40, 'a') + "b" + String::make(40, 'a') + "b" + // hits sit at the start; 70 false candidates follow them, so the reverse + // scan burns its budget before reaching the hits and hands off to the + // kmp fallback, which must resolve the overlap to the rightmost start + let haystack = overlapping_hits_block() + decoy_blocks(70) + debug_inspect(haystack.rev_find(needle), content="Some(41)") + // sanity: the forward search over the same input needs no fallback + debug_inspect(haystack.find(needle), content="Some(0)") +} + +///| +fn naive_find(haystack : String, needle : String) -> Int? { + let n = haystack.length() + let m = needle.length() + for start in 0..<=(n - m) { + let matched = for i in 0.. Int? { + let n = haystack.length() + let m = needle.length() + for start = n - m; start >= 0; { + let matched = for i in 0.. String { + let sb = StringBuilder(size_hint=bits.length()) + for b in bits { + sb.write_char(if b { 'a' } else { 'b' }) + } + sb.to_string() +} + +///| +test "quickcheck: find and rev_find agree with naive references" { + @quickcheck.check( + (input : (Array[Bool], Array[Bool])) => { + let (text_bits, needle_bits) = input + let text = bools_to_ab(text_bits) + // clamp the needle into the multi-unit regime the two-anchor search + // handles; single-unit and empty needles take separate code paths + guard needle_bits.length() >= 2 else { return true } + let needle = bools_to_ab(needle_bits) + text.find(needle) == naive_find(text, needle) && + text.rev_find(needle) == naive_rev_find(text, needle) + }, + count=200, + ) +} + +///| +test "quickcheck: fallback-forcing inputs agree with naive references" { + @quickcheck.check( + (input : (UInt, UInt, UInt, Array[Bool])) => { + let (p0, q0, flip0, mix) = input + // needle 'a'*p + 'b' + 'a'*q: anchors are 'a'/'a' (or 'a'/'b' when + // q == 0), interior mostly 'a' — the adversarial family + let p = (p0 % 14).reinterpret_as_int() + 1 + let q = (q0 % 15).reinterpret_as_int() + let needle = String::make(p, 'a') + "b" + String::make(q, 'a') + let m = needle.length() + guard m >= 3 else { return true } + // corrupted block: one interior char flipped, anchors intact, so a + // block-start candidate passes the anchor test and fails verification + let flip = 1 + + (flip0 % (m - 2).reinterpret_as_uint()).reinterpret_as_int() + let sb = StringBuilder(size_hint=m) + for i in 0.. Int? { find_by_two_anchors(self, str) } } - // TODO: When the pattern string is long (>= 256), - // consider using Two-Way algorithm to ensure linear time complexity. + // Worst-case linearity: after dense false-anchor candidates the search + // cuts over to a KMP fallback (see the two-anchor search internals), so + // the total cost stays O(self + str) even on adversarial inputs. } ///| @@ -145,8 +146,9 @@ pub fn StringView::rev_find(self : StringView, str : StringView) -> Int? { rev_find_by_two_anchors(self, str) } } - // TODO: When the pattern string is long (>= 256), - // consider using Two-Way algorithm to ensure linear time complexity. + // Worst-case linearity: after dense false-anchor candidates the search + // cuts over to a KMP fallback (see the two-anchor search internals), so + // the total cost stays O(self + str) even on adversarial inputs. } ///| @@ -420,7 +422,7 @@ pub fn StringView::strip_suffix( pub fn StringView::to_array(self : StringView) -> Array[Char] { self .iter() - .fold(init=Array::new(capacity=self.length()), (rv, c) => { + .fold(init=Array(capacity=self.length()), (rv, c) => { rv.push(c) rv }) @@ -566,6 +568,433 @@ pub fn String::contains_code_unit(self : String, code : UInt16) -> Bool { string_contains_code_unit(self, 0, self.length(), code) } +///| +const ASCII_CHAR_SET_LIMIT : UInt = 128U + +///| +const ASCII_CHAR_SET_WORD_MASK : UInt = 31U + +///| +const ASCII_CHAR_SET_WORD_SHIFT = 5 + +///| +// At most this many set members are broadcast to vectors by the SIMD scan; +// larger sets use the scalar bitmap scan. +#cfg(any(target="native", target="wasm")) +const ASCII_CHAR_SET_SIMD_MAX_CHARS = 8 + +///| +#inline +fn build_ascii_char_set(chars : StringView) -> (UInt, UInt, UInt, UInt)? { + let mut bits0 = 0U + let mut bits1 = 0U + let mut bits2 = 0U + let mut bits3 = 0U + for c in chars { + let code = c.to_uint() + guard code < ASCII_CHAR_SET_LIMIT else { return None } + let bit = 1U << (code & ASCII_CHAR_SET_WORD_MASK).reinterpret_as_int() + match code >> ASCII_CHAR_SET_WORD_SHIFT { + 0 => bits0 = bits0 | bit + 1 => bits1 = bits1 | bit + 2 => bits2 = bits2 | bit + _ => bits3 = bits3 | bit + } + } + Some((bits0, bits1, bits2, bits3)) +} + +///| +/// Tests membership in a 128-bit ASCII character set represented by four +/// scalar words, so callers do not need a temporary heap allocation. Code +/// units outside the ASCII range are never members. +/// +/// An ASCII code unit is never half of a surrogate pair, so for ASCII-only +/// sets scanning raw code units is equivalent to scanning characters. +#inline +fn ascii_char_set_contains( + bits0 : UInt, + bits1 : UInt, + bits2 : UInt, + bits3 : UInt, + code : UInt, +) -> Bool { + guard code < ASCII_CHAR_SET_LIMIT else { return false } + let bit = 1U << (code & ASCII_CHAR_SET_WORD_MASK).reinterpret_as_int() + match code >> ASCII_CHAR_SET_WORD_SHIFT { + 0 => (bits0 & bit) != 0U + 1 => (bits1 & bit) != 0U + 2 => (bits2 & bit) != 0U + _ => (bits3 & bit) != 0U + } +} + +///| +// The caller must ensure `0 <= start <= end <= str.length()`. +#cfg(not(target="js")) +#inline +fn string_contains_any_ascii_scalar( + str : String, + start : Int, + end : Int, + bits0 : UInt, + bits1 : UInt, + bits2 : UInt, + bits3 : UInt, +) -> Bool { + for i in start.. Int { + for pos = start { + if pos < end && + ascii_char_set_contains( + bits0, + bits1, + bits2, + bits3, + str.unsafe_get(pos).to_uint(), + ) { + continue pos + 1 + } else { + break pos + } + } +} + +///| +// Returns the position just past the last code unit in `start.. Int { + for pos = end { + if pos > start && + ascii_char_set_contains( + bits0, + bits1, + bits2, + bits3, + str.unsafe_get(pos - 1).to_uint(), + ) { + continue pos - 1 + } else { + break pos + } + } +} + +///| +// Broadcasts the set member at `index` (repeating the first member for unused +// slots, so the compare tree stays branchless) for the SIMD scan. +#cfg(any(target="native", target="wasm")) +#inline +fn ascii_char_set_splat(chars : StringView, count : Int, index : Int) -> V128 { + i16x8_splat(chars.unsafe_get(if index < count { index } else { 0 })) +} + +///| +// Per-lane mask of which of the eight code units in `block` are members of +// the set broadcast across `s0..s7`. +#cfg(any(target="native", target="wasm")) +#inline +fn ascii_char_set_block_mask( + block : V128, + s0 : V128, + s1 : V128, + s2 : V128, + s3 : V128, + s4 : V128, + s5 : V128, + s6 : V128, + s7 : V128, +) -> V128 { + v128_or( + v128_or( + v128_or(i16x8_eq(block, s0), i16x8_eq(block, s1)), + v128_or(i16x8_eq(block, s2), i16x8_eq(block, s3)), + ), + v128_or( + v128_or(i16x8_eq(block, s4), i16x8_eq(block, s5)), + v128_or(i16x8_eq(block, s6), i16x8_eq(block, s7)), + ), + ) +} + +///| +// On the JavaScript backend the character iterator compiles to a faster loop +// than indexed code-unit reads, and for an ASCII set the two scans agree. +#cfg(target="js") +fn string_contains_any_ascii( + str : String, + start : Int, + end : Int, + _chars : StringView, + bits0 : UInt, + bits1 : UInt, + bits2 : UInt, + bits3 : UInt, +) -> Bool { + StringView::make_view(str, start, end).contains_any_ascii_chars( + bits0, bits1, bits2, bits3, + ) +} + +///| +#cfg(target="js") +fn StringView::contains_any_ascii_chars( + self : StringView, + bits0 : UInt, + bits1 : UInt, + bits2 : UInt, + bits3 : UInt, +) -> Bool { + for c in self { + if ascii_char_set_contains(bits0, bits1, bits2, bits3, c.to_uint()) { + return true + } + } + false +} + +///| +#cfg(not(any(target="native", target="wasm", target="js"))) +fn string_contains_any_ascii( + str : String, + start : Int, + end : Int, + _chars : StringView, + bits0 : UInt, + bits1 : UInt, + bits2 : UInt, + bits3 : UInt, +) -> Bool { + string_contains_any_ascii_scalar(str, start, end, bits0, bits1, bits2, bits3) +} + +///| +// Scan eight UTF-16 code units at a time on linear-memory backends, comparing +// each block against every set member at once, then scan the remaining tail +// one code unit at a time. +#cfg(any(target="native", target="wasm")) +fn string_contains_any_ascii( + str : String, + start : Int, + end : Int, + chars : StringView, + bits0 : UInt, + bits1 : UInt, + bits2 : UInt, + bits3 : UInt, +) -> Bool { + let count = chars.length() + guard count >= 1 && count <= ASCII_CHAR_SET_SIMD_MAX_CHARS && start + 8 <= end else { + return string_contains_any_ascii_scalar( + str, start, end, bits0, bits1, bits2, bits3, + ) + } + let s0 = ascii_char_set_splat(chars, count, 0) + let s1 = ascii_char_set_splat(chars, count, 1) + let s2 = ascii_char_set_splat(chars, count, 2) + let s3 = ascii_char_set_splat(chars, count, 3) + let s4 = ascii_char_set_splat(chars, count, 4) + let s5 = ascii_char_set_splat(chars, count, 5) + let s6 = ascii_char_set_splat(chars, count, 6) + let s7 = ascii_char_set_splat(chars, count, 7) + let tail_start = for pos = start; pos + 8 <= end; { + let block = v128_load_i16x8(str, pos) + if v128_any_true( + ascii_char_set_block_mask(block, s0, s1, s2, s3, s4, s5, s6, s7), + ) { + return true + } + continue pos + 8 + } nobreak { + pos + } + string_contains_any_ascii_scalar( + str, tail_start, end, bits0, bits1, bits2, bits3, + ) +} + +///| +#cfg(not(any(target="native", target="wasm"))) +fn string_trim_start_ascii( + str : String, + start : Int, + end : Int, + _chars : StringView, + bits0 : UInt, + bits1 : UInt, + bits2 : UInt, + bits3 : UInt, +) -> Int { + string_trim_start_ascii_scalar(str, start, end, bits0, bits1, bits2, bits3) +} + +///| +// Skip eight fully-trimmable code units at a time on linear-memory backends; +// the first block containing a non-member (and the sub-8 tail) is finished by +// the scalar scan. +#cfg(any(target="native", target="wasm")) +fn string_trim_start_ascii( + str : String, + start : Int, + end : Int, + chars : StringView, + bits0 : UInt, + bits1 : UInt, + bits2 : UInt, + bits3 : UInt, +) -> Int { + let count = chars.length() + guard count >= 1 && count <= ASCII_CHAR_SET_SIMD_MAX_CHARS && start + 8 <= end else { + return string_trim_start_ascii_scalar( + str, start, end, bits0, bits1, bits2, bits3, + ) + } + let s0 = ascii_char_set_splat(chars, count, 0) + let s1 = ascii_char_set_splat(chars, count, 1) + let s2 = ascii_char_set_splat(chars, count, 2) + let s3 = ascii_char_set_splat(chars, count, 3) + let s4 = ascii_char_set_splat(chars, count, 4) + let s5 = ascii_char_set_splat(chars, count, 5) + let s6 = ascii_char_set_splat(chars, count, 6) + let s7 = ascii_char_set_splat(chars, count, 7) + let boundary = for pos = start; pos + 8 <= end; { + let block = v128_load_i16x8(str, pos) + let mask = ascii_char_set_block_mask(block, s0, s1, s2, s3, s4, s5, s6, s7) + if i16x8_bitmask(mask) != 0xFF { + break pos + } + continue pos + 8 + } nobreak { + pos + } + string_trim_start_ascii_scalar(str, boundary, end, bits0, bits1, bits2, bits3) +} + +///| +#cfg(not(any(target="native", target="wasm"))) +fn string_trim_end_ascii( + str : String, + start : Int, + end : Int, + _chars : StringView, + bits0 : UInt, + bits1 : UInt, + bits2 : UInt, + bits3 : UInt, +) -> Int { + string_trim_end_ascii_scalar(str, start, end, bits0, bits1, bits2, bits3) +} + +///| +// Mirror of `string_trim_start_ascii`, scanning blocks backward from the end. +#cfg(any(target="native", target="wasm")) +fn string_trim_end_ascii( + str : String, + start : Int, + end : Int, + chars : StringView, + bits0 : UInt, + bits1 : UInt, + bits2 : UInt, + bits3 : UInt, +) -> Int { + let count = chars.length() + guard count >= 1 && count <= ASCII_CHAR_SET_SIMD_MAX_CHARS && start + 8 <= end else { + return string_trim_end_ascii_scalar( + str, start, end, bits0, bits1, bits2, bits3, + ) + } + let s0 = ascii_char_set_splat(chars, count, 0) + let s1 = ascii_char_set_splat(chars, count, 1) + let s2 = ascii_char_set_splat(chars, count, 2) + let s3 = ascii_char_set_splat(chars, count, 3) + let s4 = ascii_char_set_splat(chars, count, 4) + let s5 = ascii_char_set_splat(chars, count, 5) + let s6 = ascii_char_set_splat(chars, count, 6) + let s7 = ascii_char_set_splat(chars, count, 7) + let boundary = for pos = end; pos - 8 >= start; { + let block = v128_load_i16x8(str, pos - 8) + let mask = ascii_char_set_block_mask(block, s0, s1, s2, s3, s4, s5, s6, s7) + if i16x8_bitmask(mask) != 0xFF { + break pos + } + continue pos - 8 + } nobreak { + pos + } + string_trim_end_ascii_scalar(str, start, boundary, bits0, bits1, bits2, bits3) +} + +///| +fn StringView::trim_start_with_chars( + self : StringView, + chars : StringView, +) -> StringView { + for x = self { + match x { + [] as v => break v + [c, .. rest] as v => + if chars.contains_char(c) { + continue rest + } else { + break v + } + } + } +} + +///| +fn StringView::trim_end_with_chars( + self : StringView, + chars : StringView, +) -> StringView { + for x = self { + match x { + [] as v => break v + [.. rest, c] as v => + if chars.contains_char(c) { + continue rest + } else { + break v + } + } + } +} + ///| /// Returns true if this string contains any character from the given set. pub fn StringView::contains_any(self : StringView, chars~ : StringView) -> Bool { @@ -573,12 +1002,26 @@ pub fn StringView::contains_any(self : StringView, chars~ : StringView) -> Bool [] => false [c] => self.contains_char(c) // specialize for single character _ => - for c in self { - if chars.contains_char(c) { - break true - } - } nobreak { - false + match build_ascii_char_set(chars) { + Some((bits0, bits1, bits2, bits3)) => + string_contains_any_ascii( + self.str(), + self.start(), + self.end(), + chars, + bits0, + bits1, + bits2, + bits3, + ) + None => + for c in self { + if chars.contains_char(c) { + break true + } + } nobreak { + false + } } } } @@ -647,6 +1090,37 @@ test "contains_any" { inspect("hello"[:].contains_any(chars="eo"), content="true") } +///| +test "contains_any and trim ASCII character sets" { + assert_true("😀a".contains_any(chars="az")) + assert_false("😀".contains_any(chars="az")) + assert_true("😀".contains_any(chars="a😀")) + let view = "x hello \ty"[1:10] + assert_true(view.trim(chars=" \t") == "hello") +} + +///| +test "build ASCII character set" { + match build_ascii_char_set("a z") { + Some((bits0, bits1, bits2, bits3)) => { + assert_true( + ascii_char_set_contains(bits0, bits1, bits2, bits3, 'a'.to_uint()), + ) + assert_true( + ascii_char_set_contains(bits0, bits1, bits2, bits3, 'z'.to_uint()), + ) + assert_true( + ascii_char_set_contains(bits0, bits1, bits2, bits3, ' '.to_uint()), + ) + assert_false( + ascii_char_set_contains(bits0, bits1, bits2, bits3, 'b'.to_uint()), + ) + } + None => assert_false(true) + } + assert_true(build_ascii_char_set("a😀") is None) +} + ///| /// Returns true if this string contains the given character. pub fn StringView::contains_char(self : StringView, c : Char) -> Bool { @@ -718,16 +1192,21 @@ pub fn StringView::trim_start( self : StringView, chars? : StringView = "\t\n\r ", ) -> StringView { - for x = self { - match x { - [] as v => break v - [c, .. rest] as v => - if chars.contains_char(c) { - continue rest - } else { - break v - } + match build_ascii_char_set(chars) { + Some((bits0, bits1, bits2, bits3)) => { + let start = string_trim_start_ascii( + self.str(), + self.start(), + self.end(), + chars, + bits0, + bits1, + bits2, + bits3, + ) + StringView::make_view(self.str(), start, self.end()) } + None => self.trim_start_with_chars(chars) } } @@ -766,16 +1245,21 @@ pub fn StringView::trim_end( self : StringView, chars? : StringView = "\t\n\r ", ) -> StringView { - for x = self { - match x { - [] as v => break v - [.. rest, c] as v => - if chars.contains_char(c) { - continue rest - } else { - break v - } + match build_ascii_char_set(chars) { + Some((bits0, bits1, bits2, bits3)) => { + let end = string_trim_end_ascii( + self.str(), + self.start(), + self.end(), + chars, + bits0, + bits1, + bits2, + bits3, + ) + StringView::make_view(self.str(), self.start(), end) } + None => self.trim_end_with_chars(chars) } } @@ -815,7 +1299,32 @@ pub fn StringView::trim( self : StringView, chars? : StringView = "\t\n\r ", ) -> StringView { - self.trim_start(chars~).trim_end(chars~) + match build_ascii_char_set(chars) { + Some((bits0, bits1, bits2, bits3)) => { + let start = string_trim_start_ascii( + self.str(), + self.start(), + self.end(), + chars, + bits0, + bits1, + bits2, + bits3, + ) + let end = string_trim_end_ascii( + self.str(), + start, + self.end(), + chars, + bits0, + bits1, + bits2, + bits3, + ) + StringView::make_view(self.str(), start, end) + } + None => self.trim_start_with_chars(chars).trim_end_with_chars(chars) + } } ///| @@ -939,8 +1448,12 @@ test "is_blank" { ///| /// Returns a new string with `padding_char`s prefixed to `self` if -/// `self.char_length() < total_width`. The number of unicode characters in -/// the returned string is `total_width` if padding is added. +/// `self.length() < total_width`. The threshold and the pad count are +/// measured in UTF-16 code units: `total_width - self.length()` copies of +/// `padding_char` are prefixed. Characters outside the BMP count as two +/// code units, so with such characters in `self` the result has fewer than +/// `total_width` characters, and with a non-BMP `padding_char` the result's +/// UTF-16 length exceeds `total_width`. pub fn StringView::pad_start( self : StringView, total_width : Int, @@ -954,8 +1467,12 @@ pub fn StringView::pad_start( ///| /// Returns a new string with `padding_char`s prefixed to `self` if -/// `self.char_length() < total_width`. The number of unicode characters in -/// the returned string is `total_width` if padding is added. +/// `self.length() < total_width`. The threshold and the pad count are +/// measured in UTF-16 code units: `total_width - self.length()` copies of +/// `padding_char` are prefixed. Characters outside the BMP count as two +/// code units, so with such characters in `self` the result has fewer than +/// `total_width` characters, and with a non-BMP `padding_char` the result's +/// UTF-16 length exceeds `total_width`. pub fn String::pad_start( self : String, total_width : Int, @@ -994,8 +1511,12 @@ test "pad_start" { ///| /// Returns a new string with `padding_char`s appended to `self` if -/// `self.length() < total_width`. The number of unicode characters in -/// the returned string is `total_width` if padding is added. +/// `self.length() < total_width`. The threshold and the pad count are +/// measured in UTF-16 code units: `total_width - self.length()` copies of +/// `padding_char` are appended. Characters outside the BMP count as two +/// code units, so with such characters in `self` the result has fewer than +/// `total_width` characters, and with a non-BMP `padding_char` the result's +/// UTF-16 length exceeds `total_width`. pub fn StringView::pad_end( self : StringView, total_width : Int, @@ -1009,8 +1530,12 @@ pub fn StringView::pad_end( ///| /// Returns a new string with `padding_char`s appended to `self` if -/// `self.length() < total_width`. The number of unicode characters in -/// the returned string is `total_width` if padding is added. +/// `self.length() < total_width`. The threshold and the pad count are +/// measured in UTF-16 code units: `total_width - self.length()` copies of +/// `padding_char` are appended. Characters outside the BMP count as two +/// code units, so with such characters in `self` the result has fewer than +/// `total_width` characters, and with a non-BMP `padding_char` the result's +/// UTF-16 length exceeds `total_width`. pub fn String::pad_end( self : String, total_width : Int, @@ -1875,7 +2400,9 @@ fn ascii_lowercase_copy(view : StringView, first_uppercase : Int) -> String { } ///| -/// Converts this string to lowercase. +/// Converts the ASCII uppercase letters (`'A'` to `'Z'`) in this string to +/// lowercase. All other characters, including non-ASCII letters, are left +/// unchanged. #cfg(not(target="js")) pub fn StringView::to_lower(self : StringView) -> StringView { // TODO: deal with non-ascii characters @@ -1889,7 +2416,9 @@ pub fn StringView::to_lower(self : StringView) -> StringView { } ///| -/// Converts this string to lowercase. +/// Converts the ASCII uppercase letters (`'A'` to `'Z'`) in this string to +/// lowercase. All other characters, including non-ASCII letters, are left +/// unchanged. #cfg(not(target="js")) pub fn String::to_lower(self : String) -> String { // TODO: deal with non-ascii characters @@ -1923,7 +2452,9 @@ pub fn StringView::to_lower(self : StringView) -> StringView { } ///| -/// Converts this string to lowercase. +/// Converts the ASCII uppercase letters (`'A'` to `'Z'`) in this string to +/// lowercase. All other characters, including non-ASCII letters, are left +/// unchanged. #cfg(target="js") pub fn String::to_lower(self : String) -> String { // TODO: deal with non-ascii characters @@ -1987,7 +2518,9 @@ test "View::to_lower" { } ///| -/// Converts this string to uppercase. +/// Converts the ASCII lowercase letters (`'a'` to `'z'`) in this string to +/// uppercase. All other characters, including non-ASCII letters, are left +/// unchanged. pub fn StringView::to_upper(self : StringView) -> StringView { // TODO: deal with non-ascii characters guard self.find_by(c => c.is_ascii_lowercase()) is Some(idx) else { @@ -2007,7 +2540,9 @@ pub fn StringView::to_upper(self : StringView) -> StringView { } ///| -/// Converts this string to uppercase. +/// Converts the ASCII lowercase letters (`'a'` to `'z'`) in this string to +/// uppercase. All other characters, including non-ASCII letters, are left +/// unchanged. pub fn String::to_upper(self : String) -> String { // TODO: deal with non-ascii characters guard self.find_by(c => c.is_ascii_lowercase()) is Some(idx) else { @@ -2165,6 +2700,7 @@ test "rev_fold with raise" { ///| /// Returns the UTF-16 code unit at the given index. Returns `None` if the index /// is out of bounds. +#intrinsic("%string.get_opt") pub fn String::get(self : String, idx : Int) -> UInt16? { guard idx >= 0 && idx < self.length() else { return None } Some(self.unsafe_get(idx)) @@ -2173,6 +2709,7 @@ pub fn String::get(self : String, idx : Int) -> UInt16? { ///| /// Returns the UTF-16 code unit at the given index. Returns `None` if the index /// is out of bounds. +#intrinsic("%stringview.get_opt") pub fn StringView::get(self : StringView, idx : Int) -> UInt16? { guard idx >= 0 && idx < self.length() else { return None } Some(self.unsafe_get(idx)) diff --git a/builtin/string_test.mbt b/builtin/string_test.mbt index 6d5690722d..2fc94eea1e 100644 --- a/builtin/string_test.mbt +++ b/builtin/string_test.mbt @@ -162,7 +162,7 @@ test "String default and to_array" { ///| test "String from_iter" { - let iter = ['a', 'b', 'c'].iter() + let iter = [|'a', 'b', 'c'|] inspect(String::from_iter(iter), content="abc") } diff --git a/builtin/stringbuilder_buffer.mbt b/builtin/stringbuilder_buffer.mbt index 9d79ad1d62..be3dfe68a1 100644 --- a/builtin/stringbuilder_buffer.mbt +++ b/builtin/stringbuilder_buffer.mbt @@ -29,11 +29,11 @@ struct StringBuilder { /// /// Returns a new `StringBuilder` instance with the specified initial capacity. /// -#alias(new) +#alias(new, deprecated="Use `StringBuilder()` instead") pub fn StringBuilder::StringBuilder(size_hint? : Int = 0) -> StringBuilder { let initial = if size_hint < 1 { 1 } else { (size_hint + 1) / 2 } let data : FixedArray[UInt16] = FixedArray::make(initial, 0) - { data, len: 0 } + { data, len: 0, } } ///| @@ -139,14 +139,12 @@ pub impl Logger for StringBuilder with fn write_char(self, ch) { } ///| -/// Writes a part of the given string to the StringBuilder. -/// +/// Writes a string view to the StringBuilder. +/// /// Parameters: /// /// * `self` : The StringBuilder to write to. -/// * `str` : The given string. -/// * `start` : The start index of the substring to write. -/// * `len` : The length of the substring to write. +/// * `str` : The view of the string to write. /// /// Example: /// diff --git a/builtin/stringbuilder_concat.mbt b/builtin/stringbuilder_concat.mbt index f8e1d752bb..d8ea0df2a7 100644 --- a/builtin/stringbuilder_concat.mbt +++ b/builtin/stringbuilder_concat.mbt @@ -28,10 +28,10 @@ struct StringBuilder { /// /// Returns a new `StringBuilder` instance with the specified initial capacity. /// -#alias(new) +#alias(new, deprecated="Use `StringBuilder()` instead") pub fn StringBuilder::StringBuilder(size_hint? : Int = 0) -> StringBuilder { ignore(size_hint) - { val: "" } + { val: "", } } ///| @@ -53,14 +53,12 @@ pub impl Logger for StringBuilder with fn write_char(self, ch) { } ///| -/// Writes a part of the given string to the StringBuilder. -/// +/// Writes a string view to the StringBuilder. +/// /// Parameters: /// /// * `self` : The StringBuilder to write to. -/// * `str` : The given string. -/// * `start` : The start index of the substring to write. -/// * `len` : The length of the substring to write. +/// * `str` : The view of the string to write. /// /// Example: /// diff --git a/builtin/stringview.mbt b/builtin/stringview.mbt index 815dc12018..739f951185 100644 --- a/builtin/stringview.mbt +++ b/builtin/stringview.mbt @@ -151,7 +151,7 @@ pub fn StringView::code_units(self : StringView) -> ArrayView[UInt16] { /// index is within bounds. /// /// This method has O(1) complexity. -/// #Example +/// # Example /// /// ```mbt check /// test { diff --git a/builtin/stringview_test.mbt b/builtin/stringview_test.mbt index 42861e21c7..b6ab5a9167 100644 --- a/builtin/stringview_test.mbt +++ b/builtin/stringview_test.mbt @@ -109,12 +109,12 @@ test "StringView core operations" { let buf = StringBuilder() let it = view.iter2() while it.next() is Some((i, ch)) { - buf.write_string("\{i}:\{ch};") + buf <+ "\{i}:\{ch};" } inspect(buf.to_string(), content="0:b;1:😀;2:c;") let from_arr = StringView::from_array(['x', 'y']) inspect(from_arr.to_owned(), content="xy") - let from_iter = StringView::from_iter(['m', 'n'].iter()) + let from_iter = StringView::from_iter([|'m', 'n'|]) inspect(from_iter.to_owned(), content="mn") let make_view = StringView::make(3, 'a') inspect(make_view.to_owned(), content="aaa") diff --git a/builtin/to_string_test.mbt b/builtin/to_string_test.mbt index 1aa8de46a2..815ec49321 100644 --- a/builtin/to_string_test.mbt +++ b/builtin/to_string_test.mbt @@ -110,7 +110,7 @@ test "panic UInt::to_string invalid radix" { ///| test "to_string runtime zero paths" { - let arr = Array::new() + let arr = Array() arr.push(1) ignore(arr.pop()) let zero = arr.length() @@ -122,7 +122,7 @@ test "to_string runtime zero paths" { ///| test "panic UInt64::to_string invalid radix" { - let arr = Array::new() + let arr = Array() arr.push(1) ignore(arr.pop()) let zero = arr.length().to_uint64() diff --git a/builtin/traits_test.mbt b/builtin/traits_test.mbt index 7432908c97..5b51bd80fc 100644 --- a/builtin/traits_test.mbt +++ b/builtin/traits_test.mbt @@ -29,7 +29,7 @@ priv struct Data { ///| test "hash_data" { - let data = Data::{ x: [1, 2, 3], y: [4, 5, 6] } + let data = Data::{ x: [1, 2, 3], y: [4, 5, 6], } let map = Map([]) map[data] = 1 debug_inspect( @@ -70,21 +70,21 @@ impl Logger for TestLogger with fn write_view(self, value) { test "Eq helpers" { inspect(Eq::not_equal(1, 2), content="true") inspect(Eq::equal(3, 3), content="true") - let a : Pair = { left: 1, right: 2 } - let b : Pair = { left: 1, right: 3 } + let a : Pair = { left: 1, right: 2, } + let b : Pair = { left: 1, right: 3, } inspect(Eq::not_equal(a, b), content="true") } ///| test "Eq::equal on custom impl" { - let a = Wrapper::{ value: 1 } - let b = Wrapper::{ value: 2 } + let a = Wrapper::{ value: 1, } + let b = Wrapper::{ value: 2, } inspect(Eq::equal(a, b), content="false") } ///| test "Logger defaults" { - let logger = TestLogger::{ output: "" } + let logger = TestLogger::{ output: "", } Logger::write_string(logger, "hi") Logger::write_char(logger, '!') inspect(logger.output, content="hi!") diff --git a/builtin/tuple_show_test.mbt b/builtin/tuple_show_test.mbt index d7553fadfa..c9a1eee782 100644 --- a/builtin/tuple_show_test.mbt +++ b/builtin/tuple_show_test.mbt @@ -13,118 +13,133 @@ // limitations under the License. ///| -#warnings("-deprecated") -test "2-tuple to_json" { +test "2-tuple Debug" { let pair = (42, "hello") - @json.json_inspect(pair.to_string(), content="(42, hello)") + debug_inspect(pair, content="(42, \"hello\")") } ///| -#warnings("-deprecated") -test "3-tuple to_json" { +test "3-tuple Debug" { let triple = (42, "hello", true) - @json.json_inspect(triple.to_string(), content="(42, hello, true)") + debug_inspect(triple, content="(42, \"hello\", true)") } ///| -#warnings("-deprecated") -test "4-tuple to_json" { +test "4-tuple Debug" { let tuple = (42, "hello", true, 3.14) - @json.json_inspect(tuple.to_string(), content="(42, hello, true, 3.14)") + debug_inspect(tuple, content="(42, \"hello\", true, 3.14)") } ///| -#warnings("-deprecated") -test "5-tuple to_json" { +test "5-tuple Debug" { let tuple = (42, "hello", true, 3.14, 'a') - @json.json_inspect(tuple.to_string(), content="(42, hello, true, 3.14, a)") + debug_inspect(tuple, content="(42, \"hello\", true, 3.14, 'a')") } ///| -#warnings("-deprecated") -test "6-tuple to_json" { +test "6-tuple Debug" { let tuple = (42, "hello", true, 3.14, 'a', 1) - @json.json_inspect(tuple.to_string(), content="(42, hello, true, 3.14, a, 1)") + debug_inspect(tuple, content="(42, \"hello\", true, 3.14, 'a', 1)") } ///| -#warnings("-deprecated") -test "7-tuple to_json" { +test "7-tuple Debug" { let tuple = (42, "hello", true, 3.14, 'a', 1, "world") - @json.json_inspect( - tuple.to_string(), - content="(42, hello, true, 3.14, a, 1, world)", - ) + debug_inspect(tuple, content="(42, \"hello\", true, 3.14, 'a', 1, \"world\")") } ///| -#warnings("-deprecated") -test "8-tuple to_json" { +test "8-tuple Debug" { let tuple = (42, "hello", true, 3.14, 'a', 1, "world", false) - @json.json_inspect( - tuple.to_string(), - content="(42, hello, true, 3.14, a, 1, world, false)", + debug_inspect( + tuple, + content="(42, \"hello\", true, 3.14, 'a', 1, \"world\", false)", ) } ///| -#warnings("-deprecated") -test "9-tuple to_json" { +test "9-tuple Debug" { let tuple = (42, "hello", true, 3.14, 'a', 1, "world", false, 2.71) - @json.json_inspect( - tuple.to_string(), - content="(42, hello, true, 3.14, a, 1, world, false, 2.71)", + debug_inspect( + tuple, + content="(42, \"hello\", true, 3.14, 'a', 1, \"world\", false, 2.71)", ) } ///| -#warnings("-deprecated") -test "10-tuple to_json" { +test "10-tuple Debug" { let tuple = (42, "hello", true, 3.14, 'a', 1, "world", false, 2.71, 'b') - @json.json_inspect( - tuple.to_string(), - content="(42, hello, true, 3.14, a, 1, world, false, 2.71, b)", + debug_inspect( + tuple, + content="(42, \"hello\", true, 3.14, 'a', 1, \"world\", false, 2.71, 'b')", ) } ///| -#warnings("-deprecated") -test "11-tuple to_json" { +test "11-tuple Debug" { let tuple = (42, "hello", true, 3.14, 'a', 1, "world", false, 2.71, 'b', 43UL) - @json.json_inspect( - tuple.to_string(), - content="(42, hello, true, 3.14, a, 1, world, false, 2.71, b, 43)", + debug_inspect( + tuple, + content="(42, \"hello\", true, 3.14, 'a', 1, \"world\", false, 2.71, 'b', 43)", ) } ///| -#warnings("-deprecated") -test "12-tuple to_json" { +test "12-tuple Debug" { let tuple = ( 42, "hello", true, 3.14, 'a', 1, "world", false, 2.71, 'b', 43UL, 0x12345678U, ) - @json.json_inspect( - tuple.to_string(), - content="(42, hello, true, 3.14, a, 1, world, false, 2.71, b, 43, 305419896)", + debug_inspect( + tuple, + content=( + #|( + #| 42, + #| "hello", + #| true, + #| 3.14, + #| 'a', + #| 1, + #| "world", + #| false, + #| 2.71, + #| 'b', + #| 43, + #| 305419896, + #|) + ), ) } ///| -#warnings("-deprecated") -test "13-tuple to_json" { +test "13-tuple Debug" { let tuple = ( 42, "hello", true, 3.14, 'a', 1, "world", false, 2.71, 'b', 43UL, 0x12345678U, 0x87654321L, ) - @json.json_inspect( - tuple.to_string(), - content="(42, hello, true, 3.14, a, 1, world, false, 2.71, b, 43, 305419896, 2271560481)", + debug_inspect( + tuple, + content=( + #|( + #| 42, + #| "hello", + #| true, + #| 3.14, + #| 'a', + #| 1, + #| "world", + #| false, + #| 2.71, + #| 'b', + #| 43, + #| 305419896, + #| 2271560481, + #|) + ), ) } ///| -#warnings("-deprecated") -test "14-tuple to_json" { +test "14-tuple Debug" { let tuple = ( 42, "hello", @@ -141,15 +156,31 @@ test "14-tuple to_json" { 0x87654321L, (0xabcdef, 1234567890UL), ) - @json.json_inspect( - tuple.to_string(), - content="(42, hello, true, 3.14, a, 1, world, false, 2.71, b, 43, 305419896, 2271560481, (11259375, 1234567890))", + debug_inspect( + tuple, + content=( + #|( + #| 42, + #| "hello", + #| true, + #| 3.14, + #| 'a', + #| 1, + #| "world", + #| false, + #| 2.71, + #| 'b', + #| 43, + #| 305419896, + #| 2271560481, + #| (11259375, 1234567890), + #|) + ), ) } ///| -#warnings("-deprecated") -test "15-tuple to_json" { +test "15-tuple Debug" { let tuple = ( 42, "hello", @@ -167,15 +198,32 @@ test "15-tuple to_json" { (0xabcdef, 1234567890UL), (0x12345678UL, 0x87654321UL, 0xabcdefUL), ) - @json.json_inspect( - tuple.to_string(), - content="(42, hello, true, 3.14, a, 1, world, false, 2.71, b, 43, 305419896, 2271560481, (11259375, 1234567890), (305419896, 2271560481, 11259375))", + debug_inspect( + tuple, + content=( + #|( + #| 42, + #| "hello", + #| true, + #| 3.14, + #| 'a', + #| 1, + #| "world", + #| false, + #| 2.71, + #| 'b', + #| 43, + #| 305419896, + #| 2271560481, + #| (11259375, 1234567890), + #| (305419896, 2271560481, 11259375), + #|) + ), ) } ///| -#warnings("-deprecated") -test "16-tuple to_json" { +test "16-tuple Debug" { let tuple = ( 42, "hello", @@ -194,8 +242,27 @@ test "16-tuple to_json" { (0x12345678UL, 0x87654321UL, 0xabcdefUL), ("wow", [0x87654321UL, 1234567890UL]), ) - @json.json_inspect( - tuple.to_string(), - content="(42, hello, true, 3.14, a, 1, world, false, 2.71, b, 43, 305419896, 2271560481, (11259375, 1234567890), (305419896, 2271560481, 11259375), (wow, [2271560481, 1234567890]))", + debug_inspect( + tuple, + content=( + #|( + #| 42, + #| "hello", + #| true, + #| 3.14, + #| 'a', + #| 1, + #| "world", + #| false, + #| 2.71, + #| 'b', + #| 43, + #| 305419896, + #| 2271560481, + #| (11259375, 1234567890), + #| (305419896, 2271560481, 11259375), + #| ("wow", [2271560481, 1234567890]), + #|) + ), ) } diff --git a/builtin/uint.mbt b/builtin/uint.mbt index 662279c136..3c9bc2c4d6 100644 --- a/builtin/uint.mbt +++ b/builtin/uint.mbt @@ -26,7 +26,24 @@ pub fn UInt::UInt(self : UInt) -> UInt = "%identity" ///| -/// Returns the minimum of two unsigned integers. +/// Returns the smaller of two unsigned integers. +/// +/// Parameters: +/// +/// * `self` : The first integer to compare. +/// * `other` : The second integer to compare. +/// +/// Returns `self` if it is not greater than `other`, `other` otherwise. +/// +/// Example: +/// +/// ```mbt check +/// test { +/// inspect(UInt(1).min(UInt(2)), content="1") +/// inspect(UInt(2).min(UInt(1)), content="1") +/// inspect(UInt(0).min(UInt(0)), content="0") +/// } +/// ``` pub fn UInt::min(self : UInt, other : UInt) -> UInt { if self < other { self @@ -36,7 +53,24 @@ pub fn UInt::min(self : UInt, other : UInt) -> UInt { } ///| -/// Returns the maximum of two unsigned integers. +/// Returns the larger of two unsigned integers. +/// +/// Parameters: +/// +/// * `self` : The first integer to compare. +/// * `other` : The second integer to compare. +/// +/// Returns `self` if it is not less than `other`, `other` otherwise. +/// +/// Example: +/// +/// ```mbt check +/// test { +/// inspect(UInt(1).max(UInt(2)), content="2") +/// inspect(UInt(2).max(UInt(1)), content="2") +/// inspect(UInt(0).max(UInt(0)), content="0") +/// } +/// ``` pub fn UInt::max(self : UInt, other : UInt) -> UInt { if self > other { self @@ -46,7 +80,26 @@ pub fn UInt::max(self : UInt, other : UInt) -> UInt { } ///| -/// Clamps the value `self` between `min` and `max`. +/// Clamps an unsigned integer into the inclusive range [`min`, `max`]. +/// +/// Parameters: +/// +/// * `self` : The value to clamp. +/// * `min` : The lower bound of the range. +/// * `max` : The upper bound of the range. +/// +/// Returns `min` if `self` is less than `min`, `max` if `self` is greater +/// than `max`, and `self` otherwise. Aborts if `min` is greater than `max`. +/// +/// Example: +/// +/// ```mbt check +/// test { +/// inspect(UInt(5).clamp(min=UInt(0), max=UInt(10)), content="5") +/// inspect(UInt(0).clamp(min=UInt(2), max=UInt(10)), content="2") +/// inspect(UInt(15).clamp(min=UInt(0), max=UInt(10)), content="10") +/// } +/// ``` pub fn UInt::clamp(self : UInt, min~ : UInt, max~ : UInt) -> UInt { guard! min <= max if self < min { diff --git a/builtin/uint64.mbt b/builtin/uint64.mbt index 4ee01dbfb0..f42fabd63f 100644 --- a/builtin/uint64.mbt +++ b/builtin/uint64.mbt @@ -36,7 +36,29 @@ test { } ///| -/// Converts the UInt64 to a Bytes in big-endian byte order. +/// Converts the `UInt64` to a `Bytes` of 8 bytes in big-endian byte order +/// (most significant byte first). +/// +/// Parameters: +/// +/// * `self` : The 64-bit unsigned integer to convert. +/// +/// Returns a `Bytes` of length 8 whose first element is the most significant +/// byte of `self` and whose last element is the least significant byte. +/// +/// Example: +/// +/// ```mbt check +/// test { +/// // 0x41..0x48 are the ASCII codes for 'A'..'H' +/// inspect( +/// 0x4142_4344_4546_4748UL.to_be_bytes(), +/// content=( +/// #|b"ABCDEFGH" +/// ), +/// ) +/// } +/// ``` pub fn UInt64::to_be_bytes(self : UInt64) -> Bytes { [ (self >> 56).to_byte(), @@ -51,7 +73,29 @@ pub fn UInt64::to_be_bytes(self : UInt64) -> Bytes { } ///| -/// Converts the UInt64 to a Bytes in little-endian byte order. +/// Converts the `UInt64` to a `Bytes` of 8 bytes in little-endian byte order +/// (least significant byte first). +/// +/// Parameters: +/// +/// * `self` : The 64-bit unsigned integer to convert. +/// +/// Returns a `Bytes` of length 8 whose first element is the least significant +/// byte of `self` and whose last element is the most significant byte. +/// +/// Example: +/// +/// ```mbt check +/// test { +/// // The same value as `to_be_bytes`, with the byte order reversed +/// inspect( +/// 0x4142_4344_4546_4748UL.to_le_bytes(), +/// content=( +/// #|b"HGFEDCBA" +/// ), +/// ) +/// } +/// ``` pub fn UInt64::to_le_bytes(self : UInt64) -> Bytes { [ self.to_byte(), diff --git a/builtin/uninitialized_array.mbt b/builtin/uninitialized_array.mbt index ec2329680b..2397ca0cd1 100644 --- a/builtin/uninitialized_array.mbt +++ b/builtin/uninitialized_array.mbt @@ -96,7 +96,7 @@ pub fn[T] UninitializedArray::sub( Some(end) | (None with end = len) => end } guard start >= 0 && start <= end && end <= len else { - abort("View start index out of bounds") + abort("View index out of bounds") } ArrayView::make(self, start, end - start) } diff --git a/bytes/README.mbt.md b/bytes/README.mbt.md index 0d1d8c7fea..fc5157d57a 100644 --- a/bytes/README.mbt.md +++ b/bytes/README.mbt.md @@ -1,6 +1,6 @@ # `bytes` -This package provides utilities for working with sequences of bytes, offering both mutable (`Bytes`) and immutable (`View`) representations. +This package provides utilities for working with sequences of bytes, offering both an owned representation (`Bytes`) and a slice representation (`BytesView`); both are immutable. ## Creating Bytes diff --git a/bytes/bytes_test.mbt b/bytes/bytes_test.mbt index 49d524da8d..38a4c4b1d7 100644 --- a/bytes/bytes_test.mbt +++ b/bytes/bytes_test.mbt @@ -131,7 +131,7 @@ test "to_array" { ///| test "from_iter multiple elements" { debug_inspect( - Bytes::from_iter([b'\x00', b'\x01', b'\x02'].iter()).to_array(), + Bytes::from_iter([|b'\x00', b'\x01', b'\x02'|]).to_array(), content=( #|[0x00, 0x01, 0x02] ), @@ -141,7 +141,7 @@ test "from_iter multiple elements" { ///| test "from_iter single element" { debug_inspect( - Bytes::from_iter([b'\x00'].iter()).to_array(), + Bytes::from_iter([|b'\x00'|]).to_array(), content=( #|[0x00] ), @@ -150,7 +150,7 @@ test "from_iter single element" { ///| test "from_iter empty iterator" { - debug_inspect(Bytes::from_iter(Iter::empty()).to_array(), content="[]") + debug_inspect(Bytes::from_iter([||]).to_array(), content="[]") } ///| @@ -230,7 +230,7 @@ test "Bytes::of with different byte values" { ///| test "Bytes::from_iter with multiple elements" { - let iter = [b'a', b'b', b'c'].iter() + let iter = [|b'a', b'b', b'c'|] let bytes = Bytes::from_iter(iter) inspect( bytes, diff --git a/bytes/find_test.mbt b/bytes/find_test.mbt index bb4bbe3229..a023259449 100644 --- a/bytes/find_test.mbt +++ b/bytes/find_test.mbt @@ -124,8 +124,5 @@ test "BytesView rev_find long pattern offset view" { ///| test "Bytes::from_iter" { - inspect( - Bytes::from_iter(([97, 98, 99] : Array[Byte]).iter()), - content="b\"abc\"", - ) + inspect(Bytes::from_iter([|97, 98, 99|]), content="b\"abc\"") } diff --git a/char/char_test.mbt b/char/char_test.mbt index 1e18aa0a1e..f95bc1cd2a 100644 --- a/char/char_test.mbt +++ b/char/char_test.mbt @@ -44,8 +44,8 @@ priv struct TestHash { ///| test "Char hash function with struct" { let m = Map([]) - m[{ x: 'a' }] = '3' - m[{ x: 'b' }] = '3' + m[{ x: 'a', }] = '3' + m[{ x: 'b', }] = '3' debug_inspect( m, content=( @@ -526,10 +526,9 @@ test "Case conversion with non-letters" { } ///| -#warnings("-deprecated") -test "length is an alias of utf16_len" { - inspect('A'.length(), content="1") - inspect('🌟'.length(), content="2") - assert_eq('\u{FFFF}'.length(), '\u{FFFF}'.utf16_len()) - assert_eq('\u{10000}'.length(), '\u{10000}'.utf16_len()) +test "utf16_len counts UTF-16 code units" { + inspect('A'.utf16_len(), content="1") + inspect('🌟'.utf16_len(), content="2") + assert_eq('\u{FFFF}'.utf16_len(), 1) + assert_eq('\u{10000}'.utf16_len(), 2) } diff --git a/char/predicate_reference_test.mbt b/char/predicate_reference_test.mbt new file mode 100644 index 0000000000..5036c47742 --- /dev/null +++ b/char/predicate_reference_test.mbt @@ -0,0 +1,204 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Exhaustive equivalence check for the restructured char predicates: the +// reference functions below are the previous single flat patterns, kept +// verbatim, and every Unicode scalar value is compared against them. Any +// off-by-one in the restructured fast paths (a shifted range bound, a +// dropped table entry) fails here with the exact code point. + +///| +fn reference_is_ascii_whitespace(c : Char) -> Bool { + c is ('\u{20}' | '\u{09}' | '\u{0A}' | '\u{0B}' | '\u{0C}' | '\u{0D}') +} + +///| +fn reference_is_whitespace(c : Char) -> Bool { + c + is ('\u0009'..='\u000D' + | '\u0020' + | '\u0085' + | '\u00A0' + | '\u1680' + | '\u2000'..='\u200A' + | '\u2028' + | '\u2029' + | '\u202F' + | '\u205F' + | '\u3000') +} + +///| +fn reference_is_numeric(c : Char) -> Bool { + c + is ('\u0030'..='\u0039' + | '\u00B2' + | '\u00B3' + | '\u00B9' + | '\u00BC' + | '\u00BD' + | '\u00BE' + | '\u0660'..='\u0669' + | '\u06F0'..='\u06F9' + | '\u07C0'..='\u07F9' + | '\u0966'..='\u096F' + | '\u09E6'..='\u09EF' + | '\u09F4'..='\u09F9' + | '\u0A66'..='\u0A6F' + | '\u0AE6'..='\u0AEF' + | '\u0B66'..='\u0B6F' + | '\u0B72'..='\u0B77' + | '\u0BE6'..='\u0BEF' + | '\u0BF0'..='\u0BF2' + | '\u0C66'..='\u0C6F' + | '\u0C78'..='\u0C7E' + | '\u0CE6'..='\u0CEF' + | '\u0D58'..='\u0D5E' + | '\u0D66'..='\u0D6F' + | '\u0D70'..='\u0D78' + | '\u0DE6'..='\u0DEF' + | '\u0E50'..='\u0E59' + | '\u0ED0'..='\u0ED9' + | '\u0F20'..='\u0F33' + | '\u1040'..='\u1049' + | '\u1090'..='\u1099' + | '\u1369'..='\u137C' + | '\u16EE'..='\u16F0' + | '\u17E0'..='\u17E9' + | '\u17F0'..='\u17F9' + | '\u1810'..='\u1819' + | '\u1946'..='\u194F' + | '\u19D0'..='\u19DA' + | '\u1A80'..='\u1A89' + | '\u1A90'..='\u1A99' + | '\u1B50'..='\u1B59' + | '\u1BB0'..='\u1BB9' + | '\u1C40'..='\u1C49' + | '\u1C50'..='\u1C59' + | '\u2070' + | '\u2074'..='\u2079' + | '\u2080'..='\u2089' + | '\u2150'..='\u2189' + | '\u2460'..='\u249B' + | '\u24EA'..='\u24FF' + | '\u2776'..='\u2793' + | '\u2CFD' + | '\u3007' + | '\u3021'..='\u3029' + | '\u3038'..='\u303A' + | '\u3192'..='\u3195' + | '\u3220'..='\u3229' + | '\u3248'..='\u324F' + | '\u3251'..='\u325F' + | '\u3280'..='\u3289' + | '\u32B1'..='\u32BF' + | '\uA620'..='\uA629' + | '\uA6E6'..='\uA6EF' + | '\uA830'..='\uA835' + | '\uA8D0'..='\uA8D9' + | '\uA900'..='\uA909' + | '\uA9D0'..='\uA9D9' + | '\uA9F0'..='\uA9F9' + | '\uAA50'..='\uAA59' + | '\uABF0'..='\uABF9' + | '\uFF10'..='\uFF19' + | '\u{10107}'..='\u{10133}' + | '\u{10140}'..='\u{10178}' + | '\u{1018A}'..='\u{1018B}' + | '\u{102E1}'..='\u{102FB}' + | '\u{10320}'..='\u{10323}' + | '\u{10341}' + | '\u{1034A}' + | '\u{103D1}'..='\u{103D5}' + | '\u{104A0}'..='\u{104A9}' + | '\u{10858}'..='\u{1085F}' + | '\u{10879}'..='\u{1087F}' + | '\u{108A7}'..='\u{108AF}' + | '\u{108FB}'..='\u{108FF}' + | '\u{10916}'..='\u{1091B}' + | '\u{109BC}'..='\u{109BD}' + | '\u{109C0}'..='\u{109CF}' + | '\u{10A40}'..='\u{10A48}' + | '\u{10A7D}'..='\u{10A7E}' + | '\u{10A9D}'..='\u{10A9F}' + | '\u{10AEB}'..='\u{10AEF}' + | '\u{10B58}'..='\u{10B5F}' + | '\u{10B78}'..='\u{10B7F}' + | '\u{10BA9}'..='\u{10BAF}' + | '\u{10CFA}'..='\u{10CFF}' + | '\u{10D30}'..='\u{10D39}' + | '\u{10D40}'..='\u{10D49}' + | '\u{10E60}'..='\u{10E7E}' + | '\u{10F1D}'..='\u{10F26}' + | '\u{10F51}'..='\u{10F54}' + | '\u{10FC5}'..='\u{10FCB}' + | '\u{11052}'..='\u{1106F}' + | '\u{110F0}'..='\u{110F9}' + | '\u{11136}'..='\u{1113F}' + | '\u{111D0}'..='\u{111D9}' + | '\u{111E1}'..='\u{111F4}' + | '\u{112F0}'..='\u{112F9}' + | '\u{11450}'..='\u{11459}' + | '\u{114D0}'..='\u{114D9}' + | '\u{11650}'..='\u{11659}' + | '\u{116C0}'..='\u{116C9}' + | '\u{116D0}'..='\u{116E3}' + | '\u{11730}'..='\u{1173B}' + | '\u{118E0}'..='\u{118F2}' + | '\u{11950}'..='\u{11959}' + | '\u{11BF0}'..='\u{11BF9}' + | '\u{11C50}'..='\u{11C6C}' + | '\u{11D50}'..='\u{11D59}' + | '\u{11DA0}'..='\u{11DA9}' + | '\u{11F50}'..='\u{11F59}' + | '\u{11FC0}'..='\u{11FD4}' + | '\u{12400}'..='\u{1246E}' + | '\u{16130}'..='\u{16139}' + | '\u{16A60}'..='\u{16A69}' + | '\u{16AC0}'..='\u{16AC9}' + | '\u{16B50}'..='\u{16B59}' + | '\u{16B5B}'..='\u{16B61}' + | '\u{16D70}'..='\u{16D79}' + | '\u{16D80}'..='\u{16E96}' + | '\u{1CCF0}'..='\u{1CCF9}' + | '\u{1D2C0}'..='\u{1D2F3}' + | '\u{1D360}'..='\u{1D378}' + | '\u{1D7CE}'..='\u{1D7FF}' + | '\u{1E140}'..='\u{1E149}' + | '\u{1E2F0}'..='\u{1E2F9}' + | '\u{1E4F0}'..='\u{1E4F9}' + | '\u{1E5F1}'..='\u{1E5FA}' + | '\u{1E8C7}'..='\u{1E8CF}' + | '\u{1E950}'..='\u{1E959}' + | '\u{1EC71}'..='\u{1ECB4}' + | '\u{1ED01}'..='\u{1ED3D}' + | '\u{1F100}'..='\u{1F10C}' + | '\u{1FBF0}'..='\u{1FBF9}') +} + +///| +test "restructured char predicates agree with the flat reference patterns for every scalar" { + for code in 0..<=0x10FFFF { + guard code.to_char() is Some(c) else { continue } + if c.is_ascii_whitespace() != reference_is_ascii_whitespace(c) { + fail("is_ascii_whitespace mismatch at code point \{code}") + } + if c.is_whitespace() != reference_is_whitespace(c) { + fail("is_whitespace mismatch at code point \{code}") + } + if c.is_numeric() != reference_is_numeric(c) { + fail("is_numeric mismatch at code point \{code}") + } + } +} diff --git a/cmp/README.mbt.md b/cmp/README.mbt.md index 427486cf4b..0c0111ff98 100644 --- a/cmp/README.mbt.md +++ b/cmp/README.mbt.md @@ -61,7 +61,7 @@ test "reverse with arrays" { ## Comparison by Key -With `@cmp.maximum_by_key()` and `@cmp.minimum_by_key()`, it is possible to compare values based on arbitrary keys derived from the them. This is particularly useful when you need to compare complex objects based on some specific aspect or field. +With `@cmp.maximum_by_key()` and `@cmp.minimum_by_key()`, it is possible to compare values based on arbitrary keys derived from them. This is particularly useful when you need to compare complex objects based on some specific aspect or field. ```mbt check ///| @@ -79,8 +79,8 @@ test "cmp_by_key" { inspect(longer, content="hello") // Compare structs by a specific field - let alice = { name: "Alice", age: 25 } - let bob = { name: "Bob", age: 30 } + let alice = { name: "Alice", age: 25, } + let bob = { name: "Bob", age: 30, } let younger = @cmp.minimum_by_key(alice, bob, p => p.age) debug_inspect( younger, diff --git a/cmp/cmp.mbt b/cmp/cmp.mbt index c3504352ae..1c451053aa 100644 --- a/cmp/cmp.mbt +++ b/cmp/cmp.mbt @@ -171,8 +171,8 @@ pub fn[T : Compare] minmax(x : T, y : T) -> (T, T) { } ///| -/// Returns the minimum and maximum of two values based on a comparison -/// function. +/// Returns the minimum and maximum of two values based on a key extracted +/// from each of them. /// /// Parameters: /// diff --git a/coverage/coverage.mbt b/coverage/coverage.mbt index 9082aa9a3e..f1ecdd6cbc 100644 --- a/coverage/coverage.mbt +++ b/coverage/coverage.mbt @@ -61,7 +61,7 @@ priv struct CounterList { ///| /// The global list of counters currently tracking. -let counters : CounterList = { val: MNil } +let counters : CounterList = { val: MNil, } ///| /// Add the given counter along its ID to the tracking list. diff --git a/debug/README.mbt.md b/debug/README.mbt.md index 7dde185e3a..ca2c24e283 100644 --- a/debug/README.mbt.md +++ b/debug/README.mbt.md @@ -51,7 +51,7 @@ test "to_string" { } ``` -Use `to_repr` in string interpolation: +Use `Repr(value)` in string interpolation: ```mbt check ///| diff --git a/debug/debug_coverage_test.mbt b/debug/debug_coverage_test.mbt index 8f596d8135..2b6d80a289 100644 --- a/debug/debug_coverage_test.mbt +++ b/debug/debug_coverage_test.mbt @@ -64,8 +64,8 @@ test "assert_eq diff over primitives with mixed equal and differing fields" { ///| test "assert_eq diff over records, enums and maps" { - let p1 : CovPoint = { x: 1, y: 2.5 } - let p2 : CovPoint = { x: 1, y: 4.5 } + let p1 : CovPoint = { x: 1, y: 2.5, } + let p2 : CovPoint = { x: 1, y: 4.5, } assert_true(reports_diff(p1, p2)) let c1 : CovShape = Circle(radius=1) let c2 : CovShape = Circle(radius=2) @@ -168,7 +168,7 @@ test "depth-limited render replaces deep subtrees with ellipsis" { assert_true(@debug.render(Repr(nested)).contains("1")) assert_true(@debug.render(Repr(nested), max_depth=1).contains("...")) // a depth-limited record still renders its (pruned) fields - let p : CovPoint = { x: 7, y: 8.0 } + let p : CovPoint = { x: 7, y: 8.0, } assert_true(!@debug.render(Repr(p), max_depth=1).is_empty()) // depth <= 0 is treated as 1, so max_depth=0 renders identically to max_depth=1 let r = Repr(nested) @@ -181,13 +181,13 @@ test "debug runs over representative values (smoke)" { // executes for primitive, collection and record values without raising. @debug.debug(42) @debug.debug([1, 2, 3]) - let p : CovPoint = { x: 1, y: 2.0 } + let p : CovPoint = { x: 1, y: 2.0, } @debug.debug(p) } ///| test "Debug instance for Iter renders an omitted body" { - let it : Iter[Int] = [1, 2, 3].iter() + let it : Iter[Int] = [|1, 2, 3|] inspect( @debug.to_string(it), content=( diff --git a/debug/delta.mbt b/debug/delta.mbt index 74090300df..6460a532c4 100644 --- a/debug/delta.mbt +++ b/debug/delta.mbt @@ -152,9 +152,10 @@ fn diff_repr( /// /// Optional parameters: /// - `max_depth?`: maximum expansion depth; deeper subtrees are folded. -/// Defaults to `4` when omitted. Values `<= 0` are treated as `1`. -/// - `compact_threshold?`: compact-vs-multiline layout threshold (heuristic one-line vs multiline). -/// Larger values prefer single-line output. Defaults to `30` when omitted. +/// Defaults to `16` when omitted. Values `<= 0` are treated as `1`. +/// - `compact_threshold?`: compact-vs-multiline layout threshold (maximum line +/// width for keeping a node on one line). +/// Larger values prefer single-line output. Defaults to `70` when omitted. /// - `use_ansi?`: whether to emit ANSI color escape codes (for +/- markers). /// Defaults to `true` when omitted. fn pretty_print_delta( diff --git a/debug/delta_wbtest.mbt b/debug/delta_wbtest.mbt index 720289c121..d1b278c9a2 100644 --- a/debug/delta_wbtest.mbt +++ b/debug/delta_wbtest.mbt @@ -32,7 +32,7 @@ test "diff: empty containers stay Same" { guard delta is Same(Array([]), []) else { fail("expected Same(Array([]), [])") } - let empty = Empty::{ } + let empty = Empty::{ } let delta = diff_repr(Debug::to_repr(empty), Debug::to_repr(empty)) guard delta is Same(Record([]), []) else { fail("expected Same(Array([]), [])") diff --git a/debug/docs_test.mbt b/debug/docs_test.mbt index 2d9726c0f7..677ddb7e9a 100644 --- a/debug/docs_test.mbt +++ b/debug/docs_test.mbt @@ -41,7 +41,7 @@ fn List::debug(x : Self) -> Repr { ///| test "Nested List" { - let rcd = { answer: 1, xs: [1, 2, 3], tag: Ok("test string result") } + let rcd = { answer: 1, xs: [1, 2, 3], tag: Ok("test string result"), } fn repeat(n) { if n == 0 { Nil @@ -103,7 +103,7 @@ fn example_repr(example : Example) -> Repr { ///| test "docs: custom type pretty_print" { - let sample : Example = { answer: 42, xs: [1, 2, 3], tag: Ok("done") } + let sample : Example = { answer: 42, xs: [1, 2, 3], tag: Ok("done"), } let r = example_repr(sample) @builtin.inspect( render(r), @@ -160,7 +160,7 @@ fn numbers_repr(value : Numbers) -> Repr { ///| test "docs: threshold controls single-line vs multi-line" { - let sample : Numbers = { xs: [1, 2, 3] } + let sample : Numbers = { xs: [1, 2, 3], } let r = numbers_repr(sample) let compact = render(r) let expanded = render(r) @@ -268,7 +268,7 @@ fn dict_repr(value : DictExample) -> Repr { ///| test "docs: dict prints as k: v pairs" { - let sample : DictExample = { entries: [("a", 1), ("b", 2)] } + let sample : DictExample = { entries: [("a", 1), ("b", 2)], } let r = dict_repr(sample) @builtin.inspect( render(r), @@ -280,7 +280,7 @@ test "docs: dict prints as k: v pairs" { ///| test "docs: dict expands with quoted keys + trailing commas" { - let sample : DictExample = { entries: [("a", 1), ("b", 2)] } + let sample : DictExample = { entries: [("a", 1), ("b", 2)], } let r = dict_repr(sample) @builtin.inspect( render(r), @@ -306,7 +306,7 @@ fn opaque_repr(value : OpaqueExample) -> Repr { ///| test "docs: opaque wrappers show tag and children" { - let sample : OpaqueExample = { label: "Vec", xs: [1, 2] } + let sample : OpaqueExample = { label: "Vec", xs: [1, 2], } let r = opaque_repr(sample) @builtin.inspect(render(r), content="") } diff --git a/debug/pretty_print.mbt b/debug/pretty_print.mbt index 145480fcfc..0943fb94e5 100644 --- a/debug/pretty_print.mbt +++ b/debug/pretty_print.mbt @@ -106,14 +106,11 @@ fn info_size(info : Repr) -> Int { ///| /// Returns `true` when a record field name can be printed without quotes. fn is_unquoted_key(key : String) -> Bool { - key is ['a'..='z' | '_', .. rest] && - (for rest = rest { - match rest { - ['a'..='z' | 'A'..='Z' | '0'..='9' | '_', .. rest] => continue rest - [_, ..] => break false - [] => break true - } - }) + match key { + ['a'..='z' | '_', .. rest] => + rest.all(c => c is ('a'..='z' | 'A'..='Z' | '0'..='9' | '_')) + _ => false + } } ///| @@ -238,12 +235,11 @@ fn Repr::render_repr(self : Repr, threshold : Int) -> Content { /// /// Optional parameters: /// - `max_depth?`: maximum expansion depth; deeper subtrees are replaced with `...`. -/// Defaults to `4` when `None`. Values `<= 0` are treated as `1`. -/// - `compact_threshold?`: compact-vs-multiline layout threshold. -/// The printer uses a heuristic "size" for nodes; when the structure is deemed -/// small enough under this threshold, it is kept on one line, otherwise it is -/// broken into multiple lines. -/// Larger values prefer single-line output. Defaults to `80` when `None`. +/// Defaults to `16` when `None`. Values `<= 0` are treated as `1`. +/// +/// The compact-vs-multiline layout threshold is not configurable here: `render` +/// always uses the internal default of `70`, keeping a node on one line when +/// every line of its compacted form fits within that many characters. pub fn render(r : Repr, max_depth? : Int) -> String { let max_depth : Int? = match max_depth { Some(_) => max_depth diff --git a/debug/printer.mbt b/debug/printer.mbt index a0d8688651..151d002a00 100644 --- a/debug/printer.mbt +++ b/debug/printer.mbt @@ -16,7 +16,7 @@ /// Rendered content with line layout and paren preference. /// /// This is an internal implementation detail of `pretty_print`. -/// Users should rely on `pretty_print_repr` / `pretty_print_delta`. +/// Users should rely on `render` / `pretty_print_delta`. priv struct Content { size : Int lines : Array[String] @@ -36,31 +36,31 @@ priv struct ContentParens { ///| impl Add for ContentParens with fn add(self, other) { - { size: self.size + other.size, lines: self.lines + other.lines } + { size: self.size + other.size, lines: self.lines + other.lines, } } ///| /// Empty content value. fn empty_content() -> Content { - { size: 0, lines: [], needs_parens: false } + { size: 0, lines: [], needs_parens: false, } } ///| /// Single literal token as a `ContentParens`. fn verbatim(x : String) -> ContentParens { - { size: 1, lines: [x] } + { size: 1, lines: [x], } } ///| /// Build a `ContentParens` from explicit size and lines. fn content_parens(size : Int, lines : Array[String]) -> ContentParens { - { size, lines } + { size, lines, } } ///| /// Render any `Show` value as a leaf content node. fn leaf(x : String, needs_parens? : Bool = false) -> Content { - { size: 1, lines: [x], needs_parens } + { size: 1, lines: [x], needs_parens, } } ///| @@ -72,7 +72,7 @@ fn with_lines( r : ContentParens, f : (Array[String]) -> Array[String], ) -> ContentParens { - { size: r.size, lines: f(r.lines) } + { size: r.size, lines: f(r.lines), } } ///| @@ -81,7 +81,7 @@ fn Content::with_lines_content( r : Content, f : (Array[String]) -> Array[String], ) -> Content { - { size: r.size, lines: f(r.lines), needs_parens: r.needs_parens } + { size: r.size, lines: f(r.lines), needs_parens: r.needs_parens, } } ///| @@ -111,19 +111,19 @@ fn surround( ///| /// Convert `Content` to `ContentParens` without adding parentheses. fn Content::no_wrap(c : Content) -> ContentParens { - { size: c.size, lines: c.lines } + { size: c.size, lines: c.lines, } } ///| /// Mark a content chunk as needing parentheses when embedded. fn parens(r : ContentParens) -> Content { - { size: r.size, lines: r.lines, needs_parens: true } + { size: r.size, lines: r.lines, needs_parens: true, } } ///| /// Mark a content chunk as not needing parentheses when embedded. fn no_parens(r : ContentParens) -> Content { - { size: r.size, lines: r.lines, needs_parens: false } + { size: r.size, lines: r.lines, needs_parens: false, } } ///| @@ -169,14 +169,8 @@ fn compact_lines(lines : Array[String]) -> Array[String] { // - arrays: [a, b] // - records/maps: { field: value, field: value } if first == "{" { - let s1 = match joined.strip_prefix(" ") { - Some(sv) => sv - None => joined - } - let inner = match s1.strip_suffix(" ") { - Some(sv) => sv - None => s1 - } + let s1 = joined.strip_prefix(" ").unwrap_or(joined) + let inner = s1.strip_suffix(" ").unwrap_or(s1) if inner == "" { ["{}"] } else { @@ -272,7 +266,7 @@ fn bracket_seq_lines( // multi-line too (important for nested structures when threshold=0). // Also ensure the single element still gets a trailing comma in multi-line mode. if item.length() > 1 { - let lines = indent_spaces(indent_by, { size: 0, lines: item }).lines + let lines = indent_spaces(indent_by, { size: 0, lines: item, }).lines if !lines.is_empty() { let last_i = lines.length() - 1 lines[last_i] += "," @@ -297,7 +291,7 @@ fn bracket_seq_lines( _ => [ open, - ..indent_spaces(indent_by, { size: 0, lines: item }).lines, + ..indent_spaces(indent_by, { size: 0, lines: item, }).lines, close, ] } diff --git a/debug/repr.mbt b/debug/repr.mbt index cc84715056..6e6d3a0c0f 100644 --- a/debug/repr.mbt +++ b/debug/repr.mbt @@ -315,24 +315,5 @@ pub fn Repr::ctor(name : String, args : Array[(String?, Repr)]) -> Repr { /// This is used by diff/pretty-print when the structure is preserved but /// children are rendered separately. fn Repr::shallow(self : Repr) -> Repr { - match self { - UnitLit - | Integer(_) - | DoubleLit(_) - | FloatLit(_) - | BoolLit(_) - | CharLit(_) - | StringLit(_) - | Literal(_) - | Omitted => self - Tuple(_) => Tuple([]) - Array(_) => Array([]) - Record(_) => Record([]) - Enum(name, _) => Enum(name, []) - Opaque(name, _) => Opaque(name, Omitted) - Map(_) => Map([]) - RecordField(name, _) => RecordField(name, Omitted) - EnumLabeledArg(label, _) => EnumLabeledArg(label, Omitted) - MapEntry(_, _) => MapEntry(Omitted, Omitted) - } + self.with_children([]) } diff --git a/deque/README.mbt.md b/deque/README.mbt.md index 79e81f6cdd..c54df1a08b 100644 --- a/deque/README.mbt.md +++ b/deque/README.mbt.md @@ -34,7 +34,7 @@ test { @test.assert_eq(dv.is_empty(), true) let dv2 = @deque.from_array([1, 2, 3, 4, 5]) @test.assert_eq(dv2.length(), 5) - let dv3 = @deque.from_iter([1, 2, 3].iter()) + let dv3 = @deque.from_iter([|1, 2, 3|]) @test.assert_eq(dv3.length(), 3) } ``` diff --git a/deque/deque.mbt b/deque/deque.mbt index 06f7dcefaf..8576eee083 100644 --- a/deque/deque.mbt +++ b/deque/deque.mbt @@ -13,11 +13,25 @@ // limitations under the License. ///| -fn[T] set_null(buffer : UninitializedArray[T], index : Int) = "%fixedarray.set_null" +/// Overwrites `buf[start.. Unit { + for i in start.. Deque[A] { - { buf: UninitializedArray::make(capacity), len: 0, head: 0 } + { buf: UninitializedArray::make(capacity), len: 0, head: 0, } } ///| @@ -121,7 +135,7 @@ pub impl[A] Add for Deque[A] with fn add(self, other) { let len = self.len + other.len let buf = self.unsafe_make_and_blit_to(len, 0) other.unsafe_blit_to(buf, self.len) - { buf, len, head: 0 } + { buf, len, head: 0, } } ///| @@ -173,7 +187,7 @@ pub fn[A] Deque::Deque(arr : ArrayView[A], capacity? : Int) -> Deque[A] { for i, x in arr { buf[i] = x } - { buf, len, head: 0 } + { buf, len, head: 0, } } ///| @@ -214,7 +228,7 @@ test "from_array_empty" { pub fn[A] Deque::copy(self : Deque[A]) -> Deque[A] { let len = self.len let buf = self.unsafe_make_and_blit_to(len, 0) - { buf, len, head: 0 } + { buf, len, head: 0, } } ///| @@ -492,7 +506,7 @@ pub fn[A] Deque::insert(self : Deque[A], index : Int, value : A) -> Unit { pub fn[A] Deque::remove(self : Deque[A], index : Int) -> A { guard index >= 0 && index < self.length() else { abort( - "index out of bounds: the len is from 0 to \{self.length()} but the index is \{index}", + "index out of bounds: the len is \{self.length()} but the index is \{index}", ) } let res = self[index] @@ -505,27 +519,19 @@ pub fn[A] Deque::remove(self : Deque[A], index : Int) -> A { let from = (self.head + i) % cap self.buf[to] = self.buf[from] } - set_null(self.buf, self.head) self.head = new_head } else { // Shift back elements left - let tail_idx = (self.head + self.len - 1) % cap for i in (index + 1).. Unit { ///| /// Removes a front element from a deque. /// +/// As with `Deque::pop_front`, the vacated slot goes on referring to the +/// removed element until a later push reuses it, the buffer grows, the buffer +/// is dropped, or `Deque::release_unused` overwrites it. +/// /// # Example /// ```mbt check /// test { @@ -683,7 +693,6 @@ pub fn[A] Deque::push_back(self : Deque[A], value : A) -> Unit { #alias(pop_front_exn, deprecated) pub fn[A] Deque::unsafe_pop_front(self : Deque[A]) -> Unit { guard self.len > 0 else { abort("The deque is empty!") } - set_null(self.buf, self.head) let cap = self.buf.length() self.head = (self.head + 1) % cap self.len -= 1 @@ -701,28 +710,13 @@ test "unsafe_pop_front after many push_front" { @test.assert_eq(dq.len, 0) } -///| -/// Removes and discards the first element from the deque. This function is a -/// deprecated version of `unsafe_pop_front`. -/// -/// Parameters: -/// -/// * `self` : The deque to remove the first element from. -/// -/// Throws a runtime error if the deque is empty. -/// -/// Example: -/// -/// ```mbt test -/// let dq = @deque.from_array([1, 2, 3]) -/// dq.unsafe_pop_front() -/// inspect(dq, content="@deque.from_array([2, 3])") -/// ``` -/// - ///| /// Removes a back element from a deque. /// +/// As with `Deque::pop_front`, the vacated slot goes on referring to the +/// removed element until a later push reuses it, the buffer grows, the buffer +/// is dropped, or `Deque::release_unused` overwrites it. +/// /// # Example /// ```mbt check /// test { @@ -736,35 +730,18 @@ test "unsafe_pop_front after many push_front" { #alias(pop_back_exn, deprecated) pub fn[A] Deque::unsafe_pop_back(self : Deque[A]) -> Unit { guard self.len > 0 else { abort("The deque is empty!") } - let tail_idx = self.tail_index() - set_null(self.buf, tail_idx) self.len -= 1 } -///| -/// Removes and discards the last element from a deque. -/// -/// Parameters: -/// -/// * `deque` : The deque to remove the last element from. -/// -/// Throws a runtime error if the deque is empty. -/// -/// Example: -/// -/// ```mbt test -/// let dq = @deque.from_array([1, 2, 3]) -/// // Deprecated way: -/// // dq.pop_back_exn() -/// // Recommended way: -/// dq.unsafe_pop_back() -/// inspect(dq, content="@deque.from_array([1, 2])") -/// ``` -/// - ///| /// Removes a front element from a deque and returns it, or `None` if it is empty. /// +/// The vacated slot goes on referring to the removed element, so an +/// `ArrayView` obtained from `Deque::as_views` beforehand keeps observing it, +/// and it is released only once a later push reuses the slot, the buffer +/// grows, or the buffer is dropped. Call `Deque::release_unused` to release it at +/// once. +/// /// # Example /// ```mbt check /// test { @@ -775,7 +752,6 @@ pub fn[A] Deque::unsafe_pop_back(self : Deque[A]) -> Unit { pub fn[A] Deque::pop_front(self : Deque[A]) -> A? { guard self.len > 0 else { return None } let value = self.buf[self.head] - set_null(self.buf, self.head) let cap = self.buf.length() self.head = (self.head + 1) % cap self.len -= 1 @@ -785,6 +761,12 @@ pub fn[A] Deque::pop_front(self : Deque[A]) -> A? { ///| /// Removes a back element from a deque and returns it, or `None` if it is empty. /// +/// The vacated slot goes on referring to the removed element, so an +/// `ArrayView` obtained from `Deque::as_views` beforehand keeps observing it, +/// and it is released only once a later push reuses the slot, the buffer +/// grows, or the buffer is dropped. Call `Deque::release_unused` to release it at +/// once. +/// /// # Example /// ```mbt check /// test { @@ -796,7 +778,6 @@ pub fn[A] Deque::pop_back(self : Deque[A]) -> A? { guard self.len > 0 else { return None } let tail_idx = self.tail_index() let value = self.buf[tail_idx] - set_null(self.buf, tail_idx) self.len -= 1 Some(value) } @@ -879,7 +860,7 @@ pub fn[A] Deque::set(self : Deque[A], index : Int, value : A) -> Unit { /// ``` pub fn[A] Deque::as_views(self : Deque[A]) -> (ArrayView[A], ArrayView[A]) { guard self.len != 0 else { ([], []) } - let { buf, head, len } = self + let { buf, head, len, } = self let cap = buf.length() let head_len = cap - head if head_len >= len { @@ -977,7 +958,7 @@ pub impl[A : Eq] Eq for Deque[A] with fn equal(self, other) { /// } /// ``` #locals(f) -pub fn[A] Deque::each(self : Deque[A], f : (A) -> Unit) -> Unit { +pub fn[A] Deque::each(self : Deque[A], f : (A) -> Unit raise?) -> Unit raise? { for v in self { f(v) } @@ -996,7 +977,10 @@ pub fn[A] Deque::each(self : Deque[A], f : (A) -> Unit) -> Unit { /// } /// ``` #locals(f) -pub fn[A] Deque::eachi(self : Deque[A], f : (Int, A) -> Unit) -> Unit { +pub fn[A] Deque::eachi( + self : Deque[A], + f : (Int, A) -> Unit raise?, +) -> Unit raise? { for i, v in self { f(i, v) } @@ -1015,7 +999,10 @@ pub fn[A] Deque::eachi(self : Deque[A], f : (Int, A) -> Unit) -> Unit { /// } /// ``` #locals(f) -pub fn[A] Deque::rev_each(self : Deque[A], f : (A) -> Unit) -> Unit { +pub fn[A] Deque::rev_each( + self : Deque[A], + f : (A) -> Unit raise?, +) -> Unit raise? { for v in self.rev_iter() { f(v) } @@ -1034,7 +1021,10 @@ pub fn[A] Deque::rev_each(self : Deque[A], f : (A) -> Unit) -> Unit { /// } /// ``` #locals(f) -pub fn[A] Deque::rev_eachi(self : Deque[A], f : (Int, A) -> Unit) -> Unit { +pub fn[A] Deque::rev_eachi( + self : Deque[A], + f : (Int, A) -> Unit raise?, +) -> Unit raise? { for i, v in self.rev_iter2() { f(i, v) } @@ -1043,7 +1033,14 @@ pub fn[A] Deque::rev_eachi(self : Deque[A], f : (Int, A) -> Unit) -> Unit { ///| /// Clears the deque, removing all values. /// -/// This method has no effect on the allocated capacity of the deque, only setting the length to 0. +/// This method has no effect on the allocated capacity of the deque, only +/// setting the length to 0. +/// +/// Emptying a deque is a removal like any other: the buffer keeps referring to +/// the elements that were in it, and they are released once later pushes reuse +/// those slots, the buffer grows, or the deque is dropped. Call +/// `Deque::release_unused` to overwrite them at once, or `Deque::shrink_to_fit` +/// to hand the buffer back entirely. /// /// # Example /// ```mbt check @@ -1054,21 +1051,9 @@ pub fn[A] Deque::rev_eachi(self : Deque[A], f : (Int, A) -> Unit) -> Unit { /// } /// ``` pub fn[A] Deque::clear(self : Deque[A]) -> Unit { - let { head, buf, len } = self - let cap = buf.length() - let head_len = cap - head - if head_len >= len { - for i in head..<(head + len) { - set_null(buf, i) - } - } else { - for i in head.. Unit { /// } /// ``` #locals(f) -pub fn[A, U] Deque::map(self : Deque[A], f : (A) -> U) -> Deque[U] { +pub fn[A, U] Deque::map( + self : Deque[A], + f : (A) -> U raise?, +) -> Deque[U] raise? { let cap = self.buf.length() if self.len == 0 { new_deque(0) @@ -1095,7 +1083,7 @@ pub fn[A, U] Deque::map(self : Deque[A], f : (A) -> U) -> Deque[U] { let idx = (self.head + i) % cap buf[i] = f(self.buf[idx]) } - { buf, len: self.len, head: 0 } + { buf, len: self.len, head: 0, } } } @@ -1111,7 +1099,10 @@ pub fn[A, U] Deque::map(self : Deque[A], f : (A) -> U) -> Deque[U] { /// } /// ``` #locals(f) -pub fn[A, U] Deque::mapi(self : Deque[A], f : (Int, A) -> U) -> Deque[U] { +pub fn[A, U] Deque::mapi( + self : Deque[A], + f : (Int, A) -> U raise?, +) -> Deque[U] raise? { let cap = self.buf.length() if self.len == 0 { new_deque(0) @@ -1121,7 +1112,7 @@ pub fn[A, U] Deque::mapi(self : Deque[A], f : (Int, A) -> U) -> Deque[U] { let idx = (self.head + i) % cap buf[i] = f(i, self.buf[idx]) } - { buf, len: self.len, head: 0 } + { buf, len: self.len, head: 0, } } } @@ -1224,7 +1215,7 @@ pub fn[A : Eq] Deque::contains(self : Deque[A], value : A) -> Bool { pub fn[A] Deque::extract_if(self : Deque[A], f : (A) -> Bool) -> Deque[A] { guard !self.is_empty() else { from_array([]) } let removed = from_array([]) - let write = for read in 0.. Bool) -> Deque[A] { } continue write + 1 } - } nobreak { - write - } - let total_len = self.length() - for i in write.. Unit { /// inspect(dv.capacity(), content="3") /// } /// ``` +/// +/// The survivors are copied into the new buffer and the old one is released +/// with them, so this also releases whatever earlier removals left in the +/// unused capacity. It pays an allocation plus a copy of every survivor to do +/// so; `Deque::release_unused` releases the same elements in one pass over the +/// unused region and no allocation, at the cost of leaving the capacity alone. pub fn[A] Deque::shrink_to_fit(self : Deque[A]) -> Unit { if self.capacity() <= self.length() { return @@ -1335,6 +1326,55 @@ pub fn[A] Deque::shrink_to_fit(self : Deque[A]) -> Unit { self.head = 0 } +///| +/// Overwrites the deque's unused capacity -- every slot not currently holding +/// an element -- with `placeholder`, releasing whatever those slots held. +/// +/// Shrinking a deque never clears the slots it vacates, so whatever they held +/// -- a removed element, or a duplicate reference to a survivor that was +/// shifted over it -- stays reachable from the buffer and unreleased until +/// later pushes reuse those slots, the buffer grows, or the deque is dropped. +/// This releases them on demand without reallocating, which is what +/// `Deque::pop_front`, `Deque::pop_back`, `Deque::remove`, `Deque::retain` +/// and the other operations that take no placeholder leave outstanding. +/// `Deque::shrink_to_fit` releases them too, but by allocating an exact-size +/// buffer and copying every survivor into it; this costs one pass over the +/// unused region and no allocation. +/// +/// This only matters for element types holding references -- for types such as +/// `Int` there is nothing to release and the call merely costs a pass over the +/// buffer. +/// +/// # Example +/// +/// ```mbt check +/// test { +/// let dq = @deque.from_array(["a", "b", "c"]) +/// let _ = dq.pop_back() +/// dq.release_unused(placeholder="") +/// @debug.debug_inspect( +/// dq, +/// content=( +/// #| +/// ), +/// ) +/// } +/// ``` +pub fn[A] Deque::release_unused(self : Deque[A], placeholder~ : A) -> Unit { + let cap = self.buf.length() + let head_len = cap - self.head + if self.len <= head_len { + // The elements sit in one run at `[head, head + len)`, so the unused + // capacity is everything before and after it. + fill_slots(self.buf, 0, self.head, placeholder) + fill_slots(self.buf, self.head + self.len, cap, placeholder) + } else { + // The elements wrap, occupying `[head, cap)` and `[0, len - head_len)`; + // what is left is the single run between them. + fill_slots(self.buf, self.len - head_len, self.head, placeholder) + } +} + ///| /// Shortens the deque in-place, keeping the first `len` elements and dropping /// the rest. @@ -1361,34 +1401,20 @@ pub fn[A] Deque::shrink_to_fit(self : Deque[A]) -> Unit { /// ) /// } /// ``` +/// +/// Elements beyond `len` are removed from the deque, but the backing buffer +/// keeps referring to them: they are released once those slots are reused, +/// once the buffer grows, or once the deque is dropped. Call +/// `Deque::release_unused` to overwrite them at once, or `Deque::shrink_to_fit` +/// to move the survivors into an exact-size buffer. pub fn[A] Deque::truncate(self : Deque[A], len : Int) -> Unit { guard len >= 0 && len < self.len else { return } + // The surviving elements keep their slots, so there is nothing to move: only + // the length changes. An emptied deque restarts at offset zero, as + // `Deque::clear` and `Deque::drain` also arrange. + self.len = len if len == 0 { - self.clear() - return - } - let { head, buf, .. } = self - let (front, back) = self.as_views() - if front.length() < len { - // `len` is wrapping around the end of the buffer. - // Thus, we need to drop the latter part of the back view. - self.len = len - let start = len - front.length() - for i in start.. A?) -> Unit { self.truncate(kept_len) } -///| -/// Filters and maps elements in-place using a provided function. Modifies the -/// deque to retain only elements for which the provided function returns `Some`, -/// and updates those elements with the values inside the `Some` variant. -/// - ///| /// Filters elements in-place by retaining only the elements that satisfy the /// given predicate. Modifies the deque to keep only the elements for which the @@ -1471,7 +1491,7 @@ pub fn[A] Deque::retain_map(self : Deque[A], f : (A) -> A?) -> Unit { /// Parameters: /// /// * `self` : The deque to be filtered. -/// * `predicate` : A function that takes an element and returns `true` if the +/// * `f` : A function that takes an element and returns `true` if the /// element should be kept, `false` if it should be removed. /// /// Example: @@ -1727,14 +1747,14 @@ pub fn[A] Deque::from_iter(iter : Iter[A]) -> Deque[A] { pub fn[A] Deque::to_array(self : Deque[A]) -> Array[A] { let len = self.length() if len == 0 { - [] - } else { - let xs = Array::make(len, self[0]) - for i in 0.. Deque[A] { } nobreak { len } - let target = Deque::{ buf: UninitializedArray::make(len), len, head: 0 } + let target = Deque::{ buf: UninitializedArray::make(len), len, head: 0, } for deque in self; i = 0 { let (front, back) = deque.as_views() target.buf.unsafe_blit(i, deque.buf, front.start_offset(), front.length()) @@ -2034,6 +2055,13 @@ pub fn[A] Deque::flatten(self : Deque[Deque[A]]) -> Deque[A] { /// ) /// } /// ``` +/// +/// The slots the drain vacates are not cleared: each keeps whatever it held +/// before the survivors were shifted, so a drained element or a duplicate +/// reference to a survivor stays reachable there until the slot is reused, the +/// buffer grows, or the deque is dropped. Call `Deque::release_unused` to +/// overwrite them at once, or `Deque::shrink_to_fit` to move the survivors +/// into an exact-size buffer. pub fn[A] Deque::drain(self : Deque[A], start~ : Int, len? : Int) -> Deque[A] { // Validate start guard start >= 0 && start <= self.len else { @@ -2057,7 +2085,7 @@ pub fn[A] Deque::drain(self : Deque[A], start~ : Int, len? : Int) -> Deque[A] { return new_deque(0) } // Prepare deque to return - let deque = Deque::{ buf: UninitializedArray::make(len), len, head: 0 } + let deque = Deque::{ buf: UninitializedArray::make(len), len, head: 0, } // Prepare slices let (front, back) = self.as_views() // We drain from front and back accordingly @@ -2069,21 +2097,17 @@ pub fn[A] Deque::drain(self : Deque[A], start~ : Int, len? : Int) -> Deque[A] { // copy to deque deque.buf.unsafe_blit(0, self.buf, front.start_offset() + start, len) if start == 0 && len == front_max_drain { - // just set_null - for i in front.start_offset().. Deque[A] { // front is not empty let back_remaining = len - front_max_drain let back_len = back.length() - back_remaining - if back_len == 0 { - // back is empty - for i in 0.. Deque[A] { back_start + len, back.length() - back_start - len, ) - // set_null - for i in (back.length() - len).. Deque[A] { new_buf[i] = self.buf[src_idx] } // Create new deque with reversed elements - { buf: new_buf, len, head: 0 } + { buf: new_buf, len, head: 0, } } ///| diff --git a/deque/deque_bench_test.mbt b/deque/deque_bench_test.mbt new file mode 100644 index 0000000000..9368702d9d --- /dev/null +++ b/deque/deque_bench_test.mbt @@ -0,0 +1,31 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +let deque_bench_n = 50000 + +///| +/// `to_array` copies every element out in index order. The Int and Ref +/// variants are both kept because the element type decides whether the +/// per-element store carries reference-counting traffic. +test "bench Deque::to_array Int n=50000" (it : @bench.T) { + let d = @deque.from_array(Array::makei(deque_bench_n, i => i)) + it.bench(fn() { it.keep(d.to_array()) }) +} + +///| +test "bench Deque::to_array Ref n=50000" (it : @bench.T) { + let d = @deque.from_array(Array::makei(deque_bench_n, i => "value\{i}")) + it.bench(fn() { it.keep(d.to_array()) }) +} diff --git a/deque/deque_test.mbt b/deque/deque_test.mbt index 59fb7ce2d0..bc2e10c155 100644 --- a/deque/deque_test.mbt +++ b/deque/deque_test.mbt @@ -101,8 +101,7 @@ test "iter" { dv .iter() .each(x => { - buf.write_string(x.to_string()) - buf.write_char('\n') + buf <+ "\{x}\n" i += 1 }) @test.assert_eq(i, dv.length()) @@ -385,6 +384,71 @@ test "map" { ) } +///| +fn expect_callback_failure(action : () -> Unit raise) -> Unit raise { + try action() catch { + Failure(message) => inspect(message, content="callback failed") + _ => fail("unexpected error") + } noraise { + _ => fail("expected error") + } +} + +///| +fn fail_on_two(x : Int) -> Unit raise { + if x == 2 { + raise Failure::Failure("callback failed") + } +} + +///| +test "each with error callback" { + let dq = @deque.from_array([1, 2, 3]) + expect_callback_failure(() => dq.each(fail_on_two)) +} + +///| +test "eachi with error callback" { + let dq = @deque.from_array([1, 2, 3]) + expect_callback_failure(() => dq.eachi((_i, x) => fail_on_two(x))) +} + +///| +test "rev_each with error callback" { + let dq = @deque.from_array([1, 2, 3]) + expect_callback_failure(() => dq.rev_each(fail_on_two)) +} + +///| +test "rev_eachi with error callback" { + let dq = @deque.from_array([1, 2, 3]) + expect_callback_failure(() => dq.rev_eachi((_i, x) => fail_on_two(x))) +} + +///| +test "map with error callback" { + let dq = @deque.from_array([1, 2, 3]) + expect_callback_failure(() => { + dq.map(x => { + fail_on_two(x) + x + }) + |> ignore + }) +} + +///| +test "mapi with error callback" { + let dq = @deque.from_array([1, 2, 3]) + expect_callback_failure(() => { + dq.mapi((_i, x) => { + fail_on_two(x) + x + }) + |> ignore + }) +} + ///| test "push_and_pop" { let dv = @deque.from_array([1, 2, 3, 4, 5]) @@ -507,7 +571,7 @@ test "reserve_and_push" { ///| test "from_iter multiple elements iter" { debug_inspect( - @deque.from_iter([1, 2, 3].iter()), + @deque.from_iter([|1, 2, 3|]), content=( #| ), @@ -517,7 +581,7 @@ test "from_iter multiple elements iter" { ///| test "from_iter single element iter" { debug_inspect( - @deque.from_iter([1].iter()), + @deque.from_iter([|1|]), content=( #| ), @@ -526,7 +590,7 @@ test "from_iter single element iter" { ///| test "from_iter empty iter" { - let dq : @deque.Deque[Int] = @deque.from_iter(Iter::empty()) + let dq : @deque.Deque[Int] = @deque.from_iter([||]) debug_inspect( dq, content=( @@ -2798,3 +2862,271 @@ test "Deque::append/self_alias" { dq.append(dq) debug_inspect(dq.to_array(), content="[1, 2, 3, 1, 2, 3]") } + +///| +/// Removing elements must never leave a slot a view could read as +/// uninitialized memory, so nothing is written to the slots a removal vacates +/// and views taken beforehand still see the original elements. +test "deque clear leaves the emptied slots as they were" { + let dq = @deque.from_array(["a", "b", "c"]) + let (front, back) = dq.as_views() + dq.clear() + inspect(dq.length(), content="0") + @debug.debug_inspect( + front, + content=( + #| + ), + ) + @debug.debug_inspect( + back, + content=( + #| + ), + ) +} + +///| +test "deque release_unused after clear overwrites the whole buffer" { + let dq = @deque.from_array(["a", "b", "c"]) + let (front, _) = dq.as_views() + dq.clear() + dq.release_unused(placeholder="-") + inspect(dq.length(), content="0") + @debug.debug_inspect( + front, + content=( + #| + ), + ) +} + +///| +test "deque release_unused after clear covers the wrapped case" { + let dq = @deque.from_array([1, 2, 3, 4, 5]) + // rotate so that the contents wrap around the end of the buffer + for _ in 0..<3 { + let v = dq.pop_front().unwrap() + dq.push_back(v) + } + let (front, back) = dq.as_views() + assert_true(back.length() > 0) + dq.clear() + dq.release_unused(placeholder=0) + @debug.debug_inspect( + front, + content=( + #| + ), + ) + @debug.debug_inspect( + back, + content=( + #| + ), + ) +} + +///| +test "deque truncate keeps removed elements reachable unless filled" { + let kept = @deque.from_array(["a", "b", "c", "d"]) + let (kept_view, _) = kept.as_views() + kept.truncate(2) + @debug.debug_inspect( + kept, + content=( + #| + ), + ) + @debug.debug_inspect( + kept_view, + content=( + #| + ), + ) + let filled = @deque.from_array(["a", "b", "c", "d"]) + let (filled_view, _) = filled.as_views() + filled.truncate(2) + filled.release_unused(placeholder="-") + @debug.debug_inspect( + filled, + content=( + #| + ), + ) + @debug.debug_inspect( + filled_view, + content=( + #| + ), + ) +} + +///| +/// No shrinking operation replaces the buffer, so emptying a deque never costs +/// an allocation -- `truncate(0)` and `clear` take the same path and both keep +/// it. A later push makes that observable: it lands in the reused buffer, which +/// a view taken beforehand still points at. +test "no deque shrinking operation replaces the buffer" { + let truncated = @deque.from_array(["a", "b", "c"]) + let (truncated_view, _) = truncated.as_views() + truncated.truncate(0) + inspect(truncated.length(), content="0") + truncated.push_back("x") + @debug.debug_inspect( + truncated_view, + content=( + #| + ), + ) + let cleared = @deque.from_array(["a", "b", "c"]) + let (cleared_view, _) = cleared.as_views() + cleared.clear() + cleared.push_back("x") + @debug.debug_inspect( + cleared_view, + content=( + #| + ), + ) +} + +///| +test "deque release_unused overwrites the slots a drain vacated" { + let dq = @deque.from_array(["a", "b", "c", "d"]) + let (view, _) = dq.as_views() + let drained = dq.drain(start=1, len=2) + dq.release_unused(placeholder="-") + @debug.debug_inspect( + drained, + content=( + #| + ), + ) + @debug.debug_inspect( + dq, + content=( + #| + ), + ) + @debug.debug_inspect( + view, + content=( + #| + ), + ) +} + +///| +/// The pops offer no fill value of their own, so what they remove stays in the +/// buffer until something overwrites it. `release_unused` is what releases it +/// without reallocating. +test "deque release_unused releases what the pops leave behind" { + let dq = @deque.from_array(["a", "b", "c"]) + let (view, _) = dq.as_views() + let _ = dq.pop_back() + @debug.debug_inspect( + view, + content=( + #| + ), + ) + dq.release_unused(placeholder="-") + @debug.debug_inspect( + view, + content=( + #| + ), + ) +} + +///| +/// The unused capacity of a deque can wrap, or straddle both ends of the +/// buffer, so `release_unused` has to cover the complement of the occupied run +/// rather than a single tail. +test "deque release_unused covers both sides of an unwrapped run" { + let dq = @deque.from_array(["a", "b", "c", "d"]) + let (view, _) = dq.as_views() + let _ = dq.pop_front() + let _ = dq.pop_back() + // The elements now sit at `[1, 3)`, leaving unused slots on both sides. + dq.release_unused(placeholder="-") + @debug.debug_inspect( + dq, + content=( + #| + ), + ) + @debug.debug_inspect( + view, + content=( + #| + ), + ) +} + +///| +/// Draining the tail of an unwrapped deque leaves the survivors where they +/// are. A view taken beforehand makes that observable: the survivors are +/// still at their original offsets and the drained slot keeps its occupant, +/// rather than the survivors having been shifted right by the drained length. +test "deque tail drain leaves the survivors in place" { + let dq = @deque.Deque([], capacity=4) + dq.push_back("x") + dq.push_back("a") + dq.push_back("b") + dq.push_back("c") + let _ = dq.pop_front() + let (front, back) = dq.as_views() + inspect(back.length(), content="0") + let drained = dq.drain(start=2) + @debug.debug_inspect( + drained, + content=( + #| + ), + ) + @debug.debug_inspect( + dq, + content=( + #| + ), + ) + @debug.debug_inspect( + front, + content=( + #| + ), + ) +} + +///| +test "deque release_unused covers the wrapped case" { + let dq = @deque.Deque([], capacity=4) + dq.push_back("c") + dq.push_back("d") + dq.push_front("b") + dq.push_front("a") + let (front, back) = dq.as_views() + let _ = dq.pop_front() + let _ = dq.pop_back() + dq.release_unused(placeholder="-") + @debug.debug_inspect( + dq, + content=( + #| + ), + ) + @debug.debug_inspect( + front, + content=( + #| + ), + ) + @debug.debug_inspect( + back, + content=( + #| + ), + ) +} diff --git a/deque/moon.pkg b/deque/moon.pkg index 8d9d47b61f..184f5b7f93 100644 --- a/deque/moon.pkg +++ b/deque/moon.pkg @@ -9,6 +9,7 @@ import { import { "moonbitlang/core/test", "moonbitlang/core/quickcheck", + "moonbitlang/core/bench", } for "test" options( diff --git a/deque/pkg.generated.mbti b/deque/pkg.generated.mbti index 68a1af0e30..a6917e583c 100644 --- a/deque/pkg.generated.mbti +++ b/deque/pkg.generated.mbti @@ -35,8 +35,8 @@ pub fn[A : Eq] Deque::contains(Self[A], A) -> Bool #alias(clone, deprecated) pub fn[A] Deque::copy(Self[A]) -> Self[A] pub fn[A] Deque::drain(Self[A], start~ : Int, len? : Int) -> Self[A] -pub fn[A] Deque::each(Self[A], (A) -> Unit) -> Unit -pub fn[A] Deque::eachi(Self[A], (Int, A) -> Unit) -> Unit +pub fn[A] Deque::each(Self[A], (A) -> Unit raise?) -> Unit raise? +pub fn[A] Deque::eachi(Self[A], (Int, A) -> Unit raise?) -> Unit raise? pub fn[A : Eq] Deque::equal(Self[A], Self[A]) -> Bool pub fn[A] Deque::extract_if(Self[A], (A) -> Bool) -> Self[A] pub fn[A] Deque::filter(Self[A], (A) -> Bool raise?) -> Self[A] raise? @@ -56,8 +56,8 @@ pub fn[A] Deque::iter(Self[A]) -> Iter[A] pub fn[A] Deque::iter2(Self[A]) -> Iter2[Int, A] pub fn Deque::join(Self[String], StringView) -> String pub fn[A] Deque::length(Self[A]) -> Int -pub fn[A, U] Deque::map(Self[A], (A) -> U) -> Self[U] -pub fn[A, U] Deque::mapi(Self[A], (Int, A) -> U) -> Self[U] +pub fn[A, U] Deque::map(Self[A], (A) -> U raise?) -> Self[U] raise? +pub fn[A, U] Deque::mapi(Self[A], (Int, A) -> U raise?) -> Self[U] raise? #as_free_fn(deprecated) #deprecated pub fn[A] Deque::new(capacity? : Int) -> Self[A] @@ -65,14 +65,15 @@ pub fn[A] Deque::pop_back(Self[A]) -> A? pub fn[A] Deque::pop_front(Self[A]) -> A? pub fn[A] Deque::push_back(Self[A], A) -> Unit pub fn[A] Deque::push_front(Self[A], A) -> Unit +pub fn[A] Deque::release_unused(Self[A], placeholder~ : A) -> Unit pub fn[A] Deque::remove(Self[A], Int) -> A pub fn[A] Deque::reserve_capacity(Self[A], Int) -> Unit pub fn[A] Deque::retain(Self[A], (A) -> Bool) -> Unit #alias(filter_map_inplace, deprecated) pub fn[A] Deque::retain_map(Self[A], (A) -> A?) -> Unit pub fn[A] Deque::rev(Self[A]) -> Self[A] -pub fn[A] Deque::rev_each(Self[A], (A) -> Unit) -> Unit -pub fn[A] Deque::rev_eachi(Self[A], (Int, A) -> Unit) -> Unit +pub fn[A] Deque::rev_each(Self[A], (A) -> Unit raise?) -> Unit raise? +pub fn[A] Deque::rev_eachi(Self[A], (Int, A) -> Unit raise?) -> Unit raise? #alias(rev_inplace, deprecated) pub fn[A] Deque::rev_in_place(Self[A]) -> Unit #alias(rev_iterator, deprecated) diff --git a/diff/README.mbt.md b/diff/README.mbt.md index c6f0594aa2..aa494aab72 100644 --- a/diff/README.mbt.md +++ b/diff/README.mbt.md @@ -148,7 +148,7 @@ or markup support on purpose; a renderer is a small loop: test "git-style terminal colors from the public Hunk API" { let old = ["a", "b", "c"][:] let new = ["a", "x", "c"][:] - let buf = StringBuilder::new() + let buf = StringBuilder() for h in @diff.Diff(old~, new~).group(context=1) { buf <+ "\u{1b}[36m\{h.header()}\u{1b}[0m\n" let o = h.old_view() diff --git a/diff/diff.mbt b/diff/diff.mbt index 9440f39856..99e4146021 100644 --- a/diff/diff.mbt +++ b/diff/diff.mbt @@ -477,7 +477,8 @@ fn[T : Eq + Hash] diff_( /// /// The `cutoff` is an upper bound on the minimum edit distance between `old~` and `new~`. When /// `cutoff` is exceeded, `iter_matches` returns a correct, but not necessarily minimal -/// diff. It defaults to about `sqrt(old.length() + new.length())`. +/// diff. It defaults to about the square root of the compacted input sizes +/// (elements present in both sequences), floored at 4096. fn[T : Eq + Hash] iter_matches( cutoff~ : Int?, // optional computation cost limit old~ : ArrayView[T], // original array @@ -600,7 +601,7 @@ fn[T : Eq + Hash] collect_matches( new : ArrayView[T], algorithm : DiffAlgorithm, ) -> Array[(Int, Int)] { - let matches = Array::new(capacity=old.length().min(new.length())) + let matches = Array(capacity=old.length().min(new.length())) match algorithm { Patience => append_patience_matches(matches, cutoff, old, new, 0, 0) Myers => append_myers_matches(matches, cutoff, old, new, 0, 0) @@ -663,7 +664,7 @@ pub fn[T : Hash + Eq] Diff::Diff( cutoff? : Int, algorithm? : DiffAlgorithm = Myers, ) -> Diff[T] { - let result : Array[Edit] = Array::new(capacity=old.length() + new.length()) + let result : Array[Edit] = Array(capacity=old.length() + new.length()) let matches = collect_matches(cutoff, old, new, algorithm) let mut prev_old_idx = 0 let mut prev_new_idx = 0 @@ -758,7 +759,7 @@ pub fn[T : Hash + Eq] Diff::Diff( ), ) } - return { old, new, edits: result } + return { old, new, edits: result, } } } diff --git a/diff/diff_align_test.mbt b/diff/diff_align_test.mbt index 2fb806508c..d4592d1352 100644 --- a/diff/diff_align_test.mbt +++ b/diff/diff_align_test.mbt @@ -55,19 +55,19 @@ fn push_comment_tokens(toks : Array[Tok], body : String) -> Unit { while rest is [_, ..] { rest = lexmatch rest with longest { (re"^//+" as t, after=next) => { - toks.push({ kind: Filler, text: t.to_owned() }) + toks.push({ kind: Filler, text: t.to_owned(), }) next } (re"^[A-Za-z_][A-Za-z0-9_]*" as t, after=next) => { - toks.push({ kind: Comment, text: t.to_owned() }) + toks.push({ kind: Comment, text: t.to_owned(), }) next } (re"^[ \t]+" as t, after=next) => { - toks.push({ kind: Filler, text: t.to_owned() }) + toks.push({ kind: Filler, text: t.to_owned(), }) next } (re"^." as t, after=next) => { - toks.push({ kind: Comment, text: t.to_string() }) + toks.push({ kind: Comment, text: t.to_string(), }) next } _ => abort("unreachable") @@ -85,7 +85,7 @@ fn tokenize_line(line : String) -> Array[Tok] { // `///|` prefix must be handled before lexmatch: under `longest` matching // the comment rule would otherwise swallow `///| x` whole. if line is ['/', '/', '/', '|', ..] { - toks.push({ kind: Marker, text: "///|" }) + toks.push({ kind: Marker, text: "///|", }) push_comment_tokens(toks, line.view(start_offset=4).to_owned()) return toks } @@ -93,19 +93,19 @@ fn tokenize_line(line : String) -> Array[Tok] { while rest is [_, ..] { rest = lexmatch rest with longest { (re"^///\|" as t, after=next) => { - toks.push({ kind: Marker, text: t.to_owned() }) + toks.push({ kind: Marker, text: t.to_owned(), }) next } (re"^#\|[^\n]*" as t, after=next) => { - toks.push({ kind: Str, text: t.to_owned() }) + toks.push({ kind: Str, text: t.to_owned(), }) next } (re"^\$\|[^\n]*" as t, after=next) => { - toks.push({ kind: Str, text: t.to_owned() }) + toks.push({ kind: Str, text: t.to_owned(), }) next } (re"^\"(\\.|[^\"\\])*\"" as t, after=next) => { - toks.push({ kind: Str, text: t.to_owned() }) + toks.push({ kind: Str, text: t.to_owned(), }) next } (re"^//[^\n]*" as t, after=next) => { @@ -113,19 +113,19 @@ fn tokenize_line(line : String) -> Array[Tok] { next } (re"^[A-Za-z_][A-Za-z0-9_]*" as t, after=next) => { - toks.push({ kind: Word, text: t.to_owned() }) + toks.push({ kind: Word, text: t.to_owned(), }) next } (re"^[0-9]+" as t, after=next) => { - toks.push({ kind: Word, text: t.to_owned() }) + toks.push({ kind: Word, text: t.to_owned(), }) next } (re"^[ \t]+" as t, after=next) => { - toks.push({ kind: Space, text: t.to_owned() }) + toks.push({ kind: Space, text: t.to_owned(), }) next } (re"^." as t, after=next) => { - toks.push({ kind: Punct, text: t.to_string() }) + toks.push({ kind: Punct, text: t.to_string(), }) next } _ => abort("unreachable") @@ -349,10 +349,10 @@ fn pair_ops(a : Array[Tok], b : Array[Tok]) -> Array[Op] { /// Render one aligned pair's ops as the two row bodies (left = old side, /// right = new side), merging adjacent highlighted tokens into single runs. fn pair_row_html(ops : Array[Op]) -> (String, String) { - let l = StringBuilder::new() - let r = StringBuilder::new() - let lrun = StringBuilder::new() - let rrun = StringBuilder::new() + let l = StringBuilder() + let r = StringBuilder() + let lrun = StringBuilder() + let rrun = StringBuilder() fn flushes() { if !lrun.is_empty() { l <+ "\{lrun.to_string()}" @@ -478,8 +478,8 @@ test "pair_ops reconstruction property" { let (oa, ob) = pair let ta = tokenize_line(oa) let tb = tokenize_line(ob) - let left = StringBuilder::new() - let right = StringBuilder::new() + let left = StringBuilder() + let right = StringBuilder() for op in pair_ops(ta, tb) { match op { OEq(t) => { diff --git a/diff/diff_html_test.mbt b/diff/diff_html_test.mbt index 2fb300f026..9afe90305a 100644 --- a/diff/diff_html_test.mbt +++ b/diff/diff_html_test.mbt @@ -27,7 +27,7 @@ fn side_by_side_html( old~ : ArrayView[String], new~ : ArrayView[String], ) -> String { - let buf = StringBuilder::new() + let buf = StringBuilder() fn row(l : String, lc : String, r : String, rc : String) { buf <+ "\{l}\{r}\n" diff --git a/diff/diff_test.mbt b/diff/diff_test.mbt index 254bb94268..d1ff512446 100644 --- a/diff/diff_test.mbt +++ b/diff/diff_test.mbt @@ -16,7 +16,7 @@ fn[T] edits_to_string(d : @diff.Diff[T], show~ : (T) -> String) -> String { let old = d.old_view() let new = d.new_view() - let lines = Array::new() + let lines = Array() for edit in d.edits() { let (prefix, slice) = match edit { Insert(new_index~, new_len~, ..) => @@ -361,14 +361,14 @@ test "diff with Bool elements" { ///| test "diff with struct elements" { let old = [ - { id: 1, name: "alice" }, - { id: 2, name: "bob" }, - { id: 3, name: "charlie" }, + { id: 1, name: "alice", }, + { id: 2, name: "bob", }, + { id: 3, name: "charlie", }, ][:] let new = [ - { id: 1, name: "alice" }, - { id: 3, name: "charlie" }, - { id: 4, name: "david" }, + { id: 1, name: "alice", }, + { id: 3, name: "charlie", }, + { id: 4, name: "david", }, ][:] let d = @diff.Diff(old~, new~) let patience = @diff.Diff(old~, new~, algorithm=Patience) @@ -457,7 +457,7 @@ fn check_reconstruct( old : ArrayView[Int], new : ArrayView[Int], ) -> Unit raise { - let rebuilt = Array::new() + let rebuilt = Array() let mut oi = 0 let mut ni = 0 for edit in d.edits() { @@ -502,7 +502,7 @@ test "property: edit scripts reconstruct new from old" { let seqs : Array[Array[Int]] = [] for len in 0..<5 { for bits in 0..<(1 << len) { - let a = Array::new(capacity=len) + let a = Array(capacity=len) for i in 0..> i) & 1) } @@ -524,7 +524,7 @@ test "property: edit scripts reconstruct new from old" { /// (`header` + `edits` + views), the way a third-party `diff-ansi` package /// would: cyan hunk header, red removals, green additions (git's default look). fn[T] ansi_render(h : @diff.Hunk[T], show~ : (T) -> String) -> String { - let buf = StringBuilder::new() + let buf = StringBuilder() buf <+ "\u{1b}[36m\{h.header()}\u{1b}[0m\n" let old = h.old_view() let new = h.new_view() @@ -553,7 +553,7 @@ fn[T] ansi_render(h : @diff.Hunk[T], show~ : (T) -> String) -> String { /// concern — exactly why HTML stays out of core. fn[T] html_render(h : @diff.Hunk[T], show~ : (T) -> String) -> String { fn esc(s : String) -> String { - let buf = StringBuilder::new() + let buf = StringBuilder() for c in s { match c { '&' => buf.write_string("&") @@ -569,7 +569,7 @@ fn[T] html_render(h : @diff.Hunk[T], show~ : (T) -> String) -> String { buf <+ "\{prefix}\{text}\n" } - let buf = StringBuilder::new() + let buf = StringBuilder() buf <+ "
\n"
   buf <+ "\{esc(h.header())}\n"
   let old = h.old_view()
@@ -632,7 +632,7 @@ test "edits() with the views agrees with render()" {
   let old = ["a", "b", "c", "d"][:]
   let new = ["a", "x", "c", "y"][:]
   for h in @diff.Diff(old~, new~).group(context=1) {
-    let rebuilt = StringBuilder::new()
+    let rebuilt = StringBuilder()
     rebuilt <+ "\{h.header()}\n"
     let o = h.old_view()
     let n = h.new_view()
diff --git a/diff/diff_wbtest.mbt b/diff/diff_wbtest.mbt
index afad372631..cc42b9c379 100644
--- a/diff/diff_wbtest.mbt
+++ b/diff/diff_wbtest.mbt
@@ -20,7 +20,7 @@ fn print_edits(
 ) -> String {
   let mut prev_old_idx = 0
   let mut prev_new_idx = 0
-  let result = Array::new(capacity=old.length() + new.length())
+  let result = Array(capacity=old.length() + new.length())
   let callback = fn(old_idx, new_idx) {
     for i in prev_old_idx.. String::make(3, x))[:]
   let new = ['a', 'x', 'c', 'y'].map(x => String::make(3, x))[:]
-  let matches = Array::new()
-  let old_indices = Array::new()
-  let new_indices = Array::new()
+  let matches = Array()
+  let old_indices = Array()
+  let new_indices = Array()
   for old_idx, new_idx in iter_matches(old~, new~, cutoff=None) {
     matches.push((old_idx, new_idx))
     old_indices.push(old_idx)
@@ -409,7 +409,7 @@ test "iter_matches callback verification" {
 test "string array edge cases" {
   let old = ["", "a", "", "b", ""][:]
   let new = ["", "b", "", "a", ""][:]
-  let result = Array::new()
+  let result = Array()
   for old_idx, new_idx in iter_matches(old~, new~, cutoff=None) {
     result.push((old_idx, new_idx))
   }
@@ -489,7 +489,7 @@ test "partial overlap sequence" {
 test "iter_matches parameter order" {
   let old = ['a', 'b', 'c'].map(x => String::make(3, x))[:]
   let new = ['a', 'x', 'c'].map(x => String::make(3, x))[:]
-  let matches = Array::new()
+  let matches = Array()
   let all_equal = @ref.Ref(true)
   for old_idx, new_idx in iter_matches(old~, new~, cutoff=None) {
     // Verify parameter order: old_idx should correspond to old array, new_idx to new array
@@ -511,7 +511,7 @@ test "iter_matches parameter order" {
 test "repeated subsequence" {
   let old = ['a', 'b', 'a', 'b', 'c'].map(x => String::make(3, x))[:]
   let new = ['a', 'b', 'c', 'a', 'b'].map(x => String::make(3, x))[:]
-  let result = Array::new()
+  let result = Array()
   for old_idx, new_idx in iter_matches(old~, new~, cutoff=None) {
     result.push((old_idx, new_idx))
   }
@@ -526,13 +526,13 @@ test "diff symmetry test" {
   let arr2 = ['a', 'x', 'c'].map(x => String::make(3, x))[:]
 
   // Calculate diff from arr1 -> arr2
-  let matches1 = Array::new()
+  let matches1 = Array()
   for old_idx, new_idx in iter_matches(old=arr1, new=arr2, cutoff=None) {
     matches1.push((old_idx, new_idx))
   }
 
   // Calculate diff from arr2 -> arr1
-  let matches2 = Array::new()
+  let matches2 = Array()
   for old_idx, new_idx in iter_matches(old=arr2, new=arr1, cutoff=None) {
     matches2.push((old_idx, new_idx))
   }
diff --git a/diff/diff_word_test.mbt b/diff/diff_word_test.mbt
index 8fb7e7fea3..7ac38db559 100644
--- a/diff/diff_word_test.mbt
+++ b/diff/diff_word_test.mbt
@@ -87,7 +87,7 @@ test "tokenize is faithful and lexical" {
 
 ///|
 fn esc(s : String) -> String {
-  let buf = StringBuilder::new()
+  let buf = StringBuilder()
   for c in s {
     match c {
       '&' => buf <+ "&"
@@ -107,8 +107,8 @@ fn esc(s : String) -> String {
 /// rows, so unequal line counts fall out naturally.
 fn side_rows(inner : @diff.Diff[String], old_side~ : Bool) -> Array[String] {
   let rows = []
-  let buf = StringBuilder::new()
-  let run = StringBuilder::new()
+  let buf = StringBuilder()
+  let run = StringBuilder()
   fn flush_run() {
     if run.is_empty() {
       return
@@ -164,7 +164,7 @@ fn side_rows(inner : @diff.Diff[String], old_side~ : Bool) -> Array[String] {
 /// Delete+Insert replacement pair, word-level highlights via a second
 /// `@diff.Diff` over `lexmatch` tokens.
 fn word_diff_html(old~ : ArrayView[String], new~ : ArrayView[String]) -> String {
-  let buf = StringBuilder::new()
+  let buf = StringBuilder()
   for h in @diff.Diff(old~, new~).group(context=1) {
     buf <+ "\{esc(h.header())}\n"
     let edits = h.edits()
@@ -274,7 +274,7 @@ fn loose(line : String) -> Loose {
     None => line.view()
   }
   let kept = tokenize(code).filter(t => !(t is [' ' | '\t', ..]))
-  { text: line, key: kept.join("\u{0}") }
+  { text: line, key: kept.join("\u{0}"), }
 }
 
 ///|
diff --git a/diff/edit.mbt b/diff/edit.mbt
index 6a1b41854f..43b0c92d5d 100644
--- a/diff/edit.mbt
+++ b/diff/edit.mbt
@@ -96,8 +96,8 @@ fn[T] group_edits(
     return []
   }
   let n = edits.length()
-  let mut pending : Array[Edit] = Array::new()
-  let result : Array[Hunk[T]] = Array::new()
+  let mut pending : Array[Edit] = Array()
+  let result : Array[Hunk[T]] = Array()
   for i, edit in edits {
     match edit {
       Equal(old_index~, new_index~, len~) => {
@@ -119,7 +119,7 @@ fn[T] group_edits(
           if context > 0 {
             pending.push(Equal(old_index~, new_index~, len=context))
           }
-          result.push({ edits: pending, old, new })
+          result.push({ edits: pending, old, new, })
           let offset = len.saturating_sub(context)
           pending = if context > 0 {
             [
@@ -140,7 +140,7 @@ fn[T] group_edits(
     }
   }
   if !(pending is [] || pending is [Equal(_)]) {
-    result.push({ edits: pending, old, new })
+    result.push({ edits: pending, old, new, })
   }
   result
 }
diff --git a/diff/hunk.mbt b/diff/hunk.mbt
index c16f71751c..9c15fdf6f7 100644
--- a/diff/hunk.mbt
+++ b/diff/hunk.mbt
@@ -20,15 +20,13 @@ impl Show for Range with fn output(self, logger) {
   let mut beginning = self.0 + 1 // from array index to line number
   let len = self.1 - self.0
   if len == 1 {
-    logger.write_string(beginning.to_string())
+    logger.write_object(beginning)
   } else {
     if len == 0 {
       // empty ranges begin at line just before the range
       beginning -= 1
     }
-    logger.write_string(beginning.to_string())
-    logger.write_char(',')
-    logger.write_string(len.to_string())
+    logger <+ "\{beginning},\{len}"
   }
 }
 
@@ -67,7 +65,7 @@ fn HunkHeader::new(edits : ArrayView[Edit]) -> Self {
 
 ///|
 impl Show for HunkHeader with fn output(self, logger) {
-  logger.write_string("@@ -\{self.0} +\{self.1} @@")
+  logger <+ "@@ -\{self.0} +\{self.1} @@"
 }
 
 ///|
@@ -125,7 +123,7 @@ pub fn[T] Hunk::new_view(self : Hunk[T]) -> ArrayView[T] {
 /// a `Show` bound so that element types need no `Show` impl and callers
 /// control the rendering (e.g. a field of a record).
 pub fn[T] Hunk::render(self : Hunk[T], show~ : (T) -> String) -> String {
-  let buf = StringBuilder::new()
+  let buf = StringBuilder()
   buf <+ "\{self.header()}\n"
   for edit in self.edits {
     let (prefix, slice) = match edit {
diff --git a/diff/pile.mbt b/diff/pile.mbt
index 6a22c5cbf4..a3c6f97c58 100644
--- a/diff/pile.mbt
+++ b/diff/pile.mbt
@@ -13,7 +13,7 @@
 // limitations under the License.
 
 ///|
-/// `Pile[T]` models one pile in patience sorting.
+/// `Pile` models one pile in patience sorting.
 priv struct Pile(Array[BackPointer])
 
 ///|
diff --git a/diff/piles.mbt b/diff/piles.mbt
index 32a2ca4072..3a6ef2219f 100644
--- a/diff/piles.mbt
+++ b/diff/piles.mbt
@@ -69,12 +69,12 @@ fn Piles::put_by_binary_search(
     // Place the candidate on the next pile; appending a new pile means the
     // candidate extends the longest chain seen so far.
     if lo + 1 < self.0.length() {
-      self[lo + 1].push({ value: (new_idx, place), prev })
+      self[lo + 1].push({ value: (new_idx, place), prev, })
     } else {
-      self.put_back({ value: (new_idx, place), prev })
+      self.put_back({ value: (new_idx, place), prev, })
     }
   } else {
     // new_idx is smaller than all pile tops; place on pile 0 with no predecessor.
-    self[0].push({ value: (new_idx, place), prev: None })
+    self[0].push({ value: (new_idx, place), prev: None, })
   }
 }
diff --git a/diff/quickcheck_test.mbt b/diff/quickcheck_test.mbt
index 088c40e16e..bc602dce68 100644
--- a/diff/quickcheck_test.mbt
+++ b/diff/quickcheck_test.mbt
@@ -456,7 +456,7 @@ fn changes(edits : ArrayView[@diff.Edit]) -> Array[@diff.Edit] {
 
 ///|
 fn to_text(a : Array[Int]) -> String {
-  let buf = StringBuilder::new()
+  let buf = StringBuilder()
   for x in a {
     buf.write_char(
       match x & 3 {
diff --git a/diff/unique_lcs.mbt b/diff/unique_lcs.mbt
index a414953cbd..075778d654 100644
--- a/diff/unique_lcs.mbt
+++ b/diff/unique_lcs.mbt
@@ -28,14 +28,14 @@ fn[T : Eq + Hash] unique_lcs(
   new~ : ArrayView[T],
 ) -> ArrayView[(Int, Int)] {
   let matches = find_unique(old~, new~)
-  let piles = Piles(Array::new(capacity=matches.length()))
+  let piles = Piles(Array(capacity=matches.length()))
   for place in 0.. String = "%string.unsafe_from_uint16_fixedarray"
 
+///|
+#cfg(target="js")
+#warnings("-unused_value")
+fn suppress_unused_v128_import_on_js() -> Unit {
+  ignore(@v128.i8x16_splat(0))
+}
+
 ///|
 fn finish_string(buffer : FixedArray[UInt16], len : Int) -> String {
   if len == buffer.length() {
@@ -32,10 +39,80 @@ fn finish_string(buffer : FixedArray[UInt16], len : Int) -> String {
   }
 }
 
+///|
+#cfg(not(target="js"))
+#inline
+fn decode_scalar(bytes : BytesView) -> String raise Malformed {
+  let t : FixedArray[UInt16] = FixedArray::make(bytes.length(), 0)
+  let tlen = for tlen = 0, bs = bytes {
+    match (tlen, bs) {
+      (tlen, []) => break tlen
+      (tlen, [0..=0x7F as b, .. rest]) => {
+        t.unsafe_set(tlen, b.to_uint16())
+        continue tlen + 1, rest
+      }
+      (_, _ as bytes) => raise Malformed(bytes)
+    }
+  }
+  finish_string(t, tlen)
+}
+
+///|
+#cfg(not(target="js"))
+fn decode_v128(bytes : BytesView) -> String raise Malformed {
+  let length = bytes.length()
+  let result = FixedArray::make(length * 2, b'\x00')
+  for rest = bytes, result_offset = 0 {
+    match rest {
+      [v128le(block), .. tail] => {
+        let non_ascii = @v128.i8x16_bitmask(block)
+        if non_ascii != 0 {
+          raise Malformed(bytes[result_offset / 2 + non_ascii.ctz():])
+        }
+        @v128.v128_store(
+          result,
+          result_offset,
+          @v128.i16x8_extend_low_i8x16_u(block),
+        )
+        @v128.v128_store(
+          result,
+          result_offset + 16,
+          @v128.i16x8_extend_high_i8x16_u(block),
+        )
+        continue tail, result_offset + 32
+      }
+      [0..=0x7F as byte, .. tail] => {
+        result.unsafe_set(result_offset, byte)
+        result.unsafe_set(result_offset + 1, b'\x00')
+        continue tail, result_offset + 2
+      }
+      [] => break
+      _ => raise Malformed(bytes[result_offset / 2:])
+    }
+  }
+  result.unsafe_reinterpret_as_bytes().to_unchecked_string()
+}
+
+///|
+/// Decodes an ASCII byte array into a string.
+///
+/// Raises `Malformed` if any byte is outside the ASCII range.
+#cfg(not(target="js"))
+#inline
+pub fn decode(bytes : BytesView) -> String raise Malformed {
+  // The scalar loop is faster below this crossover point on supported targets.
+  if bytes.length() >= 64 {
+    decode_v128(bytes)
+  } else {
+    decode_scalar(bytes)
+  }
+}
+
 ///|
 /// Decodes an ASCII byte array into a string.
 ///
 /// Raises `Malformed` if any byte is outside the ASCII range.
+#cfg(target="js")
 pub fn decode(bytes : BytesView) -> String raise Malformed {
   let t : FixedArray[UInt16] = FixedArray::make(bytes.length(), 0)
   let tlen = for tlen = 0, bs = bytes {
diff --git a/encoding/ascii/decode_v128_wbtest.mbt b/encoding/ascii/decode_v128_wbtest.mbt
new file mode 100644
index 0000000000..6c2b7fa004
--- /dev/null
+++ b/encoding/ascii/decode_v128_wbtest.mbt
@@ -0,0 +1,79 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+fn v128_outcome(view : BytesView) -> (String?, Bytes?) {
+  (Some(decode_v128(view)), None) catch {
+    Malformed(rest) => (None, Some(rest.to_owned()))
+  }
+}
+
+///|
+fn scalar_outcome(view : BytesView) -> (String?, Bytes?) {
+  (Some(decode_scalar(view)), None) catch {
+    Malformed(rest) => (None, Some(rest.to_owned()))
+  }
+}
+
+///|
+/// The SIMD decoder must agree with the scalar decoder — same string on
+/// success, same remaining view on failure — on arbitrary payloads seen
+/// through views with arbitrary (unaligned) start offsets.
+test "quickcheck: V128 decode agrees with scalar decode" {
+  @quickcheck.check(count=300, (input : (Array[Int], Int, Bool)) => {
+    let (seeds, pad_seed, all_valid) = input
+    let payload = seeds.map(seed => {
+      if all_valid || (seed & 0xF) != 0xF {
+        (seed & 0x7F).to_byte()
+      } else {
+        (0x80 | (seed & 0x7F)).to_byte()
+      }
+    })
+    let pad = pad_seed % 17
+    let pad = if pad < 0 { pad + 17 } else { pad }
+    let full : Array[Byte] = []
+    for _ in 0..
+      inspect(
+        rest,
+        content=(
+          #|b"\xffDEF"
+        ),
+      )
+  }
+}
diff --git a/encoding/ascii/finish_string_wbtest.mbt b/encoding/ascii/finish_string_wbtest.mbt
new file mode 100644
index 0000000000..4a151bdfd4
--- /dev/null
+++ b/encoding/ascii/finish_string_wbtest.mbt
@@ -0,0 +1,41 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+/// Every decoder finishes through `finish_string`; it must keep exactly the
+/// first `len` code units, both when the buffer is full and when it must
+/// truncate.
+test "quickcheck: finish_string keeps exactly the first len code units" {
+  @quickcheck.check(count=100, (input : (Array[Int], Int)) => {
+    let units = input.0.map(x => (x & 0x7F).to_uint16())
+    let buffer = FixedArray::makei(units.length(), i => units[i])
+    let take = if units.is_empty() {
+      0
+    } else {
+      let r = input.1 % (units.length() + 1)
+      if r < 0 {
+        r + units.length() + 1
+      } else {
+        r
+      }
+    }
+    let expected = StringBuilder(size_hint=take)
+    for i in 0.. Byte {
+  match seed & 0x7 {
+    0 => b'\x00'
+    1 => b'\x7F'
+    2 => b'A'
+    3 => b' '
+    _ => ((seed >> 3) & 0x7F).to_byte()
+  }
+}
+
+///|
+/// Maps an arbitrary Int into `0.. Int {
+  let r = value % modulus
+  if r < 0 {
+    r + modulus
+  } else {
+    r
+  }
+}
+
+///|
+fn model_decode_lossy(payload : Array[Byte]) -> String {
+  let buf = StringBuilder(size_hint=payload.length())
+  for b in payload {
+    if b <= b'\x7F' {
+      buf.write_char(b.to_int().unsafe_to_char())
+    } else {
+      buf.write_char('\u{FFFD}')
+    }
+  }
+  buf.to_string()
+}
+
+///|
+/// Embeds `payload` between runs of invalid `0xFF` bytes and returns the view
+/// covering exactly `payload`, so any scan that reads beyond the view sees
+/// bytes that must not influence the result.
+fn poisoned_view(payload : Array[Byte], pad : Int) -> BytesView {
+  let full : Array[Byte] = []
+  for _ in 0.. {
+    let (seeds, pad_seed) = input
+    let payload = seeds.map(valid_byte)
+    let view = poisoned_view(payload, wrap_index(pad_seed, 17))
+    let expected = model_decode_lossy(payload)
+    try @ascii.decode(view) catch {
+      Malformed(_) => false
+    } noraise {
+      decoded =>
+        decoded == expected &&
+        // A fully valid payload decodes identically through the lossy
+        // decoder, and re-encoding restores the original bytes.
+        @ascii.decode_lossy(view) == expected &&
+        @ascii.encode(decoded) == Bytes::from_array(payload)
+    }
+  })
+}
+
+///|
+test "quickcheck: Malformed points at the first invalid byte" {
+  @quickcheck.check(count=300, (input : (Array[Int], Int, Int)) => {
+    let (seeds, plant_seed, pad_seed) = input
+    guard seeds.length() > 0 else { return true }
+    let payload = seeds.map(valid_byte)
+    let plant = wrap_index(plant_seed, payload.length())
+    payload[plant] = if (plant_seed & 1) == 0 { b'\x80' } else { b'\xFF' }
+    let view = poisoned_view(payload, wrap_index(pad_seed, 17))
+    // The remaining view starts at the first invalid byte (the planted one,
+    // unless the seeds already produced an earlier one).
+    let mut first = plant
+    for i, b in payload {
+      if b > b'\x7F' {
+        first = i
+        break
+      }
+    }
+    try @ascii.decode(view) catch {
+      Malformed(rest) =>
+        rest.to_owned() == Bytes::from_array(payload)[first:].to_owned() &&
+        @ascii.decode_lossy(view) == model_decode_lossy(payload)
+    } noraise {
+      _ => false
+    }
+  })
+}
+
+///|
+/// Exhaustively plants an invalid byte at every position for lengths around
+/// the 16-byte SIMD blocks and the 64-byte crossover.
+test "decode malformed position sweep" {
+  let lengths = [1, 15, 16, 17, 31, 32, 63, 64, 65, 80, 96]
+  for len in lengths {
+    let clean = Array::make(len, b'A')
+    let view = poisoned_view(clean, 3)
+    assert_eq(try! @ascii.decode(view), "A".repeat(len))
+    for pos in 0.. assert_eq(rest.length(), len - pos)
+      } noraise {
+        _ => fail("expected Malformed at \{pos} for length \{len}")
+      }
+      assert_eq(@ascii.decode_lossy(view), model_decode_lossy(payload))
+    }
+  }
+}
diff --git a/encoding/base64/decode_v128.mbt b/encoding/base64/decode_v128.mbt
index 7d8738c962..cc7c263b77 100644
--- a/encoding/base64/decode_v128.mbt
+++ b/encoding/base64/decode_v128.mbt
@@ -16,20 +16,14 @@
 // classification uses the perfect hash on the high nibble described in
 // https://mcyoung.xyz/2023/11/27/simd-base64/.
 //
-// A MoonBit `String` is UTF-16, so a block reads thirty-two bytes to obtain
-// sixteen characters, which yield twelve decoded bytes.
+// A MoonBit `String` is UTF-16, so a block loads two vectors of eight code
+// units to obtain sixteen characters, which yield twelve decoded bytes.
 //
 // The fast path only handles canonical input: no whitespace, a length that is
 // a multiple of four, and characters drawn from the Base64 alphabet. Anything
 // else defers to `decode_scalar`, which remains the single definition of the
 // padding and trailing-bit rules.
 
-///|
-/// V128 loads address memory by byte; a `String` is UTF-16, so code unit `i`
-/// lives at byte offset `i * 2`.
-#cfg(any(target="native", target="wasm"))
-fn unsafe_fixedarray_from_string(str : String) -> FixedArray[Byte] = "%identity"
-
 ///|
 /// Offsets added to a Base64 character to reach its sextet, indexed by the
 /// perfect hash `(c >> 4) - (c == '/')`: 1 -> `/`, 2 -> `+`, 3 -> digits,
@@ -129,18 +123,18 @@ fn decode_v128(text : StringView) -> Bytes? {
     2
   }
   let out = FixedArray::make(length / 4 * 3 - padding, b'\x00')
-  let src = unsafe_fixedarray_from_string(text.data())
+  let src = text.data()
   // The final group may carry padding, so it is always left to the scalar
   // decoder; everything before it must be plain alphabet.
   let body = length - 4
   let base = text.start_offset()
   let mut index = 0
   let mut written = 0
-  while index + 16 <= body && written + 16 <= out.length() {
-    let offset = (base + index) * 2
+  while body - index >= 16 && out.length() - written >= 16 {
+    let offset = base + index
     let ascii = @v128.i8x16_narrow_i16x8_u(
-      @v128.v128_load(src, offset),
-      @v128.v128_load(src, offset + 16),
+      @v128.v128_load_i16x8(src, offset),
+      @v128.v128_load_i16x8(src, offset + 8),
     )
     guard decode_valid_v128(ascii) else { return None }
     @v128.v128_store(out, written, decode_block_v128(ascii))
diff --git a/encoding/base64/encode.mbt b/encoding/base64/encode.mbt
index 761e5201ac..1074c3de00 100644
--- a/encoding/base64/encode.mbt
+++ b/encoding/base64/encode.mbt
@@ -36,7 +36,6 @@ pub fn encode(bytes : BytesView, padding? : Bool = true) -> String {
 ///|
 /// Retained on every backend: the linear-memory backends reach it only from
 /// the differential tests, but it is the implementation everywhere else.
-#warnings("-unused_value")
 fn encode_scalar(bytes : BytesView, padding? : Bool = true) -> String {
   let full_groups = bytes.length() / 3
   let remainder = bytes.length() % 3
diff --git a/encoding/base64/encode_v128.mbt b/encoding/base64/encode_v128.mbt
index b82a81d41e..88de362d55 100644
--- a/encoding/base64/encode_v128.mbt
+++ b/encoding/base64/encode_v128.mbt
@@ -21,12 +21,6 @@
 // those sixteen characters are widened to thirty-two bytes before being
 // stored.
 
-///|
-/// V128 loads require `FixedArray[Byte]`; these types share a representation
-/// on the linear-memory backends.
-#cfg(any(target="native", target="wasm"))
-fn unsafe_fixedarray_from_bytes(bytes : Bytes) -> FixedArray[Byte] = "%identity"
-
 ///|
 /// Per-class offsets added to a sextet to reach its Base64 character, indexed
 /// by the class computed in `encode_block_v128`:
@@ -105,6 +99,13 @@ fn write_code_unit(out : FixedArray[Byte], offset : Int, code : Byte) -> Unit {
 #cfg(any(target="native", target="wasm"))
 fn encode_v128(bytes : BytesView, padding : Bool) -> String {
   let length = bytes.length()
+  // Three source bytes become four code units and so eight buffer bytes.
+  // The vector stores below are not bounds checked, so an input whose
+  // buffer size would wrap has to be refused before the allocation; the
+  // scalar encoder builds the string without ever materializing that
+  // buffer. The bound is a round number well inside the true ceiling,
+  // since no caller is near it either way.
+  guard length <= 0x2000_0000 else { return encode_scalar(bytes, padding~) }
   let full_groups = length / 3
   let remainder = length % 3
   let mut char_count = full_groups * 4
@@ -112,46 +113,49 @@ fn encode_v128(bytes : BytesView, padding : Bool) -> String {
     char_count += if padding { 4 } else { remainder + 1 }
   }
   let out = FixedArray::make(char_count * 2, b'\x00')
-  let src = unsafe_fixedarray_from_bytes(bytes.data())
-  let end = bytes.start_offset() + length
-  let mut index = bytes.start_offset()
-  let mut written = 0
   // The load reads sixteen bytes but only twelve are consumed, so the loop
   // stops four bytes short of the end and the tail is encoded scalar.
-  while index + 16 <= end {
-    let chars = encode_block_v128(@v128.v128_load(src, index))
-    @v128.v128_store(out, written, @v128.i16x8_extend_low_i8x16_u(chars))
-    @v128.v128_store(out, written + 16, @v128.i16x8_extend_high_i8x16_u(chars))
-    index += 12
-    written += 32
-  }
-  while index + 3 <= end {
-    let n = (src[index].to_int() << 16) |
-      (src[index + 1].to_int() << 8) |
-      src[index + 2].to_int()
-    write_code_unit(out, written, BASE64_STD[(n >> 18) & 0x3F])
-    write_code_unit(out, written + 2, BASE64_STD[(n >> 12) & 0x3F])
-    write_code_unit(out, written + 4, BASE64_STD[(n >> 6) & 0x3F])
-    write_code_unit(out, written + 6, BASE64_STD[n & 0x3F])
-    index += 3
-    written += 8
-  }
-  let rest = end - index
-  if rest != 0 {
-    let mut n = src[index].to_int() << 16
-    if rest == 2 {
-      n = n | (src[index + 1].to_int() << 8)
-    }
-    write_code_unit(out, written, BASE64_STD[(n >> 18) & 0x3F])
-    write_code_unit(out, written + 2, BASE64_STD[(n >> 12) & 0x3F])
-    if rest == 2 {
-      write_code_unit(out, written + 4, BASE64_STD[(n >> 6) & 0x3F])
-      if padding {
-        write_code_unit(out, written + 6, b'=')
+  for rest = bytes, written = 0 {
+    match rest {
+      [v128le(block), ..] as current => {
+        let chars = encode_block_v128(block)
+        @v128.v128_store(out, written, @v128.i16x8_extend_low_i8x16_u(chars))
+        @v128.v128_store(
+          out,
+          written + 16,
+          @v128.i16x8_extend_high_i8x16_u(chars),
+        )
+        continue current[12:], written + 32
+      }
+      [b0, b1, b2, .. tail] => {
+        let n = (b0.to_int() << 16) | (b1.to_int() << 8) | b2.to_int()
+        write_code_unit(out, written, BASE64_STD[(n >> 18) & 0x3F])
+        write_code_unit(out, written + 2, BASE64_STD[(n >> 12) & 0x3F])
+        write_code_unit(out, written + 4, BASE64_STD[(n >> 6) & 0x3F])
+        write_code_unit(out, written + 6, BASE64_STD[n & 0x3F])
+        continue tail, written + 8
+      }
+      [b0, b1] => {
+        let n = (b0.to_int() << 16) | (b1.to_int() << 8)
+        write_code_unit(out, written, BASE64_STD[(n >> 18) & 0x3F])
+        write_code_unit(out, written + 2, BASE64_STD[(n >> 12) & 0x3F])
+        write_code_unit(out, written + 4, BASE64_STD[(n >> 6) & 0x3F])
+        if padding {
+          write_code_unit(out, written + 6, b'=')
+        }
+        break
+      }
+      [b0] => {
+        let n = b0.to_int() << 16
+        write_code_unit(out, written, BASE64_STD[(n >> 18) & 0x3F])
+        write_code_unit(out, written + 2, BASE64_STD[(n >> 12) & 0x3F])
+        if padding {
+          write_code_unit(out, written + 4, b'=')
+          write_code_unit(out, written + 6, b'=')
+        }
+        break
       }
-    } else if padding {
-      write_code_unit(out, written + 4, b'=')
-      write_code_unit(out, written + 6, b'=')
+      [] => break
     }
   }
   out.unsafe_reinterpret_as_bytes().to_unchecked_string()
diff --git a/encoding/hex/README.mbt.md b/encoding/hex/README.mbt.md
new file mode 100644
index 0000000000..c78d8b055b
--- /dev/null
+++ b/encoding/hex/README.mbt.md
@@ -0,0 +1,63 @@
+# Hexadecimal Encoding
+
+Package `encoding/hex` implements lowercase hexadecimal encoding and decoding
+for in-memory byte data.
+
+The API follows the same core model as Go's `encoding/hex`: each source byte is
+encoded as two hexadecimal characters, and decoding accepts both uppercase and
+lowercase digits. Stream encoders and decoders are intentionally left out
+because this package does not introduce an IO abstraction, and Go's
+`EncodedLen`/`DecodedLen` sizing helpers are left out because `encode` and
+`decode` allocate their own results (the lengths are simply `2 * n` and
+`n / 2`).
+
+## Encoding
+
+Use `encode` to convert bytes into a lowercase hexadecimal string.
+
+```mbt check
+///|
+test "encode" {
+  let src : Bytes = b"Hello Gopher!"
+  inspect(@hex.encode(src), content="48656c6c6f20476f7068657221")
+}
+```
+
+## Decoding
+
+Use `decode` to convert a hexadecimal string back to bytes.
+
+```mbt check
+///|
+test "decode" {
+  let decoded = @hex.decode("48656c6c6f20476f7068657221")
+  inspect(decoded, content="b\"Hello Gopher!\"")
+}
+```
+
+Uppercase hexadecimal digits are accepted too:
+
+```mbt check
+///|
+test "decode_uppercase" {
+  let decoded = @hex.decode("48656C6C6F")
+  inspect(decoded, content="b\"Hello\"")
+}
+```
+
+## Malformed Input
+
+`decode` raises `Malformed` when the input has odd length or contains any
+non-hexadecimal character.
+
+```mbt check
+///|
+test "malformed" {
+  try {
+    let _ = @hex.decode("0g")
+    panic()
+  } catch {
+    Malformed(input) => inspect(input, content="0g")
+  }
+}
+```
diff --git a/encoding/hex/decode.mbt b/encoding/hex/decode.mbt
new file mode 100644
index 0000000000..13b0ec4bd8
--- /dev/null
+++ b/encoding/hex/decode.mbt
@@ -0,0 +1,77 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+/// Error type for malformed hexadecimal input.
+///
+/// `Malformed(input)` reports that `input` has odd length or contains a
+/// character that is not a hexadecimal digit.
+pub suberror Malformed {
+  Malformed(StringView)
+} derive(@debug.Debug)
+
+///|
+fn hex_value(code_unit : Int) -> Int {
+  match code_unit {
+    '0'..='9' => code_unit - '0'
+    'a'..='f' => code_unit - 'a' + 10
+    'A'..='F' => code_unit - 'A' + 10
+    _ => -1
+  }
+}
+
+///|
+/// Decodes a hexadecimal string into bytes.
+///
+/// Both lowercase and uppercase hexadecimal characters are accepted. Raises
+/// `Malformed` if the input length is odd or any character is not a
+/// hexadecimal digit.
+#cfg(not(any(target="native", target="wasm")))
+pub fn decode(text : StringView) -> Bytes raise Malformed {
+  decode_scalar(text)
+}
+
+///|
+/// Decodes a hexadecimal string into bytes.
+///
+/// Both lowercase and uppercase hexadecimal characters are accepted. Raises
+/// `Malformed` if the input length is odd or any character is not a
+/// hexadecimal digit.
+#cfg(any(target="native", target="wasm"))
+pub fn decode(text : StringView) -> Bytes raise Malformed {
+  match decode_v128(text) {
+    Some(bytes) => bytes
+    // Not handled by the fast path: short, odd-length, or invalid input.
+    // The scalar decoder is the single source of the `Malformed` rules.
+    None => decode_scalar(text)
+  }
+}
+
+///|
+/// Retained on every backend: the linear-memory backends reach it only from
+/// the differential tests and the fast path's rejections, but it is the
+/// implementation everywhere else.
+fn decode_scalar(text : StringView) -> Bytes raise Malformed {
+  if text.length() % 2 != 0 {
+    raise Malformed(text)
+  }
+  let buffer = @buffer.Buffer(size_hint=text.length() / 2)
+  for i in 0..<(text.length() / 2) {
+    let hi = hex_value(text.code_unit_at(i * 2).to_int())
+    let lo = hex_value(text.code_unit_at(i * 2 + 1).to_int())
+    guard hi >= 0 && lo >= 0 else { raise Malformed(text) }
+    buffer.write_byte(((hi << 4) | lo).to_byte())
+  }
+  buffer.to_bytes()
+}
diff --git a/encoding/hex/decode_test.mbt b/encoding/hex/decode_test.mbt
new file mode 100644
index 0000000000..d15850596b
--- /dev/null
+++ b/encoding/hex/decode_test.mbt
@@ -0,0 +1,75 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+test "decode" {
+  inspect(
+    try! @hex.decode("48656c6c6f20476f7068657221"),
+    content=(
+      #|b"Hello Gopher!"
+    ),
+  )
+  inspect(
+    try! @hex.decode("48656C6C6F"),
+    content=(
+      #|b"Hello"
+    ),
+  )
+  inspect(
+    try! @hex.decode("000f107f80ff"),
+    content=(
+      #|b"\x00\x0f\x10\x7f\x80\xff"
+    ),
+  )
+}
+
+///|
+test "decode all byte values" {
+  let bytes = Bytes::makei(256, i => i.to_byte())
+  let encoded = @hex.encode(bytes)
+  inspect(try! (@hex.decode(encoded) == bytes), content="true")
+}
+
+///|
+test "decode malformed" {
+  let malformed = text => {
+    try {
+      let _ = @hex.decode(text)
+      false
+    } catch {
+      Malformed(_) => true
+    }
+  }
+  inspect(malformed("0"), content="true")
+  inspect(malformed("0g"), content="true")
+  inspect(malformed("xz"), content="true")
+  inspect(malformed("12 3"), content="true")
+  inspect(malformed("12\n"), content="true")
+}
+
+///|
+test "Debug for Malformed" {
+  try {
+    let _ = @hex.decode("0g")
+    panic()
+  } catch {
+    err =>
+      @debug.debug_inspect(
+        err,
+        content=(
+          #|Malformed()
+        ),
+      )
+  }
+}
diff --git a/encoding/hex/decode_v128.mbt b/encoding/hex/decode_v128.mbt
new file mode 100644
index 0000000000..624d763bbb
--- /dev/null
+++ b/encoding/hex/decode_v128.mbt
@@ -0,0 +1,121 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Vectorized hexadecimal decoding for the linear-memory backends.
+//
+// A MoonBit `String` is UTF-16, so a block loads four vectors of eight code
+// units and narrows them to thirty-two ASCII characters, which decode to
+// sixteen bytes. The narrow reads its lanes as signed and saturates, so a
+// code unit in `0x0100..=0x7FFF` becomes 255 and one in `0x8000..=0xFFFF`
+// -- surrogate halves included -- becomes 0. Neither is a hex digit, so the
+// validity check rejects both and no non-ASCII code unit can masquerade as
+// a hex digit by way of its low byte.
+//
+// The fast path only handles even-length input made of hex digits. Anything
+// else defers to `decode_scalar`, which remains the single definition of the
+// `Malformed` rules.
+
+///|
+/// True when every lane holds an ASCII hex digit (`0-9`, `a-f`, `A-F`).
+#cfg(any(target="native", target="wasm"))
+fn hex_valid_v128(ascii : V128) -> Bool {
+  let digit = @v128.v128_and_(
+    @v128.i8x16_ge_u(ascii, @v128.i8x16_splat(b'0')),
+    @v128.i8x16_le_u(ascii, @v128.i8x16_splat(b'9')),
+  )
+  let lower = @v128.v128_and_(
+    @v128.i8x16_ge_u(ascii, @v128.i8x16_splat(b'a')),
+    @v128.i8x16_le_u(ascii, @v128.i8x16_splat(b'f')),
+  )
+  let upper = @v128.v128_and_(
+    @v128.i8x16_ge_u(ascii, @v128.i8x16_splat(b'A')),
+    @v128.i8x16_le_u(ascii, @v128.i8x16_splat(b'F')),
+  )
+  @v128.i8x16_all_true(@v128.v128_or_(digit, @v128.v128_or_(lower, upper)))
+}
+
+///|
+/// Maps sixteen ASCII hex digits to their nibble values. Only meaningful
+/// after `hex_valid_v128` accepted the lanes: `- '0'` handles digits, and a
+/// further masked `- 39` (lowercase) or `- 7` (uppercase) shifts the letter
+/// ranges onto 10..=15. The adjustments are wrapping adds of the two's
+/// complements because `i8x16_add` wraps.
+#cfg(any(target="native", target="wasm"))
+fn hex_nibbles_v128(ascii : V128) -> V128 {
+  let lower = @v128.v128_and_(
+    @v128.i8x16_ge_u(ascii, @v128.i8x16_splat(b'a')),
+    @v128.i8x16_splat(217), // -39 mod 256
+  )
+  let upper = @v128.v128_and_(
+    @v128.v128_and_(
+      @v128.i8x16_ge_u(ascii, @v128.i8x16_splat(b'A')),
+      @v128.i8x16_le_u(ascii, @v128.i8x16_splat(b'F')),
+    ),
+    @v128.i8x16_splat(249), // -7 mod 256
+  )
+  @v128.i8x16_add(
+    @v128.i8x16_add(ascii, @v128.i8x16_splat(208)), // -48 mod 256
+    @v128.v128_or_(lower, upper),
+  )
+}
+
+///|
+/// Decodes even-length all-hex text. Returns `None` when the fast path does
+/// not apply (short, odd-length, or invalid input), in which case the caller
+/// must use the scalar decoder.
+#cfg(any(target="native", target="wasm"))
+fn decode_v128(text : StringView) -> Bytes? {
+  let length = text.length()
+  // Below one full block the scalar decoder wins outright.
+  guard length >= 32 && length % 2 == 0 else { return None }
+  let out = FixedArray::make(length / 2, b'\x00')
+  let src = text.data()
+  let base = text.start_offset()
+  let mut index = 0
+  let mut written = 0
+  // subtraction, so that the bound cannot wrap on a very long input
+  while length - index >= 32 {
+    let offset = base + index
+    let ascii0 = @v128.i8x16_narrow_i16x8_u(
+      @v128.v128_load_i16x8(src, offset),
+      @v128.v128_load_i16x8(src, offset + 8),
+    )
+    let ascii1 = @v128.i8x16_narrow_i16x8_u(
+      @v128.v128_load_i16x8(src, offset + 16),
+      @v128.v128_load_i16x8(src, offset + 24),
+    )
+    guard hex_valid_v128(ascii0) && hex_valid_v128(ascii1) else { return None }
+    let n0 = hex_nibbles_v128(ascii0)
+    let n1 = hex_nibbles_v128(ascii1)
+    // high nibbles sit in the even lanes, low nibbles in the odd lanes
+    let hi = @v128.i8x16_shuffle(
+      n0, n1, 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30,
+    )
+    let lo = @v128.i8x16_shuffle(
+      n0, n1, 1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25, 27, 29, 31,
+    )
+    @v128.v128_store(out, written, @v128.v128_or_(@v128.i8x16_shl(hi, 4), lo))
+    index += 32
+    written += 16
+  }
+  while index < length {
+    let hi = hex_value(text.unsafe_get(index).to_int())
+    let lo = hex_value(text.unsafe_get(index + 1).to_int())
+    guard hi >= 0 && lo >= 0 else { return None }
+    out[written] = ((hi << 4) | lo).to_byte()
+    index += 2
+    written += 1
+  }
+  Some(out.unsafe_reinterpret_as_bytes())
+}
diff --git a/encoding/hex/encode.mbt b/encoding/hex/encode.mbt
new file mode 100644
index 0000000000..8fb2452ceb
--- /dev/null
+++ b/encoding/hex/encode.mbt
@@ -0,0 +1,49 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+const HEX_DIGITS : Bytes = b"0123456789abcdef"
+
+///|
+/// Encodes bytes as a lowercase hexadecimal string.
+///
+/// The returned string has length `2 * bytes.length()`.
+#cfg(not(any(target="native", target="wasm")))
+pub fn encode(bytes : BytesView) -> String {
+  encode_scalar(bytes)
+}
+
+///|
+/// Encodes bytes as a lowercase hexadecimal string.
+///
+/// The returned string has length `2 * bytes.length()`.
+#cfg(any(target="native", target="wasm"))
+pub fn encode(bytes : BytesView) -> String {
+  encode_v128(bytes)
+}
+
+///|
+/// Retained on every backend: the linear-memory backends reach it only from
+/// the differential tests, but it is the implementation everywhere else.
+fn encode_scalar(bytes : BytesView) -> String {
+  // size_hint is measured in bytes, and each of the 2n output code units
+  // occupies two bytes in the builder's UTF-16 buffer
+  let builder = StringBuilder(size_hint=4 * bytes.length())
+  for byte in bytes {
+    let n = byte.to_int()
+    builder.write_char(HEX_DIGITS[(n >> 4) & 0x0F].to_char())
+    builder.write_char(HEX_DIGITS[n & 0x0F].to_char())
+  }
+  builder.to_string()
+}
diff --git a/encoding/hex/encode_test.mbt b/encoding/hex/encode_test.mbt
new file mode 100644
index 0000000000..76f6da15db
--- /dev/null
+++ b/encoding/hex/encode_test.mbt
@@ -0,0 +1,33 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+test "encode" {
+  inspect(@hex.encode(b""), content="")
+  inspect(@hex.encode(b"Hello"), content="48656c6c6f")
+  inspect(@hex.encode(b"Hello Gopher!"), content="48656c6c6f20476f7068657221")
+  inspect(@hex.encode(b"\x00\x0f\x10\x7f\x80\xff"), content="000f107f80ff")
+}
+
+///|
+test "encode all byte values" {
+  let bytes = Bytes::makei(256, i => i.to_byte())
+  let encoded = @hex.encode(bytes)
+  inspect(encoded.length(), content="512")
+  inspect(encoded[:32], content="000102030405060708090a0b0c0d0e0f")
+  inspect(
+    encoded[encoded.length() - 32:],
+    content="f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff",
+  )
+}
diff --git a/encoding/hex/encode_v128.mbt b/encoding/hex/encode_v128.mbt
new file mode 100644
index 0000000000..0b3108187e
--- /dev/null
+++ b/encoding/hex/encode_v128.mbt
@@ -0,0 +1,87 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Vectorized hexadecimal encoding for the linear-memory backends.
+//
+// Each iteration turns sixteen source bytes into thirty-two hex characters:
+// split every byte into its high and low nibble, interleave the nibbles into
+// output order, and convert each nibble to ASCII with a single register-
+// resident table lookup (`i8x16_swizzle`). Because a MoonBit `String` is
+// UTF-16, the thirty-two ASCII characters are widened to sixty-four bytes
+// before being stored.
+
+///|
+/// The sixteen hex digits as swizzle-table lanes: lane `n` holds the ASCII
+/// code of the lowercase digit for nibble value `n`.
+#cfg(any(target="native", target="wasm"))
+fn hex_digits_v128() -> V128 {
+  @v128.i8x16_const(
+    48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 97, 98, 99, 100, 101, 102,
+  )
+}
+
+///|
+/// Writes one ASCII character as a little-endian UTF-16 code unit.
+#cfg(any(target="native", target="wasm"))
+#inline
+fn write_code_unit(out : FixedArray[Byte], offset : Int, code : Byte) -> Unit {
+  out[offset] = code
+  out[offset + 1] = 0
+}
+
+///|
+#cfg(any(target="native", target="wasm"))
+fn encode_v128(bytes : BytesView) -> String {
+  let length = bytes.length()
+  // The vector stores are not bounds checked, so the UTF-16 byte buffer
+  // must be sized without wrapping. Its four bytes per source byte overflow
+  // sooner than the string itself does, so the scalar encoder -- which
+  // builds the string without ever materializing that buffer -- takes over
+  // from here.
+  guard length <= 0x1FFF_FFFF else { return encode_scalar(bytes) } // Int::MAX / 4
+  // 2 output code units per byte, 2 bytes per UTF-16 code unit
+  let out = FixedArray::make(length * 4, b'\x00')
+  let table = hex_digits_v128()
+  for rest = bytes, written = 0 {
+    match rest {
+      [v128le(data), .. tail] => {
+        let hi = @v128.i8x16_shr_u(data, 4)
+        let lo = @v128.v128_and_(data, @v128.i8x16_splat(0x0F))
+        // interleave: source byte k produces digits at output positions 2k
+        // (high nibble) and 2k + 1 (low nibble)
+        let first = @v128.i8x16_shuffle(
+          hi, lo, 0, 16, 1, 17, 2, 18, 3, 19, 4, 20, 5, 21, 6, 22, 7, 23,
+        )
+        let second = @v128.i8x16_shuffle(
+          hi, lo, 8, 24, 9, 25, 10, 26, 11, 27, 12, 28, 13, 29, 14, 30, 15, 31,
+        )
+        let d0 = @v128.i8x16_swizzle(table, first)
+        let d1 = @v128.i8x16_swizzle(table, second)
+        @v128.v128_store(out, written, @v128.i16x8_extend_low_i8x16_u(d0))
+        @v128.v128_store(out, written + 16, @v128.i16x8_extend_high_i8x16_u(d0))
+        @v128.v128_store(out, written + 32, @v128.i16x8_extend_low_i8x16_u(d1))
+        @v128.v128_store(out, written + 48, @v128.i16x8_extend_high_i8x16_u(d1))
+        continue tail, written + 64
+      }
+      [byte, .. tail] => {
+        let n = byte.to_int()
+        write_code_unit(out, written, HEX_DIGITS[(n >> 4) & 0x0F])
+        write_code_unit(out, written + 2, HEX_DIGITS[n & 0x0F])
+        continue tail, written + 4
+      }
+      [] => break
+    }
+  }
+  out.unsafe_reinterpret_as_bytes().to_unchecked_string()
+}
diff --git a/encoding/hex/extends.mbt b/encoding/hex/extends.mbt
new file mode 100644
index 0000000000..d8b784fc8c
--- /dev/null
+++ b/encoding/hex/extends.mbt
@@ -0,0 +1,20 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// --- deprecated: hidden from the generated interface ---
+
+///|
+#deprecated("Use `Debug::to_repr` instead", skip_current_package=true)
+#doc(hidden)
+pub extend Malformed with @debug.Debug::{to_repr}
diff --git a/encoding/hex/moon.pkg b/encoding/hex/moon.pkg
new file mode 100644
index 0000000000..b83496b7df
--- /dev/null
+++ b/encoding/hex/moon.pkg
@@ -0,0 +1,19 @@
+import {
+  "moonbitlang/core/buffer",
+  "moonbitlang/core/builtin",
+  "moonbitlang/core/debug",
+  "moonbitlang/core/v128",
+}
+
+import {
+  "moonbitlang/core/quickcheck",
+} for "test"
+
+import {
+  "moonbitlang/core/bench",
+  "moonbitlang/core/test",
+} for "wbtest"
+
+// The v128 import is only reachable from the linear-memory backends.
+
+warnings = "-29"
diff --git a/encoding/hex/pkg.generated.mbti b/encoding/hex/pkg.generated.mbti
new file mode 100644
index 0000000000..fe7753eaae
--- /dev/null
+++ b/encoding/hex/pkg.generated.mbti
@@ -0,0 +1,22 @@
+// Generated using `moon info`, DON'T EDIT IT
+package "moonbitlang/core/encoding/hex"
+
+import {
+  "moonbitlang/core/debug",
+}
+
+// Values
+pub fn decode(StringView) -> Bytes raise Malformed
+
+pub fn encode(BytesView) -> String
+
+// Errors
+pub suberror Malformed {
+  Malformed(StringView)
+} derive(@debug.Debug)
+
+// Types and methods
+
+// Type aliases
+
+// Traits
diff --git a/encoding/hex/quickcheck_test.mbt b/encoding/hex/quickcheck_test.mbt
new file mode 100644
index 0000000000..a1763a40b0
--- /dev/null
+++ b/encoding/hex/quickcheck_test.mbt
@@ -0,0 +1,117 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Randomized properties for the hex codec, checked against a character-level
+// reference model. On the linear-memory backends the public functions take
+// the vectorized paths for large-enough inputs, so these properties fuzz the
+// SIMD code there and the scalar code elsewhere.
+
+///|
+fn reference_encode(bytes : Bytes) -> String {
+  let digits = "0123456789abcdef"
+  let sb = StringBuilder(size_hint=4 * bytes.length())
+  for b in bytes {
+    sb.write_char(digits.get_char(b.to_int() >> 4).unwrap())
+    sb.write_char(digits.get_char(b.to_int() & 0x0F).unwrap())
+  }
+  sb.to_string()
+}
+
+///|
+test "quickcheck: decode is the inverse of encode" {
+  @quickcheck.check(
+    (bytes : Bytes) => {
+      let text = @hex.encode(bytes)
+      text == reference_encode(bytes) &&
+      text.length() == 2 * bytes.length() &&
+      @hex.decode(text) == bytes
+    },
+    count=200,
+  )
+}
+
+///|
+test "quickcheck: decode accepts any case mixture and round-trips" {
+  @quickcheck.check(
+    (input : (Bytes, Array[Bool])) => {
+      let (bytes, flips) = input
+      let lower = @hex.encode(bytes)
+      let sb = StringBuilder(size_hint=2 * lower.length())
+      for i in 0.. 0 && flips[i % flips.length()]
+        sb.write_char(
+          if flip && c >= 'a' && c <= 'f' {
+            (c.to_int() - 32).unsafe_to_char()
+          } else {
+            c
+          },
+        )
+      }
+      @hex.decode(sb.to_string()) == bytes
+    },
+    count=200,
+  )
+}
+
+///|
+test "quickcheck: corrupting any position makes decode raise" {
+  @quickcheck.check(
+    (input : (Bytes, UInt, UInt)) => {
+      let (bytes, pos0, char0) = input
+      guard bytes.length() > 0 else { return true }
+      let text = @hex.encode(bytes)
+      let pos = (pos0 % text.length().reinterpret_as_uint()).reinterpret_as_int()
+      // any code unit outside the three hex ranges, spanning ASCII and
+      // non-ASCII space
+      let bad_pool : ReadOnlyArray[Char] = [
+        'g', 'z', 'G', '/', ':', '@', '`', '\u{80}', '中', ' ',
+      ]
+      let bad = bad_pool[(char0 % bad_pool.length().reinterpret_as_uint()).reinterpret_as_int()]
+      let sb = StringBuilder(size_hint=2 * text.length())
+      for i in 0.. true
+      }
+    },
+    count=200,
+  )
+}
+
+///|
+test "quickcheck: odd-length inputs always raise" {
+  @quickcheck.check(
+    (bytes : Bytes) => {
+      let text = @hex.encode(bytes) + "a"
+      try {
+        let _ = @hex.decode(text)
+        false
+      } catch {
+        Malformed(_) => true
+      }
+    },
+    count=100,
+  )
+}
diff --git a/encoding/hex/v128_bench_wbtest.mbt b/encoding/hex/v128_bench_wbtest.mbt
new file mode 100644
index 0000000000..44b28a7707
--- /dev/null
+++ b/encoding/hex/v128_bench_wbtest.mbt
@@ -0,0 +1,75 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Scalar and public (vectorized on the linear-memory backends) codec paths
+// benchmarked side by side, so a single run per target reports both.
+
+///|
+let hex_bench_input_64 : Bytes = sample_bytes(64)
+
+///|
+let hex_bench_input_4k : Bytes = sample_bytes(4096)
+
+///|
+let hex_bench_input_64k : Bytes = sample_bytes(65536)
+
+///|
+let hex_bench_text_4k : String = encode_scalar(hex_bench_input_4k)
+
+///|
+let hex_bench_text_64k : String = encode_scalar(hex_bench_input_64k)
+
+///|
+test "bench encode scalar 4k" (it : @bench.T) {
+  it.bench(fn() { it.keep(encode_scalar(hex_bench_input_4k).length()) })
+}
+
+///|
+test "bench encode public 64" (it : @bench.T) {
+  it.bench(fn() { it.keep(encode(hex_bench_input_64).length()) })
+}
+
+///|
+test "bench encode public 4k" (it : @bench.T) {
+  it.bench(fn() { it.keep(encode(hex_bench_input_4k).length()) })
+}
+
+///|
+test "bench encode public 64k" (it : @bench.T) {
+  it.bench(fn() { it.keep(encode(hex_bench_input_64k).length()) })
+}
+
+///|
+test "bench decode scalar 4k" (it : @bench.T) {
+  it.bench(fn() {
+    let bytes = try! decode_scalar(hex_bench_text_4k)
+    it.keep(bytes.length())
+  })
+}
+
+///|
+test "bench decode public 4k" (it : @bench.T) {
+  it.bench(fn() {
+    let bytes = try! decode(hex_bench_text_4k)
+    it.keep(bytes.length())
+  })
+}
+
+///|
+test "bench decode public 64k" (it : @bench.T) {
+  it.bench(fn() {
+    let bytes = try! decode(hex_bench_text_64k)
+    it.keep(bytes.length())
+  })
+}
diff --git a/encoding/hex/v128_wbtest.mbt b/encoding/hex/v128_wbtest.mbt
new file mode 100644
index 0000000000..9580892009
--- /dev/null
+++ b/encoding/hex/v128_wbtest.mbt
@@ -0,0 +1,175 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Differential tests pinning the vectorized paths against the scalar ones.
+// Lengths sweep every residue modulo sixteen (encode blocks) and modulo
+// thirty-two (decode blocks), so every block-count and tail combination is
+// covered. On the backends without the vectorized paths these compare the
+// scalar implementation with itself, which keeps the file portable.
+
+///|
+/// Deterministic pseudo-random bytes covering all 256 byte values.
+fn sample_bytes(length : Int) -> Bytes {
+  let out = FixedArray::make(length, b'\x00')
+  let mut state = 0x2545F491U
+  for i in 0..> 16) & 0xFF).reinterpret_as_int().to_byte()
+  }
+  out.unsafe_reinterpret_as_bytes()
+}
+
+///|
+test "encode agrees with the scalar encoder across all tail residues" {
+  for length in 0..<80 {
+    let bytes = sample_bytes(length)
+    @test.assert_eq(encode(bytes), encode_scalar(bytes))
+  }
+}
+
+///|
+test "decode agrees with the scalar decoder across all block counts" {
+  for length in 0..<80 {
+    let bytes = sample_bytes(length)
+    let lowercase = encode(bytes)
+    @test.assert_eq(decode(lowercase), bytes)
+    @test.assert_eq(decode(lowercase), decode_scalar(lowercase))
+    // uppercase exercises the letter-range adjustment lanes
+    let uppercase = lowercase.to_upper()
+    @test.assert_eq(decode(uppercase), bytes)
+    @test.assert_eq(decode(uppercase), decode_scalar(uppercase))
+  }
+}
+
+///|
+/// Builds a string from raw UTF-16 code units, so that a test can plant code
+/// units -- lone surrogates included -- that no `Char` can express.
+fn string_of_code_units(units : ArrayView[Int]) -> String {
+  let out = FixedArray::make(units.length() * 2, b'\x00')
+  for i, unit in units {
+    out[i * 2] = (unit & 0xFF).to_byte()
+    out[i * 2 + 1] = ((unit >> 8) & 0xFF).to_byte()
+  }
+  out.unsafe_reinterpret_as_bytes().to_unchecked_string()
+}
+
+///|
+/// The tests above compare the public entry points against the scalar ones,
+/// which stay green even if the fast paths stopped being reached. This one
+/// calls them directly: `encode_v128` must agree with the scalar encoder,
+/// and `decode_v128` must accept a well-formed block rather than defer.
+/// (Which path the entry points dispatch to is a `#cfg` decision; the
+/// benchmarks are what would show a regression there.)
+#cfg(any(target="native", target="wasm"))
+test "the vector paths are engaged, not merely present" {
+  let bytes = sample_bytes(48)
+  let text = encode_scalar(bytes)
+  @test.assert_eq(encode_v128(bytes), text)
+  match decode_v128(text) {
+    Some(decoded) => @test.assert_eq(decoded, bytes)
+    None => fail("decode_v128 declined a well-formed block")
+  }
+}
+
+///|
+test "non-ASCII code units are rejected in every lane" {
+  // The six above 0x00FF each have a low byte that is a valid hex
+  // character, so a decoder that truncated code units instead of
+  // saturating them would accept the input and decode it to the wrong
+  // bytes. 0x00FF is the value saturation itself produces and 0x0000 the
+  // value the signed lanes fold the high half onto, so both must be
+  // rejected on their own account too.
+  let aliasing_units = [
+    0x0000, 0x00FF, 0x0130, 0x0161, 0xD830, 0xDC30, 0xFF41, 0x1030,
+  ]
+  // one whole block, so the planted code unit always lands in the fast path
+  let body = encode_scalar(sample_bytes(16))
+  for unit in aliasing_units {
+    for position in 0.. {
+        if i == position {
+          unit
+        } else {
+          body.unsafe_get(i).to_int()
+        }
+      })
+      let corrupted = string_of_code_units(units)
+      let raised = try {
+        let _ = decode(corrupted)
+        false
+      } catch {
+        Malformed(_) => true
+      }
+      assert_true(raised)
+    }
+  }
+}
+
+///|
+test "vectorized paths honor view start and end offsets" {
+  // A view can start and stop anywhere inside its backing buffer, and the
+  // fast paths index from `start_offset`, so both bounds must be respected.
+  // The padding on either side is itself valid hex, which means a base or
+  // length mistake decodes successfully to the wrong bytes instead of
+  // failing over to the scalar decoder and quietly producing the right
+  // answer.
+  for length in 32..<72 {
+    for pad in 1..<5 {
+      let padded = sample_bytes(pad + length + pad)
+      let bytes_view = padded[pad:pad + length]
+      @test.assert_eq(encode(bytes_view), encode_scalar(bytes_view))
+      let body = encode_scalar(bytes_view)
+      let sb = StringBuilder(size_hint=2 * (pad + body.length() + pad))
+      for _ in 0.. true
+    }
+    assert_true(raised)
+  }
+}
diff --git a/encoding/utf16/decode.mbt b/encoding/utf16/decode.mbt
index 1cbcec0343..ad552876c4 100644
--- a/encoding/utf16/decode.mbt
+++ b/encoding/utf16/decode.mbt
@@ -30,19 +30,6 @@ fn suppress_unused_v128_import_on_js() -> Unit {
   ignore(@v128.i8x16_splat(0))
 }
 
-///|
-#cfg(not(target="js"))
-// V128 loads require FixedArray[Byte]; these types share a representation on
-// non-JS backends.
-fn unsafe_fixedarray_from_bytes(bytes : Bytes) -> FixedArray[Byte] = "%identity"
-
-///|
-#cfg(not(target="js"))
-fn is_utf16_surrogate(code_unit : UInt16) -> Bool {
-  let code = code_unit.to_int()
-  code >= 0xD800 && code <= 0xDFFF
-}
-
 ///|
 #cfg(not(target="js"))
 fn utf16_swap_u16x8(value : V128) -> V128 {
@@ -71,27 +58,20 @@ fn utf16_needs_scalar_le_v128(bytes : BytesView) -> Bool {
   if bytes.length() % 2 != 0 {
     return true
   }
-  let src = bytes.data()
-  if !bytes.is_empty() &&
-    is_utf16_surrogate(src.unsafe_read_uint16_le(bytes.start_offset())) {
-    return true
-  }
-  let src_bytes = unsafe_fixedarray_from_bytes(src)
-  let end = bytes.start_offset() + bytes.length()
-  let mut index = bytes.start_offset()
-  while index + 16 <= end {
-    if utf16_v128_has_surrogate(@v128.v128_load(src_bytes, index)) {
-      return true
-    }
-    index += 16
-  }
-  while index < end {
-    if is_utf16_surrogate(src.unsafe_read_uint16_le(index)) {
-      return true
+  for rest = bytes {
+    match rest {
+      [v128le(block), .. tail] => {
+        if utf16_v128_has_surrogate(block) {
+          return true
+        }
+        continue tail
+      }
+      [u16le(0xD800..=0xDFFF), ..] => return true
+      [u16le(_), .. tail] => continue tail
+      [] => break false
+      _ => break true
     }
-    index += 2
   }
-  false
 }
 
 ///|
@@ -100,52 +80,41 @@ fn utf16_needs_scalar_be_v128(bytes : BytesView) -> Bool {
   if bytes.length() % 2 != 0 {
     return true
   }
-  let src = bytes.data()
-  if !bytes.is_empty() &&
-    is_utf16_surrogate(src.unsafe_read_uint16_be(bytes.start_offset())) {
-    return true
-  }
-  let src_bytes = unsafe_fixedarray_from_bytes(src)
-  let end = bytes.start_offset() + bytes.length()
-  let mut index = bytes.start_offset()
-  while index + 16 <= end {
-    let swapped = utf16_swap_u16x8(@v128.v128_load(src_bytes, index))
-    if utf16_v128_has_surrogate(swapped) {
-      return true
-    }
-    index += 16
-  }
-  while index < end {
-    if is_utf16_surrogate(src.unsafe_read_uint16_be(index)) {
-      return true
+  for rest = bytes {
+    match rest {
+      [v128le(block), .. tail] => {
+        if utf16_v128_has_surrogate(utf16_swap_u16x8(block)) {
+          return true
+        }
+        continue tail
+      }
+      [u16be(0xD800..=0xDFFF), ..] => return true
+      [u16be(_), .. tail] => continue tail
+      [] => break false
+      _ => break true
     }
-    index += 2
   }
-  false
 }
 
 ///|
 #cfg(not(target="js"))
 fn utf16_decode_be_no_surrogate_v128(bytes : BytesView) -> String {
   // The caller guarantees an even byte length with no surrogate code units.
-  let src = bytes.data()
-  let src_bytes = unsafe_fixedarray_from_bytes(src)
   let string_bytes = FixedArray::make(bytes.length(), b'\x00')
-  let end = bytes.start_offset() + bytes.length()
-  let mut index = bytes.start_offset()
-  let mut written = 0
-  while index + 16 <= end {
-    let swapped = utf16_swap_u16x8(@v128.v128_load(src_bytes, index))
-    @v128.v128_store(string_bytes, written, swapped)
-    index += 16
-    written += 16
-  }
-  while index < end {
-    let code_unit = src.unsafe_read_uint16_be(index)
-    string_bytes[written] = (code_unit & 0xFF).to_byte()
-    string_bytes[written + 1] = (code_unit >> 8).to_byte()
-    index += 2
-    written += 2
+  for rest = bytes, written = 0 {
+    match rest {
+      [v128le(block), .. tail] => {
+        @v128.v128_store(string_bytes, written, utf16_swap_u16x8(block))
+        continue tail, written + 16
+      }
+      [u16be(code_unit), .. tail] => {
+        string_bytes.unsafe_set(written, (code_unit & 0xFF).to_byte())
+        string_bytes.unsafe_set(written + 1, (code_unit >> 8).to_byte())
+        continue tail, written + 2
+      }
+      [] => break
+      _ => break
+    }
   }
   string_bytes.unsafe_reinterpret_as_bytes().to_unchecked_string()
 }
diff --git a/encoding/utf16/moon.pkg b/encoding/utf16/moon.pkg
index ae5e0495b2..127176d5f8 100644
--- a/encoding/utf16/moon.pkg
+++ b/encoding/utf16/moon.pkg
@@ -6,4 +6,5 @@ import {
 
 import {
   "moonbitlang/core/bench",
+  "moonbitlang/core/quickcheck",
 } for "test"
diff --git a/encoding/utf16/quickcheck_test.mbt b/encoding/utf16/quickcheck_test.mbt
new file mode 100644
index 0000000000..cdf6a8ab0c
--- /dev/null
+++ b/encoding/utf16/quickcheck_test.mbt
@@ -0,0 +1,525 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Specification tests for UTF-16 encoding and decoding.
+//
+// `decode` has more than one implementation and picks between them at
+// run time. On the non-JS backends it first runs a V128 pre-scan
+// (`utf16_needs_scalar_*_v128`) over the input looking for any
+// surrogate code unit; if it finds none, it takes a vectorized fast
+// path that reinterprets or byte-swaps the buffer wholesale, and
+// otherwise it falls back to the scalar matcher. JS always takes the
+// scalar path. So the same input can be decoded by three different
+// pieces of code depending on backend and content, and they must all
+// agree.
+//
+// The pre-scan is the delicate part: it decides correctness for the
+// fast path, it processes sixteen bytes at a time with a scalar tail,
+// and it runs from the *view's* start offset, which need not be even
+// relative to the backing store. A pre-scan that missed a surrogate
+// would hand a lone surrogate to the fast path and produce an
+// ill-formed `String` rather than the `Malformed` the caller expects.
+//
+// So the oracle here is an independent code-unit walk written straight
+// from the UTF-16 definition, and the generators are built to land on
+// every boundary the pre-scan cares about: surrogates at each of the
+// eight lanes of a block, runs whose length straddles sixteen bytes,
+// odd byte lengths, and views at odd and even offsets.
+
+// =====================================================================
+// The oracle.
+// =====================================================================
+
+///|
+/// Reads the code units of `bytes` in the given order, dropping a
+/// trailing odd byte (which the callers handle separately).
+fn units_of(bytes : BytesView, big_endian : Bool) -> Array[Int] {
+  let out = []
+  let mut index = 0
+  while index + 1 < bytes.length() {
+    let first = bytes[index].to_int()
+    let second = bytes[index + 1].to_int()
+    out.push(
+      if big_endian {
+        (first << 8) | second
+      } else {
+        (second << 8) | first
+      },
+    )
+    index += 2
+  }
+  out
+}
+
+///|
+fn is_high_surrogate(unit : Int) -> Bool {
+  unit >= 0xD800 && unit <= 0xDBFF
+}
+
+///|
+fn is_low_surrogate(unit : Int) -> Bool {
+  unit >= 0xDC00 && unit <= 0xDFFF
+}
+
+///|
+fn is_surrogate(unit : Int) -> Bool {
+  unit >= 0xD800 && unit <= 0xDFFF
+}
+
+///|
+/// The scalars a strict decoder must produce, or `None` together with
+/// the *byte* offset at which it must report `Malformed`.
+///
+/// A well-formed UTF-16 sequence is a high surrogate followed by a low
+/// one, or any single non-surrogate unit. Everything else -- a lone
+/// surrogate of either kind, or a trailing odd byte -- is ill-formed.
+fn oracle_strict(bytes : BytesView, big_endian : Bool) -> (Array[Int]?, Int) {
+  let units = units_of(bytes, big_endian)
+  let scalars = []
+  let mut i = 0
+  while i < units.length() {
+    let unit = units[i]
+    if is_high_surrogate(unit) &&
+      i + 1 < units.length() &&
+      is_low_surrogate(units[i + 1]) {
+      scalars.push(((unit - 0xD800) << 10) + (units[i + 1] - 0xDC00) + 0x10000)
+      i += 2
+    } else if is_surrogate(unit) {
+      return (None, i * 2)
+    } else {
+      scalars.push(unit)
+      i += 1
+    }
+  }
+  // an odd trailing byte is a truncated code unit
+  if bytes.length() % 2 != 0 {
+    return (None, units.length() * 2)
+  }
+  (Some(scalars), -1)
+}
+
+///|
+/// The scalars a lossy decoder must produce: one U+FFFD per ill-formed
+/// code unit, and one for a trailing odd byte.
+fn oracle_lossy(bytes : BytesView, big_endian : Bool) -> Array[Int] {
+  let units = units_of(bytes, big_endian)
+  let scalars = []
+  let mut i = 0
+  while i < units.length() {
+    let unit = units[i]
+    if is_high_surrogate(unit) &&
+      i + 1 < units.length() &&
+      is_low_surrogate(units[i + 1]) {
+      scalars.push(((unit - 0xD800) << 10) + (units[i + 1] - 0xDC00) + 0x10000)
+      i += 2
+    } else if is_surrogate(unit) {
+      scalars.push(0xFFFD)
+      i += 1
+    } else {
+      scalars.push(unit)
+      i += 1
+    }
+  }
+  if bytes.length() % 2 != 0 {
+    scalars.push(0xFFFD)
+  }
+  scalars
+}
+
+///|
+fn scalars_to_string(scalars : Array[Int]) -> String {
+  String::from_array(scalars.map(Int::unsafe_to_char))
+}
+
+///|
+/// What `decode` did: the text, or the length of the suffix it reported
+/// as `Malformed`.
+priv enum Outcome {
+  Decoded(String)
+  Rejected(Int)
+}
+
+///|
+fn decode_outcome(bytes : BytesView, endianness : @utf16.Endian) -> Outcome {
+  try @utf16.decode(bytes, endianness~) catch {
+    Malformed(suffix) => Rejected(suffix.length())
+  } noraise {
+    text => Decoded(text)
+  }
+}
+
+// =====================================================================
+// Checking one input.
+// =====================================================================
+
+///|
+/// Renders bytes as hex, so a shrunk counterexample is readable.
+fn hex(bytes : BytesView) -> String {
+  let digits : ReadOnlyArray[Char] = [
+    '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F',
+  ]
+  let out = StringBuilder()
+  for b in bytes {
+    let v = b.to_int()
+    out.write_char(digits[v >> 4])
+    out.write_char(digits[v & 0xF])
+    out.write_char(' ')
+  }
+  out.to_string()
+}
+
+///|
+/// Both decoders, in both byte orders, against the oracle.
+fn check(bytes : BytesView) -> Bool {
+  for big_endian in ([false, true] : Array[_]) {
+    let endianness : @utf16.Endian = if big_endian { Big } else { Little }
+    let (expected, bad_offset) = oracle_strict(bytes, big_endian)
+    let strict_ok = match (decode_outcome(bytes, endianness), expected) {
+      (Decoded(text), Some(scalars)) => text == scalars_to_string(scalars)
+      (Rejected(length), None) => length == bytes.length() - bad_offset
+      _ => false
+    }
+    guard strict_ok else { return false }
+    guard @utf16.decode_lossy(bytes, endianness~) ==
+      scalars_to_string(oracle_lossy(bytes, big_endian)) else {
+      return false
+    }
+  }
+  true
+}
+
+///|
+/// The same input, but reached through a view whose start offset inside
+/// the backing store is `padding` bytes in.
+///
+/// The V128 pre-scan indexes from the view's start offset, so an odd
+/// offset shifts every sixteen-byte block relative to the code-unit
+/// grid. Decoding must not notice.
+fn check_at_offset(payload : Bytes, padding : Int) -> Bool {
+  let prefix = Bytes::makei(padding, _ => b'\xAA')
+  let padded = prefix + payload + b"\x55"
+  check(padded[padding:padded.length() - 1])
+}
+
+// =====================================================================
+// Test data.
+// =====================================================================
+
+///|
+/// Code units on every boundary of the surrogate block, plus ordinary
+/// BMP values. A pre-scan whose range test is wrong by one is wrong on
+/// one of these.
+let edge_units : Array[Int] = [
+  0x0000, 0x0041, 0xD7FF, 0xD800, 0xD801, 0xDBFE, 0xDBFF, 0xDC00, 0xDC01, 0xDFFE,
+  0xDFFF, 0xE000, 0xFEFF, 0xFFFD, 0xFFFF,
+]
+
+///|
+/// Maps an arbitrary `Int` into `0.. Int {
+  let r = value % modulus
+  if r < 0 {
+    r + modulus
+  } else {
+    r
+  }
+}
+
+///|
+fn push_unit(out : Array[Byte], unit : Int, big_endian : Bool) -> Unit {
+  if big_endian {
+    out.push((unit >> 8).to_byte())
+    out.push((unit & 0xFF).to_byte())
+  } else {
+    out.push((unit & 0xFF).to_byte())
+    out.push((unit >> 8).to_byte())
+  }
+}
+
+///|
+/// Builds a byte string from `(kind, value)` chunks, written in
+/// little-endian unit order (the properties then read it back in both
+/// orders, so a chunk laid down as a surrogate pair one way is a pair
+/// of unrelated units the other -- which is itself worth covering).
+///
+/// The kinds keep the corpus dense in the cases the pre-scan has to get
+/// right: surrogates in isolation, well-formed pairs, long
+/// surrogate-free runs that reach the vectorized path, and a lone
+/// trailing byte that makes the length odd.
+fn build_bytes(chunks : Array[(Int, Int)]) -> Bytes {
+  let out = []
+  for chunk in chunks {
+    let (kind, value) = chunk
+    match wrap_index(kind, 6) {
+      // a boundary code unit
+      0 =>
+        push_unit(
+          out,
+          edge_units[wrap_index(value, edge_units.length())],
+          false,
+        )
+      // a well-formed surrogate pair
+      1 => {
+        push_unit(out, 0xD800 + wrap_index(value, 0x400), false)
+        push_unit(out, 0xDC00 + wrap_index(value / 0x400, 0x400), false)
+      }
+      // a lone surrogate
+      2 => push_unit(out, 0xD800 + wrap_index(value, 0x800), false)
+      // an arbitrary BMP unit
+      3 => push_unit(out, wrap_index(value, 0x10000), false)
+      // a surrogate-free run long enough to reach the V128 loop, of a
+      // length that straddles the sixteen-byte block
+      4 =>
+        for i in 0..<(1 + wrap_index(value, 20)) {
+          push_unit(out, 0x0041 + wrap_index(value + i, 0x100), false)
+        }
+      // a single byte, which makes the total length odd
+      _ => out.push(wrap_index(value, 256).to_byte())
+    }
+  }
+  Bytes::from_array(out)
+}
+
+// =====================================================================
+// The specification.
+// =====================================================================
+
+///|
+test "quickcheck: decode and decode_lossy match the code-unit oracle" {
+  @quickcheck.check(
+    (chunks : Array[(Int, Int)]) => check(build_bytes(chunks)),
+    counterexample_context=chunks => hex(build_bytes(chunks)),
+    count=2000,
+  )
+}
+
+///|
+test "quickcheck: decoding does not depend on the view's offset" {
+  // Two paddings, so the pre-scan's sixteen-byte blocks land on both
+  // parities of the code-unit grid.
+  @quickcheck.check(
+    (chunks : Array[(Int, Int)]) => {
+      let payload = build_bytes(chunks)
+      check_at_offset(payload, 0) &&
+      check_at_offset(payload, 1) &&
+      check_at_offset(payload, 2) &&
+      check_at_offset(payload, 15)
+    },
+    counterexample_context=chunks => hex(build_bytes(chunks)),
+    count=1000,
+  )
+}
+
+///|
+test "every one- and two-byte input matches the oracle" {
+  // 256 + 65536 inputs. This settles the whole single-code-unit space
+  // in both byte orders: every lone surrogate, every BMP unit, and
+  // every truncated unit.
+  for b0 in 0..<256 {
+    assert_true(check(Bytes::from_array([b0.to_byte()])))
+    for b1 in 0..<256 {
+      assert_true(check(Bytes::from_array([b0.to_byte(), b1.to_byte()])))
+    }
+  }
+}
+
+///|
+test "every pair of boundary code units matches the oracle" {
+  // All ordered pairs from the surrogate-boundary alphabet, in both
+  // unit orders, which covers every combination of "high then low",
+  // "high then not-low", "low then anything", and the non-surrogate
+  // cases at the block edges.
+  for first in edge_units {
+    for second in edge_units {
+      for big_endian in ([false, true] : Array[_]) {
+        let out = []
+        push_unit(out, first, big_endian)
+        push_unit(out, second, big_endian)
+        assert_true(check(Bytes::from_array(out)))
+        // ...and the same pair with a trailing odd byte
+        out.push(b'\x7F')
+        assert_true(check(Bytes::from_array(out)))
+      }
+    }
+  }
+}
+
+///|
+test "a surrogate at every position of a vectorized block is caught" {
+  // The pre-scan reads sixteen bytes -- eight code units -- at a time.
+  // A surrogate hidden at any lane of any block must send the input to
+  // the scalar path; if the pre-scan missed it, the fast path would
+  // emit an ill-formed String instead of raising.
+  for length in 1..<40 {
+    for position in 0.. {
+      let little = @utf16.encode(text)
+      let big = @utf16.encode(text, endianness=Big)
+      // the two orders are byte-swapped images of each other
+      big.length() == little.length() &&
+      little.length() == text.length() * 2 &&
+      (for i in 0..<(little.length() / 2) {
+        if little[i * 2] != big[i * 2 + 1] || little[i * 2 + 1] != big[i * 2] {
+          break false
+        }
+      } nobreak {
+        true
+      }) &&
+      @utf16.decode(little) == text &&
+      @utf16.decode(big, endianness=Big) == text &&
+      @utf16.decode_lossy(little) == text &&
+      @utf16.decode_lossy(big, endianness=Big) == text
+    },
+    count=1000,
+  )
+}
+
+///|
+test "quickcheck: decode then encode is the identity on accepted bytes" {
+  @quickcheck.check(
+    (chunks : Array[(Int, Int)]) => {
+      let bytes = build_bytes(chunks)
+      for big_endian in ([false, true] : Array[_]) {
+        let endianness : @utf16.Endian = if big_endian { Big } else { Little }
+        match decode_outcome(bytes, endianness) {
+          Decoded(text) =>
+            if @utf16.encode(text, endianness~) != bytes {
+              return false
+            }
+          Rejected(_) => ()
+        }
+      }
+      true
+    },
+    counterexample_context=chunks => hex(build_bytes(chunks)),
+    count=1000,
+  )
+}
+
+///|
+test "quickcheck: decode_lossy output is always well-formed" {
+  @quickcheck.check(
+    (chunks : Array[(Int, Int)]) => {
+      let bytes = build_bytes(chunks)
+      for big_endian in ([false, true] : Array[_]) {
+        let endianness : @utf16.Endian = if big_endian { Big } else { Little }
+        let text = @utf16.decode_lossy(bytes, endianness~)
+        // whatever came out must survive a strict round-trip
+        if @utf16.decode(@utf16.encode(text, endianness~), endianness~) != text {
+          return false
+        }
+      }
+      true
+    },
+    counterexample_context=chunks => hex(build_bytes(chunks)),
+    count=1000,
+  )
+}
+
+// =====================================================================
+// The byte order mark.
+// =====================================================================
+
+///|
+test "quickcheck: bom emission and ignore_bom are inverse" {
+  @quickcheck.check(
+    (text : String) => {
+      for big_endian in ([false, true] : Array[_]) {
+        let endianness : @utf16.Endian = if big_endian { Big } else { Little }
+        let plain = @utf16.encode(text, endianness~)
+        let marked = @utf16.encode(text, bom=true, endianness~)
+        let mark = if big_endian { b"\xFE\xFF" } else { b"\xFF\xFE" }
+        if marked != mark + plain {
+          return false
+        }
+        if @utf16.decode(marked, ignore_bom=true, endianness~) != text {
+          return false
+        }
+        if @utf16.decode_lossy(marked, ignore_bom=true, endianness~) != text {
+          return false
+        }
+        // the default keeps it as U+FEFF
+        if @utf16.decode(marked, endianness~) != "\u{FEFF}" + text {
+          return false
+        }
+      }
+      true
+    },
+    count=1000,
+  )
+}
+
+///|
+test "a bom is only stripped when it matches the byte order" {
+  // U+FEFF little-endian is FF FE; read as big-endian those same bytes
+  // are U+FFFE, which is not a mark and must survive.
+  assert_eq(@utf16.decode(b"\xFF\xFE", ignore_bom=true), "")
+  assert_eq(
+    @utf16.decode(b"\xFF\xFE", ignore_bom=true, endianness=Big),
+    "\u{FFFE}",
+  )
+  assert_eq(@utf16.decode(b"\xFE\xFF", ignore_bom=true, endianness=Big), "")
+  assert_eq(@utf16.decode(b"\xFE\xFF", ignore_bom=true), "\u{FFFE}")
+  // only the leading one goes
+  assert_eq(@utf16.decode(b"\xFF\xFE\xFF\xFE", ignore_bom=true), "\u{FEFF}")
+  // and a mark in the middle is data
+  assert_eq(@utf16.decode(b"\x41\x00\xFF\xFE", ignore_bom=true), "A\u{FEFF}")
+}
diff --git a/encoding/utf8/decode_js.mbt b/encoding/utf8/decode_js.mbt
index 1be509d749..bc7b8aedf9 100644
--- a/encoding/utf8/decode_js.mbt
+++ b/encoding/utf8/decode_js.mbt
@@ -18,7 +18,7 @@ extern "js" fn decode_utf8_js(
   start : Int,
   len : Int,
   preserve_bom : Bool,
-) -> Array[String] =
+) -> FixedArray[String] =
   #| ((preserveBOMDecoder, dropBOMDecoder) => function(bytes, start, len, preserveBOM) {
   #|   try {
   #|     const end = start + len;
diff --git a/encoding/utf8/decode_nonjs.mbt b/encoding/utf8/decode_nonjs.mbt
index a456ca3b88..627ee5ac9b 100644
--- a/encoding/utf8/decode_nonjs.mbt
+++ b/encoding/utf8/decode_nonjs.mbt
@@ -290,12 +290,17 @@ pub fn decode_lossy(bytes : BytesView, ignore_bom? : Bool = false) -> String {
   let input = bytes.data()
   let src_offset = bytes.start_offset()
   let src_length = bytes.length()
-  let dst = FixedArray::make(src_length * 2, (0 : UInt16))
+  // Valid UTF-8 produces at most one UTF-16 code unit per input byte. Each
+  // malformed maximal subpart consumes at least one byte and emits one U+FFFD.
+  let dst = FixedArray::make(src_length, (0 : UInt16))
   let written = match
     utf8_decode_into_utf16(input, src_offset, src_length, dst, 0) {
     ok if ok >= 0 => ok
     _ => utf8_decode_lossy_into_utf16(input, src_offset, src_length, dst, 0)
   }
+  if written == src_length {
+    return unsafe_fixedarray_uint16_to_string(dst)
+  }
   let result = FixedArray::make(written, (0 : UInt16))
   result.unsafe_blit(0, dst, 0, written)
   unsafe_fixedarray_uint16_to_string(result)
diff --git a/encoding/utf8/encode_js.mbt b/encoding/utf8/encode_js.mbt
index 132dae6356..49c0039348 100644
--- a/encoding/utf8/encode_js.mbt
+++ b/encoding/utf8/encode_js.mbt
@@ -39,7 +39,8 @@ extern "js" fn encode_utf8_js(
 ///|
 /// Encodes a string into a UTF-8 byte array.
 ///
-/// Panics if the string contains an invalid surrogate pair.
+/// Unpaired surrogates are replaced with U+FFFD, matching `TextEncoder`.
+/// The other backends panic on them instead.
 pub fn encode(str : StringView, bom? : Bool = false) -> Bytes {
   encode_utf8_js(str.data(), str.start_offset(), str.length(), bom)
 }
diff --git a/encoding/utf8/moon.pkg b/encoding/utf8/moon.pkg
index 9278885d2e..1cfcbd3a65 100644
--- a/encoding/utf8/moon.pkg
+++ b/encoding/utf8/moon.pkg
@@ -5,6 +5,7 @@ import {
 
 import {
   "moonbitlang/core/buffer",
+  "moonbitlang/core/quickcheck",
 } for "test"
 
 options(
diff --git a/encoding/utf8/quickcheck_test.mbt b/encoding/utf8/quickcheck_test.mbt
new file mode 100644
index 0000000000..66757399ca
--- /dev/null
+++ b/encoding/utf8/quickcheck_test.mbt
@@ -0,0 +1,587 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Specification tests for UTF-8 encoding and decoding.
+//
+// The suite is differential: it pins `decode`, `decode_lossy`, and
+// `encode` against an oracle transcribed *from the standard*, not from
+// the implementation —
+//
+//   * `lead_class` is a literal transcription of Table 3-7 of the
+//     Unicode 16.0 core spec (the well-formed UTF-8 byte sequences);
+//   * `scan` implements D93b, the "maximal subpart of an ill-formed
+//     subsequence" rule that fixes how many U+FFFD a lossy decoder
+//     must emit and where a strict decoder must report the failure;
+//   * `push_scalar` is the closed-form encoder from the same chapter.
+//
+// This matters because the package has two independent implementations
+// selected by target — a hand-written scanner (`decode_nonjs.mbt`) and
+// the platform `TextDecoder`/`TextEncoder` (`decode_js.mbt`) — and both
+// the scanner and the encoder additionally carry `#intrinsic`
+// annotations, so a backend may substitute its own code generation for
+// the MoonBit body entirely. An oracle written against the spec is the
+// only thing all of those must agree with.
+//
+// Coverage is exhaustive where the input space allows (every 1- and
+// 2-byte string, every 3- and 4-byte string over a boundary alphabet)
+// and property-based elsewhere, with generators that deliberately
+// concentrate on near-miss sequences: boundary bytes, scalars at the
+// class edges, and truncated encodings.
+
+// =====================================================================
+// Oracle: Unicode 16.0 core spec, Table 3-7 and D93b.
+// =====================================================================
+
+///|
+/// Table 3-7, transposed to "given the first byte, what is the total
+/// length and what range must the *second* byte lie in". Every byte
+/// after the second is always `80..BF`.
+///
+/// Returns `(length, lo, hi)`, with `length == 0` for a byte that
+/// begins no well-formed sequence at all (`80..C1` and `F5..FF`) and
+/// `length == 1` for ASCII, where the range is unused.
+fn lead_class(b0 : Int) -> (Int, Int, Int) {
+  if b0 <= 0x7F {
+    (1, 0, 0)
+  } else if b0 < 0xC2 {
+    (0, 0, 0) // continuation bytes, and the overlong leads C0/C1
+  } else if b0 <= 0xDF {
+    (2, 0x80, 0xBF)
+  } else if b0 == 0xE0 {
+    (3, 0xA0, 0xBF) // excludes the overlong three-byte forms
+  } else if b0 <= 0xEC {
+    (3, 0x80, 0xBF)
+  } else if b0 == 0xED {
+    (3, 0x80, 0x9F) // excludes the surrogate block D800..DFFF
+  } else if b0 <= 0xEF {
+    (3, 0x80, 0xBF)
+  } else if b0 == 0xF0 {
+    (4, 0x90, 0xBF) // excludes the overlong four-byte forms
+  } else if b0 <= 0xF3 {
+    (4, 0x80, 0xBF)
+  } else if b0 == 0xF4 {
+    (4, 0x80, 0x8F) // excludes everything above U+10FFFF
+  } else {
+    (0, 0, 0) // F5..FF
+  }
+}
+
+///|
+/// Scans one sequence starting at `index`.
+///
+/// Returns `(scalar, consumed)` for a well-formed sequence, or
+/// `(-1, consumed)` when the bytes at `index` are ill-formed — in which
+/// case `consumed` is the length of the *maximal subpart* (D93b): the
+/// longest prefix that is still a prefix of some well-formed sequence,
+/// and at least one byte. That is exactly the quantity a conformant
+/// lossy decoder replaces with a single U+FFFD.
+fn scan(bytes : ArrayView[Byte], index : Int) -> (Int, Int) {
+  let b0 = bytes[index].to_int()
+  let (length, lo, hi) = lead_class(b0)
+  if length == 0 {
+    return (-1, 1)
+  }
+  if length == 1 {
+    return (b0, 1)
+  }
+  for k in 1..= bytes.length() {
+      return (-1, k) // truncated by the end of input
+    }
+    let bk = bytes[index + k].to_int()
+    let (lo, hi) = if k == 1 { (lo, hi) } else { (0x80, 0xBF) }
+    if bk < lo || bk > hi {
+      return (-1, k) // the first k bytes were the maximal subpart
+    }
+  }
+  let mut scalar = if length == 2 {
+    b0 & 0x1F
+  } else if length == 3 {
+    b0 & 0x0F
+  } else {
+    b0 & 0x07
+  }
+  for k in 1.. (Array[Int]?, Int) {
+  let scalars = []
+  let mut index = 0
+  while index < bytes.length() {
+    let (scalar, consumed) = scan(bytes, index)
+    if scalar < 0 {
+      return (None, index)
+    }
+    scalars.push(scalar)
+    index += consumed
+  }
+  (Some(scalars), -1)
+}
+
+///|
+/// The scalars a lossy decoder must produce: one U+FFFD per maximal
+/// subpart of each ill-formed subsequence.
+fn oracle_lossy(bytes : ArrayView[Byte]) -> Array[Int] {
+  let scalars = []
+  let mut index = 0
+  while index < bytes.length() {
+    let (scalar, consumed) = scan(bytes, index)
+    scalars.push(if scalar < 0 { 0xFFFD } else { scalar })
+    index += consumed
+  }
+  scalars
+}
+
+///|
+/// The closed-form UTF-8 encoder for a single scalar.
+fn push_scalar(out : Array[Byte], scalar : Int) -> Unit {
+  if scalar <= 0x7F {
+    out.push(scalar.to_byte())
+  } else if scalar <= 0x7FF {
+    out.push((0xC0 | (scalar >> 6)).to_byte())
+    out.push((0x80 | (scalar & 0x3F)).to_byte())
+  } else if scalar <= 0xFFFF {
+    out.push((0xE0 | (scalar >> 12)).to_byte())
+    out.push((0x80 | ((scalar >> 6) & 0x3F)).to_byte())
+    out.push((0x80 | (scalar & 0x3F)).to_byte())
+  } else {
+    out.push((0xF0 | (scalar >> 18)).to_byte())
+    out.push((0x80 | ((scalar >> 12) & 0x3F)).to_byte())
+    out.push((0x80 | ((scalar >> 6) & 0x3F)).to_byte())
+    out.push((0x80 | (scalar & 0x3F)).to_byte())
+  }
+}
+
+///|
+/// Materializes oracle scalars as a `String`, so results can be
+/// compared with `decode`'s output directly.
+fn scalars_to_string(scalars : Array[Int]) -> String {
+  String::from_array(scalars.map(Int::unsafe_to_char))
+}
+
+///|
+/// What `decode` did, flattened into a value the properties can compare
+/// against the oracle: the decoded text, or the length of the suffix it
+/// reported as `Malformed`.
+priv enum Outcome {
+  Decoded(String)
+  Rejected(Int)
+}
+
+///|
+fn decode_outcome(bytes : BytesView) -> Outcome {
+  try @utf8.decode(bytes) catch {
+    Malformed(suffix) => Rejected(suffix.length())
+  } noraise {
+    text => Decoded(text)
+  }
+}
+
+// =====================================================================
+// Test data.
+// =====================================================================
+
+///|
+/// Every byte value that sits on a boundary of some range in Table 3-7,
+/// plus one value strictly inside each range. A decoder that is wrong
+/// by one anywhere in the table is wrong on one of these.
+let edge_bytes : Array[Byte] = [
+  b'\x00', b'\x41', b'\x7F', b'\x80', b'\x81', b'\x8F', b'\x90', b'\x9F', b'\xA0',
+  b'\xBF', b'\xC0', b'\xC1', b'\xC2', b'\xD0', b'\xDF', b'\xE0', b'\xE1', b'\xEC',
+  b'\xED', b'\xEE', b'\xEF', b'\xF0', b'\xF1', b'\xF3', b'\xF4', b'\xF5', b'\xFF',
+]
+
+///|
+/// Scalars on the boundaries of the 1/2/3/4-byte length classes, around
+/// the surrogate block, and at the U+10FFFF ceiling.
+let edge_scalars : Array[Int] = [
+  0x00, 0x01, 0x7F, 0x80, 0x7FF, 0x800, 0xD7FF, 0xE000, 0xFFFD, 0xFEFF, 0xFFFF, 0x10000,
+  0x3FFFF, 0x40000, 0xFFFFF, 0x100000, 0x10FFFE, 0x10FFFF,
+]
+
+///|
+/// Maps an arbitrary `Int` into `0.. Int {
+  let r = value % modulus
+  if r < 0 {
+    r + modulus
+  } else {
+    r
+  }
+}
+
+///|
+/// An arbitrary scalar value: any code point except the surrogates.
+fn wrap_scalar(value : Int) -> Int {
+  let cp = wrap_index(value, 0x110000 - 0x800)
+  if cp >= 0xD800 {
+    cp + 0x800
+  } else {
+    cp
+  }
+}
+
+///|
+/// Builds a byte string out of `(kind, value)` chunks. The kinds are
+/// chosen so that the generated corpus is dense in *near misses* —
+/// sequences that are one byte or one bit away from well-formed — which
+/// is where a decoder's range checks actually get decided. Purely
+/// random bytes would almost never produce a valid multi-byte sequence
+/// at all, and would exercise only the "reject immediately" path.
+fn build_bytes(chunks : Array[(Int, Int)]) -> Bytes {
+  let out = []
+  for chunk in chunks {
+    let (kind, value) = chunk
+    match wrap_index(kind, 5) {
+      // a byte from the boundary alphabet
+      0 => out.push(edge_bytes[wrap_index(value, edge_bytes.length())])
+      // a well-formed sequence for a boundary scalar
+      1 =>
+        push_scalar(out, edge_scalars[wrap_index(value, edge_scalars.length())])
+      // a well-formed sequence for an arbitrary scalar
+      2 => push_scalar(out, wrap_scalar(value))
+      // a well-formed sequence with its last byte chopped off
+      3 => {
+        let head = []
+        push_scalar(
+          head,
+          edge_scalars[wrap_index(value, edge_scalars.length())],
+        )
+        for i in 0..<(head.length() - 1) {
+          out.push(head[i])
+        }
+      }
+      // an arbitrary raw byte
+      _ => out.push(wrap_index(value, 256).to_byte())
+    }
+  }
+  Bytes::from_array(out)
+}
+
+///|
+/// Renders bytes as hex, so a shrunk counterexample is readable.
+fn hex(bytes : BytesView) -> String {
+  let digits : ReadOnlyArray[Char] = [
+    '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F',
+  ]
+  let out = StringBuilder()
+  for b in bytes {
+    let v = b.to_int()
+    out.write_char(digits[v >> 4])
+    out.write_char(digits[v & 0xF])
+    out.write_char(' ')
+  }
+  out.to_string()
+}
+
+// =====================================================================
+// The specification, checked against the oracle.
+// =====================================================================
+
+///|
+/// `decode` accepts exactly the well-formed strings, returns exactly the
+/// scalars Table 3-7 assigns to them, and — when it rejects — reports a
+/// suffix beginning at the first ill-formed byte.
+fn check_strict(bytes : Bytes) -> Bool {
+  let array = bytes.to_array()
+  let (expected, bad_offset) = oracle_strict(array)
+  match (decode_outcome(bytes), expected) {
+    (Decoded(text), Some(scalars)) => text == scalars_to_string(scalars)
+    // the payload is the input from the first ill-formed byte on
+    (Rejected(length), None) => length == bytes.length() - bad_offset
+    // accepted an ill-formed string, or rejected a well-formed one
+    _ => false
+  }
+}
+
+///|
+/// `decode_lossy` is total, and replaces each maximal subpart of an
+/// ill-formed subsequence with exactly one U+FFFD (D93b).
+fn check_lossy(bytes : Bytes) -> Bool {
+  @utf8.decode_lossy(bytes) == scalars_to_string(oracle_lossy(bytes.to_array()))
+}
+
+///|
+test "quickcheck: decode and decode_lossy match the Table 3-7 oracle" {
+  @quickcheck.check(
+    (chunks : Array[(Int, Int)]) => {
+      let bytes = build_bytes(chunks)
+      check_strict(bytes) && check_lossy(bytes)
+    },
+    counterexample_context=chunks => hex(build_bytes(chunks)),
+    count=2000,
+  )
+}
+
+///|
+test "every one- and two-byte string matches the oracle" {
+  // 256 + 65536 inputs: this settles the whole of Table 3-7's first two
+  // columns exhaustively, including every overlong lead (C0/C1), every
+  // out-of-range lead (F5..FF), every bare continuation byte, and every
+  // truncated two-byte prefix.
+  for b0 in 0..<256 {
+    let one = Bytes::from_array([b0.to_byte()])
+    assert_true(check_strict(one))
+    assert_true(check_lossy(one))
+    for b1 in 0..<256 {
+      let two = Bytes::from_array([b0.to_byte(), b1.to_byte()])
+      assert_true(check_strict(two))
+      assert_true(check_lossy(two))
+    }
+  }
+}
+
+///|
+test "every three- and four-byte string over the boundary alphabet matches the oracle" {
+  // The full 3- and 4-byte spaces are too large to enumerate, but every
+  // range boundary in Table 3-7 is represented in `edge_bytes`, so a
+  // decoder whose bounds are off by one — or which forgets the
+  // surrogate (ED 80..9F) or overlong (E0 A0..BF, F0 90..BF) carve-outs
+  // — fails here.
+  for b0 in edge_bytes {
+    for b1 in edge_bytes {
+      for b2 in edge_bytes {
+        let three = Bytes::from_array([b0, b1, b2])
+        assert_true(check_strict(three))
+        assert_true(check_lossy(three))
+        for b3 in edge_bytes {
+          let four = Bytes::from_array([b0, b1, b2, b3])
+          assert_true(check_strict(four))
+          assert_true(check_lossy(four))
+        }
+      }
+    }
+  }
+}
+
+// =====================================================================
+// Round-trips.
+// =====================================================================
+
+///|
+test "quickcheck: encode then decode is the identity on strings" {
+  @quickcheck.check(
+    (text : String) => @utf8.decode(@utf8.encode(text)) == text,
+    count=1000,
+  )
+}
+
+///|
+test "quickcheck: decode then encode is the identity on accepted bytes" {
+  // UTF-8 is a canonical encoding: each scalar has exactly one
+  // well-formed representation. So `decode` must be injective, which
+  // this round-trip pins — a decoder that accepted an overlong form
+  // would re-encode it to different (shorter) bytes and fail here.
+  @quickcheck.check(
+    (chunks : Array[(Int, Int)]) => {
+      let bytes = build_bytes(chunks)
+      match decode_outcome(bytes) {
+        Decoded(text) => @utf8.encode(text) == bytes
+        Rejected(_) => true
+      }
+    },
+    counterexample_context=chunks => hex(build_bytes(chunks)),
+    count=1000,
+  )
+}
+
+///|
+test "quickcheck: decode_lossy output is always well-formed" {
+  // Whatever `decode_lossy` returns must itself survive a strict
+  // round-trip — a lossy decoder that emitted an unpaired surrogate
+  // would break here.
+  @quickcheck.check(
+    (chunks : Array[(Int, Int)]) => {
+      let text = @utf8.decode_lossy(build_bytes(chunks))
+      @utf8.decode(@utf8.encode(text)) == text
+    },
+    counterexample_context=chunks => hex(build_bytes(chunks)),
+    count=1000,
+  )
+}
+
+///|
+test "quickcheck: decode_lossy agrees with decode whenever decode succeeds" {
+  @quickcheck.check(
+    (chunks : Array[(Int, Int)]) => {
+      let bytes = build_bytes(chunks)
+      match decode_outcome(bytes) {
+        Decoded(text) => @utf8.decode_lossy(bytes) == text
+        Rejected(_) => true
+      }
+    },
+    counterexample_context=chunks => hex(build_bytes(chunks)),
+    count=1000,
+  )
+}
+
+///|
+test "every scalar round-trips, with the byte length Table 3-6 prescribes" {
+  fn expected_length(scalar : Int) -> Int {
+    if scalar <= 0x7F {
+      1
+    } else if scalar <= 0x7FF {
+      2
+    } else if scalar <= 0xFFFF {
+      3
+    } else {
+      4
+    }
+  }
+
+  fn check(scalar : Int) -> Unit raise {
+    let text = String::from_array([scalar.unsafe_to_char()])
+    let bytes = @utf8.encode(text)
+    assert_eq(bytes.length(), expected_length(scalar))
+    assert_eq(
+      bytes,
+      Bytes::from_array(
+        {
+          let o = []
+          push_scalar(o, scalar)
+          o
+        },
+      ),
+    )
+    assert_eq(@utf8.decode(bytes), text)
+    assert_eq(@utf8.decode_lossy(bytes), text)
+  }
+
+  for scalar in edge_scalars {
+    check(scalar)
+  }
+  // A strided sweep of the whole scalar range. The stride is coprime
+  // with every class width, so it lands inside all four length classes
+  // and on both sides of the surrogate block.
+  for scalar = 0; scalar < 0x110000; scalar = scalar + 743 {
+    if scalar < 0xD800 || scalar > 0xDFFF {
+      check(scalar)
+    }
+  }
+}
+
+///|
+test "quickcheck: encoding is a homomorphism from concatenation" {
+  // Decoding has no state that crosses a scalar boundary, so splitting
+  // a string anywhere and encoding the halves separately must give the
+  // same bytes — and the concatenation must decode back to the whole.
+  @quickcheck.check(
+    (input : (String, String)) => {
+      let (a, b) = input
+      let joined = a + b
+      let bytes = @utf8.encode(a) + @utf8.encode(b)
+      @utf8.encode(joined) == bytes && @utf8.decode(bytes) == joined
+    },
+    count=1000,
+  )
+}
+
+// =====================================================================
+// The byte order mark.
+// =====================================================================
+
+///|
+test "quickcheck: bom emission and ignore_bom are inverse" {
+  @quickcheck.check(
+    (text : String) => {
+      let plain = @utf8.encode(text)
+      let marked = @utf8.encode(text, bom=true)
+      // `bom=true` prepends exactly the three-byte BOM
+      marked == b"\xEF\xBB\xBF" + plain &&
+      // `ignore_bom=true` strips it back off, and only ever one
+      @utf8.decode(marked, ignore_bom=true) == text &&
+      @utf8.decode_lossy(marked, ignore_bom=true) == text &&
+      // ...while the default preserves it as U+FEFF
+      @utf8.decode(marked) == "\u{FEFF}" + text &&
+      @utf8.decode_lossy(marked) == "\u{FEFF}" + text
+    },
+    count=1000,
+  )
+}
+
+///|
+test "ignore_bom only strips a leading bom" {
+  let bom = "\u{FEFF}"
+  // a BOM in the middle is data, not a mark
+  let text = "a\u{FEFF}b"
+  assert_eq(@utf8.decode(@utf8.encode(text), ignore_bom=true), text)
+  // two leading BOMs: exactly one is stripped
+  let doubled = bom + bom + "x"
+  assert_eq(@utf8.decode(@utf8.encode(doubled), ignore_bom=true), bom + "x")
+  // the BOM alone
+  assert_eq(@utf8.decode(@utf8.encode(bom), ignore_bom=true), "")
+  assert_eq(@utf8.decode(@utf8.encode(bom)), bom)
+  // an empty input is not a truncated BOM
+  assert_eq(@utf8.decode(b"", ignore_bom=true), "")
+  assert_eq(@utf8.decode_lossy(b"", ignore_bom=true), "")
+  // a truncated BOM is ill-formed, not a mark
+  assert_true(decode_outcome(b"\xEF\xBB") is Rejected(_))
+  assert_eq(@utf8.decode_lossy(b"\xEF\xBB", ignore_bom=true), "\u{FFFD}")
+}
+
+// =====================================================================
+// Truncation.
+// =====================================================================
+
+///|
+test "quickcheck: every proper prefix of a sequence is rejected at its start" {
+  // Chopping the last byte off a well-formed string must fail, and must
+  // point at the start of the sequence that lost the byte — not at the
+  // point where the scanner noticed. This is the offset half of the
+  // `Malformed` contract, which `check_strict` pins only in aggregate.
+  @quickcheck.check(
+    (input : (Int, Int)) => {
+      let (index, _) = input
+      let scalar = edge_scalars[wrap_index(index, edge_scalars.length())]
+      let tail = []
+      push_scalar(tail, scalar)
+      if tail.length() == 1 {
+        return true // ASCII has no proper prefix to truncate
+      }
+      let prefix = @utf8.encode("abc")
+      for drop in 1.. {
+      let bytes = @utf8.encode(input.0)
+      let padded = b"\xFF\xFE\xFD" + bytes + b"\xFF"
+      let view = padded[3:padded.length() - 1]
+      @utf8.decode(view) == input.0 && @utf8.decode_lossy(view) == input.0
+    },
+    count=500,
+  )
+}
diff --git a/env/README.mbt.md b/env/README.mbt.md
index 682a8149bd..7ffbb2b34e 100644
--- a/env/README.mbt.md
+++ b/env/README.mbt.md
@@ -80,27 +80,26 @@ test "working directory" {
 ```mbt check
 ///|
 test "command line tool pattern" {
-  fn parse_command(args : Array[String]) -> Result[String, String] {
+  fn parse_command(args : Array[String]) -> String raise Failure {
     if args.length() < 2 {
-      Err("Usage: program  [args...]")
-    } else {
-      match args[1] {
-        "help" => Ok("Showing help information")
-        "version" => Ok("Version 1.0.0")
-        "status" => Ok("System is running")
-        cmd => Err("Unknown command: " + cmd)
-      }
+      raise Failure("Usage: program  [args...]")
+    }
+    match args[1] {
+      "help" => "Showing help information"
+      "version" => "Version 1.0.0"
+      "status" => "System is running"
+      cmd => raise Failure("Unknown command: \{cmd}")
     }
   }
 
   // Test with mock arguments
   let test_args = ["program", "help"]
   let result = parse_command(test_args)
-  debug_inspect(result, content="Ok(\"Showing help information\")")
-  let invalid_result = parse_command(["program", "invalid"])
-  match invalid_result {
-    Ok(_) => inspect(false, content="true")
-    Err(msg) => inspect(msg.length() > 10, content="true") // Should have error message
+  inspect(result, content="Showing help information")
+  try parse_command(["program", "invalid"]) |> ignore catch {
+    Failure(msg) => inspect(msg, content="Unknown command: invalid")
+  } noraise {
+    _ => fail("expected parse_command to raise on unknown command")
   }
 }
 ```
@@ -161,7 +160,7 @@ The env package behaves differently across platforms:
 
 ### JavaScript Environment
 - `args()` returns arguments from the JavaScript environment
-- `@env.now()` uses `Date.@env.now()` 
+- `@env.now()` uses `Date.now()` 
 - `@env.current_dir()` may return `None` in browser environments
 
 ### WebAssembly Environment  
@@ -195,18 +194,16 @@ test "error handling" {
   fn validate_args(
     args : Array[String],
     min_count : Int,
-  ) -> Result[Unit, String] {
+  ) -> Unit raise Failure {
     if args.length() < min_count {
-      Err("Insufficient arguments: expected at least " + min_count.to_string())
-    } else {
-      Ok(())
+      raise Failure("Insufficient arguments: expected at least \{min_count}")
     }
   }
 
-  let validation = validate_args(["prog"], 2)
-  match validation {
-    Ok(_) => inspect(false, content="true")
-    Err(msg) => inspect(msg.length() > 10, content="true") // Should have error message
+  try validate_args(["prog"], 2) catch {
+    Failure(msg) => inspect(msg.length() > 10, content="true") // Should have error message
+  } noraise {
+    _ => fail("expected validation to fail")
   }
 }
 ```
@@ -237,29 +234,24 @@ test "graceful handling" {
 test "argument validation" {
   fn validate_and_parse_args(
     args : Array[String],
-  ) -> Result[(String, Array[String]), String] {
+  ) -> (String, Array[String]) raise Failure {
     if args.length() == 0 {
-      Err("No program name available")
+      raise Failure("No program name available")
     } else if args.length() == 1 {
-      Ok((args[0], [])) // Program name only, no arguments
+      (args[0], []) // Program name only, no arguments
     } else {
       let program = args[0]
-      let arguments = Array::new()
+      let arguments = Array()
       for i in 1.. {
-      inspect(prog, content="myprogram")
-      inspect(args.length(), content="2")
-    }
-    Err(_) => inspect(false, content="true")
-  }
+  let (prog, args) = validate_and_parse_args(["myprogram", "arg1", "arg2"])
+  inspect(prog, content="myprogram")
+  inspect(args.length(), content="2")
 }
 ```
 
diff --git a/env/env.mbt b/env/env.mbt
index 92b85ccc87..7ebc343ef9 100644
--- a/env/env.mbt
+++ b/env/env.mbt
@@ -79,5 +79,5 @@ pub fn rand(n : Int) -> Bytes? {
 #warnings("-unused_value")
 fn unused_function() -> Unit {
   ignore(@ref.Ref(42))
-  ignore(@utf8.encode(""))
+  let _ : (@os_string.OsString) -> Unit = ignore
 }
diff --git a/env/env_js.mbt b/env/env_js.mbt
index 4b5eac06ca..da81d63588 100644
--- a/env/env_js.mbt
+++ b/env/env_js.mbt
@@ -13,7 +13,12 @@
 // limitations under the License.
 
 ///|
-extern "js" fn get_cli_args_internal() -> Array[String] =
+fn get_cli_args_internal() -> Array[String] {
+  Array::from_fixed_array(get_cli_args_ffi())
+}
+
+///|
+extern "js" fn get_cli_args_ffi() -> FixedArray[String] =
   #| function() {
   #|  if (typeof process !== "undefined" && typeof process.argv !== "undefined") {
   #|    return process.argv;
@@ -78,7 +83,7 @@ fn get_env_vars_internal() -> Map[String, String] {
 }
 
 ///|
-extern "js" fn get_env_vars_array_internal() -> Array[String] =
+extern "js" fn get_env_vars_array_internal() -> FixedArray[String] =
   #| function() {
   #|   if (typeof process === "undefined" || typeof process.env === "undefined") {
   #|     return [];
diff --git a/env/env_native.mbt b/env/env_native.mbt
index 4ae48edd88..70aef4f2b8 100644
--- a/env/env_native.mbt
+++ b/env/env_native.mbt
@@ -13,48 +13,7 @@
 // limitations under the License.
 
 ///|
-/// On Windows, we use native `W` series unicode API,
-/// which return string in UTF-16 directly,
-/// so no need for extra encoding phase here.
-///
-/// Note: technically path etc. may not be invalid UTF-16 on Windows,
-///   but this should be extremely rare in practice.
-///   If we need to fix this case, a validation or encoding phase can be added.
-#cfg(platform="windows")
-priv struct OsString(String)
-
-///|
-/// On non-Windows platforms, path etc. are essentially binary data,
-/// and we assume they contain UTF-8 encoded text here.
-#cfg(not(platform="windows"))
-priv struct OsString(Bytes)
-
-///|
-#cfg(platform="windows")
-fn OsString::to_string(self : OsString) -> String {
-  self.0
-}
-
-///|
-#cfg(not(platform="windows"))
-fn OsString::to_string(self : OsString) -> String {
-  @utf8.decode_lossy(self.0)
-}
-
-///|
-#cfg(platform="windows")
-fn OsString::from_string(str : String) -> OsString {
-  OsString(str)
-}
-
-///|
-#cfg(not(platform="windows"))
-fn OsString::from_string(str : String) -> OsString {
-  OsString(@utf8.encode(str))
-}
-
-///|
-extern "C" fn get_cli_args_ffi() -> FixedArray[OsString] = "moonbit_rt_get_cli_args"
+extern "C" fn get_cli_args_ffi() -> FixedArray[@os_string.OsString] = "moonbit_rt_get_cli_args"
 
 ///|
 fn get_cli_args_internal() -> Array[String] {
@@ -67,7 +26,7 @@ fn get_cli_args_internal() -> Array[String] {
 extern "c" fn now_internal() -> UInt64 = "moonbit_get_ms_since_epoch"
 
 ///|
-extern "c" fn getcwd() -> OsString = "moonbit_rt_get_current_dir"
+extern "c" fn getcwd() -> @os_string.OsString = "moonbit_rt_get_current_dir"
 
 ///|
 fn current_dir_internal() -> String? {
@@ -83,7 +42,7 @@ fn current_dir_internal() -> String? {
 
 ///|
 fn get_env_var_internal(key : String) -> String? {
-  let key = OsString::from_string(key)
+  let key = @os_string.OsString::from_string(key)
   let exists = @ref.Ref(false)
   let value = get_env_var_ffi(key, exists)
   if exists.val {
@@ -96,9 +55,9 @@ fn get_env_var_internal(key : String) -> String? {
 ///|
 #borrow(key, exists)
 extern "c" fn get_env_var_ffi(
-  key : OsString,
+  key : @os_string.OsString,
   exists : @ref.Ref[Bool],
-) -> OsString = "moonbit_rt_get_env_var"
+) -> @os_string.OsString = "moonbit_rt_get_env_var"
 
 ///|
 fn get_env_vars_internal() -> Map[String, String] {
@@ -111,25 +70,31 @@ fn get_env_vars_internal() -> Map[String, String] {
 }
 
 ///|
-extern "c" fn get_env_vars_ffi() -> FixedArray[OsString] = "moonbit_rt_get_env_vars"
+extern "c" fn get_env_vars_ffi() -> FixedArray[@os_string.OsString] = "moonbit_rt_get_env_vars"
 
 ///|
 fn set_env_var_internal(key : String, value : String) -> Unit {
-  set_env_var_ffi(OsString::from_string(key), OsString::from_string(value))
+  set_env_var_ffi(
+    @os_string.OsString::from_string(key),
+    @os_string.OsString::from_string(value),
+  )
 }
 
 ///|
 #borrow(key, value)
-extern "c" fn set_env_var_ffi(key : OsString, value : OsString) -> Unit = "moonbit_rt_set_env_var"
+extern "c" fn set_env_var_ffi(
+  key : @os_string.OsString,
+  value : @os_string.OsString,
+) -> Unit = "moonbit_rt_set_env_var"
 
 ///|
 fn unset_env_var_internal(key : String) -> Unit {
-  unset_env_var_ffi(OsString::from_string(key))
+  unset_env_var_ffi(@os_string.OsString::from_string(key))
 }
 
 ///|
 #borrow(key)
-extern "c" fn unset_env_var_ffi(key : OsString) -> Unit = "moonbit_rt_unset_env_var"
+extern "c" fn unset_env_var_ffi(key : @os_string.OsString) -> Unit = "moonbit_rt_unset_env_var"
 
 ///|
 fn rand_internal(to_be_filled : FixedArray[Byte]) -> Bool {
@@ -139,7 +104,3 @@ fn rand_internal(to_be_filled : FixedArray[Byte]) -> Bool {
 ///|
 #borrow(to_be_filled)
 extern "c" fn moonbit_rt_get_random(to_be_filled : FixedArray[Byte]) -> Int = "moonbit_rt_get_random"
-
-///|
-#cfg(platform="windows")
-let _supressed_unused_warning_windows : Unit = ignore(@utf8.encode(""))
diff --git a/env/moon.pkg b/env/moon.pkg
index 972f264cc8..1ee2b652eb 100644
--- a/env/moon.pkg
+++ b/env/moon.pkg
@@ -1,7 +1,7 @@
 import {
   "moonbitlang/core/builtin",
   "moonbitlang/core/ref",
-  "moonbitlang/core/encoding/utf8",
+  "moonbitlang/core/internal/os_string",
 }
 
 import {
diff --git a/error/README.mbt.md b/error/README.mbt.md
index 94784a2de3..67a33e85e4 100644
--- a/error/README.mbt.md
+++ b/error/README.mbt.md
@@ -152,7 +152,7 @@ test "error propagation" {
 
 ## Resource Management with Finally
 
-Use `protect` functions for resource cleanup:
+Perform cleanup in a `catch` handler before re-raising (or use `defer` for unconditional cleanup):
 
 ```mbt check
 ///|
diff --git a/error/error_test.mbt b/error/error_test.mbt
index d53d4b634b..6ae8cabf53 100644
--- a/error/error_test.mbt
+++ b/error/error_test.mbt
@@ -60,21 +60,8 @@ fn[A, E : Error] protect(
   finalize~ : () -> Unit raise?,
   work : () -> A raise E,
 ) -> A raise E {
-  try work() catch {
-    e => {
-      finalize() catch {
-        _ => ()
-      }
-      raise e
-    }
-  } noraise {
-    x => {
-      finalize() catch {
-        _ => ()
-      }
-      x
-    }
-  }
+  defer (finalize() catch { _ => () })
+  work()
 }
 
 ///|
@@ -84,17 +71,8 @@ fn[A, E : Error] protect(
 /// it is hard to track which error is the original one
 /// in the type system
 fn[A] protect2(finalize~ : () -> Unit raise?, work : () -> A raise) -> A raise {
-  try work() catch {
-    e => {
-      finalize()
-      raise e
-    }
-  } noraise {
-    x => {
-      finalize()
-      x
-    }
-  }
+  defer finalize()
+  work()
 }
 
 ///|
diff --git a/float/README.mbt.md b/float/README.mbt.md
index ebc058ece7..4c851067da 100644
--- a/float/README.mbt.md
+++ b/float/README.mbt.md
@@ -1,6 +1,6 @@
 # MoonBit Float Package Documentation
 
-This package provides operations on 32-bit floating-point numbers (`Float`). It includes basic arithmetic, trigonometric functions, exponential and logarithmic functions, as well as utility functions for rounding and conversion.
+This package provides operations on 32-bit floating-point numbers (`Float`). It includes basic arithmetic, special-value predicates, `sqrt`, as well as utility functions for rounding, comparison and conversion. Trigonometric, exponential and logarithmic functions live in `@math`.
 
 ## Special Values
 
diff --git a/float/float_test.mbt b/float/float_test.mbt
index f9acb2624f..6117781a74 100644
--- a/float/float_test.mbt
+++ b/float/float_test.mbt
@@ -72,7 +72,7 @@ priv struct FloatHashInput {
 
 ///|
 test "Hash" {
-  let a = FloatHashInput::{ a: 1.0 }
+  let a = FloatHashInput::{ a: 1.0, }
   let h1 = Hasher(seed=0)
   h1.combine(a)
   inspect(h1.finalize(), content="1986884538")
diff --git a/float/methods.mbt b/float/methods.mbt
index 11364db9b3..4363b074eb 100644
--- a/float/methods.mbt
+++ b/float/methods.mbt
@@ -330,7 +330,8 @@ pub fn Float::signum(self : Float) -> Float {
 ///
 /// Returns whether the two numbers are considered approximately equal. Returns
 /// `true` if the numbers are exactly equal or if they are within either the
-/// relative or absolute tolerance. Returns `false` if either number is infinite.
+/// relative or absolute tolerance. Returns `false` if the two numbers are not
+/// exactly equal and either of them is infinite.
 ///
 /// Example:
 ///
@@ -394,14 +395,16 @@ pub impl Mod for Float with fn mod(self : Float, other : Float) -> Float {
 ///
 /// # Arguments
 ///
-/// * `start` - The starting value of the range (inclusive).
+/// * `self` - The starting value of the range (inclusive).
 /// * `end` - The ending value of the range (exclusive by default).
 /// * `step` - The step size of the range (default 1.0).
 /// * `inclusive` - Whether the ending value is inclusive (default false).
 ///
 /// # Returns
 ///
-/// Returns an iterator that iterates over the range of Float from `start` to `end - 1`.
+/// Returns an iterator yielding `self`, `self + step`, `self + 2 * step`, ...,
+/// stopping before `end` (or at `end` when `inclusive` is set). Returns an
+/// empty iterator when `step` is `0.0`.
 pub fn Float::until(
   self : Float,
   end : Float,
@@ -409,7 +412,7 @@ pub fn Float::until(
   inclusive? : Bool = false,
 ) -> Iter[Float] {
   if step == 0.0 {
-    return Iter::empty()
+    return [||]
   }
   let mut curr_value = Some(self)
   Iter::new(() => {
@@ -837,7 +840,7 @@ test "Float::reinterpret" {
 /// test {
 ///   let n = 42
 ///   let f = Float::from_int(n)
-///   // Convert back to double for comparison since Float doesn't implement Show
+///   // `Float` implements `Show`, so `f` could also be inspected directly
 ///   inspect(f.to_double(), content="42")
 /// }
 /// ```
@@ -860,7 +863,7 @@ pub fn Float::from_int(self : Int) -> Float = "%i32.to_f32"
 /// test {
 ///   let b = b'\xFF' // 255 in decimal
 ///   let f = Float::from_byte(b)
-///   // Convert to double for comparison since Float doesn't implement Show
+///   // `Float` implements `Show`, so `f` could also be inspected directly
 ///   inspect(f.to_double(), content="255")
 /// }
 /// ```
@@ -975,7 +978,7 @@ test "Float::from_uint64" {
 /// test {
 ///   let n = 42L
 ///   let f = Float::from_int64(n)
-///   // Convert to double for comparison since Float doesn't implement Show
+///   // `Float` implements `Show`, so `f` could also be inspected directly
 ///   inspect(f.to_double(), content="42")
 /// }
 /// ```
diff --git a/hashmap/README.mbt.md b/hashmap/README.mbt.md
index 9b1f98fe5f..91b9650f53 100644
--- a/hashmap/README.mbt.md
+++ b/hashmap/README.mbt.md
@@ -83,7 +83,7 @@ test {
 
 ## Size & Capacity
 
-You can use `size()` to get the number of key-value pairs in the map, or `capacity()` to get the current capacity.
+You can use `length()` to get the number of key-value pairs in the map, or `capacity()` to get the current capacity.
 
 ```mbt check
 ///|
@@ -221,7 +221,7 @@ test {
 ```mbt check
 ///|
 test {
-  let map = @hashmap.from_iter([("a", 1), ("b", 2)].iter())
+  let map = @hashmap.from_iter([|("a", 1), ("b", 2)|])
   @test.assert_eq(map.length(), 2)
 }
 ```
diff --git a/hashmap/extends.mbt b/hashmap/extends.mbt
index 8d6c100104..e412fde097 100644
--- a/hashmap/extends.mbt
+++ b/hashmap/extends.mbt
@@ -24,6 +24,11 @@ pub extend HashMap with Eq::{equal}
 #doc(hidden)
 pub extend HashMap with @debug.Debug::{to_repr}
 
+///|
+#deprecated("Use `@json.from_json` instead", skip_current_package=true)
+#doc(hidden)
+pub extend HashMap with @json.FromJson::{from_json}
+
 ///|
 #deprecated("Use `Default::default` instead", skip_current_package=true)
 #doc(hidden)
@@ -34,11 +39,6 @@ pub extend HashMap with Default::{default}
 #doc(hidden)
 pub extend HashMap with Eq::{not_equal}
 
-///|
-#deprecated("Use `@debug.Debug` instead of `Show` for collections", skip_current_package=true)
-#doc(hidden)
-pub extend HashMap with Show::{output, to_string}
-
 ///|
 #deprecated("Use `@json.to_json` instead", skip_current_package=true)
 #doc(hidden)
diff --git a/hashmap/hashmap.mbt b/hashmap/hashmap.mbt
index 0e600adef5..0967380446 100644
--- a/hashmap/hashmap.mbt
+++ b/hashmap/hashmap.mbt
@@ -12,6 +12,19 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+// SAFETY NOTE for the unchecked probe accesses in this file.
+//
+// `capacity` is always a power of two and at least 1, `capacity_mask` is
+// `capacity - 1`, and `entries.length()` is `capacity`. Every probe index is
+// therefore in bounds: it starts as `hash & capacity_mask` and each step
+// re-masks with `(idx + 1) & capacity_mask`. `grow` installs the new array,
+// capacity and mask together before any rehash probe runs, and `set_with_hash`
+// re-masks from scratch after growing. `shift_back` is the one entry point
+// that can be reached with an index which is not mask-derived -- `retain` in
+// utils.mbt passes a linear one -- so it carries its own proof at its
+// definition. On that basis the probe loops below use `unsafe_get` /
+// `unsafe_set`; the linear iteration sites keep the checked form.
+
 ///|
 let default_init_capacity = 8
 
@@ -119,8 +132,9 @@ fn[K : Eq, V] HashMap::set_with_hash(
   hash : Int,
 ) -> Unit {
   // Only grow when actually inserting a new entry, not when updating existing
+  // SAFETY: masked probe index; see the note at the top of this file.
   for psl = 0, idx = hash & self.capacity_mask {
-    match self.entries[idx] {
+    match self.entries.unsafe_get(idx) {
       None => {
         // Need to insert new entry - check if grow is needed first
         if self.size >= self.capacity / 2 {
@@ -128,8 +142,8 @@ fn[K : Eq, V] HashMap::set_with_hash(
           // Restart search with new capacity_mask
           continue 0, hash & self.capacity_mask
         }
-        let entry = { psl, key, value, hash }
-        self.entries[idx] = Some(entry)
+        let entry = { psl, key, value, hash, }
+        self.entries.unsafe_set(idx, Some(entry))
         self.size += 1
         return
       }
@@ -147,8 +161,8 @@ fn[K : Eq, V] HashMap::set_with_hash(
             continue 0, hash & self.capacity_mask
           }
           self.push_away(idx, curr_entry)
-          let entry = { psl, key, value, hash }
-          self.entries[idx] = Some(entry)
+          let entry = { psl, key, value, hash, }
+          self.entries.unsafe_set(idx, Some(entry))
           self.size += 1
           return
         }
@@ -165,17 +179,18 @@ fn[K, V] HashMap::push_away(
   idx : Int,
   entry : Entry[K, V],
 ) -> Unit {
+  // SAFETY: masked probe index; see the note at the top of this file.
   for psl = entry.psl + 1, idx = (idx + 1) & self.capacity_mask, entry = entry {
-    match self.entries[idx] {
+    match self.entries.unsafe_get(idx) {
       None => {
         entry.psl = psl
-        self.entries[idx] = Some(entry)
+        self.entries.unsafe_set(idx, Some(entry))
         break
       }
       Some(curr_entry) =>
         if psl > curr_entry.psl {
           entry.psl = psl
-          self.entries[idx] = Some(entry)
+          self.entries.unsafe_set(idx, Some(entry))
           continue curr_entry.psl + 1,
             (idx + 1) & self.capacity_mask,
             curr_entry
@@ -208,8 +223,9 @@ fn[K, V] HashMap::push_away(
 pub fn[K : Hash + Eq, V] HashMap::get(self : HashMap[K, V], key : K) -> V? {
   // self.get_with_hash(key, key.hash())
   let hash = Hash::hash(key)
+  // SAFETY: masked probe index; see the note at the top of this file.
   for i = 0, idx = hash & self.capacity_mask {
-    guard self.entries[idx] is Some(entry) else { break None }
+    guard self.entries.unsafe_get(idx) is Some(entry) else { break None }
     if entry.hash == hash && entry.key == key {
       break Some(entry.value)
     }
@@ -244,8 +260,9 @@ pub fn[V] HashMap::get_from_bytes(
   key : BytesView,
 ) -> V? {
   let hash = Hash::hash(key)
+  // SAFETY: masked probe index; see the note at the top of this file.
   for i = 0, idx = hash & self.capacity_mask {
-    guard self.entries[idx] is Some(entry) else { break None }
+    guard self.entries.unsafe_get(idx) is Some(entry) else { break None }
     if entry.hash == hash && key.equal_to_bytes(entry.key) {
       break Some(entry.value)
     }
@@ -280,8 +297,9 @@ pub fn[V] HashMap::get_from_string(
   key : StringView,
 ) -> V? {
   let hash = Hash::hash(key)
+  // SAFETY: masked probe index; see the note at the top of this file.
   for i = 0, idx = hash & self.capacity_mask {
-    guard self.entries[idx] is Some(entry) else { break None }
+    guard self.entries.unsafe_get(idx) is Some(entry) else { break None }
     if entry.hash == hash && key.equal_to_string(entry.key) {
       break Some(entry.value)
     }
@@ -313,8 +331,9 @@ pub fn[V] HashMap::get_from_string(
 #alias("_[_]")
 pub fn[K : Hash + Eq, V] HashMap::at(self : HashMap[K, V], key : K) -> V {
   let hash = Hash::hash(key)
+  // SAFETY: masked probe index; see the note at the top of this file.
   for i = 0, idx = hash & self.capacity_mask {
-    guard! self.entries[idx] is Some(entry)
+    guard! self.entries.unsafe_get(idx) is Some(entry)
     if entry.hash == hash && entry.key == key {
       break entry.value
     }
@@ -354,9 +373,10 @@ pub fn[K : Hash + Eq, V] HashMap::get_or_init(
   init : () -> V,
 ) -> V {
   let hash = Hash::hash(key)
+  // SAFETY: masked probe index; see the note at the top of this file.
   let (idx, psl, new_value, push_away) = for psl = 0, idx = hash &
                                                self.capacity_mask {
-    match self.entries[idx] {
+    match self.entries.unsafe_get(idx) {
       Some(entry) => {
         if entry.hash == hash && entry.key == key {
           return entry.value
@@ -381,8 +401,8 @@ pub fn[K : Hash + Eq, V] HashMap::get_or_init(
     if push_away is Some(entry) {
       self.push_away(idx, entry)
     }
-    let entry = { psl, hash, key, value: new_value }
-    self.entries[idx] = Some(entry)
+    let entry = { psl, hash, key, value: new_value, }
+    self.entries.unsafe_set(idx, Some(entry))
     self.size += 1
   }
   new_value
@@ -445,9 +465,10 @@ pub fn[K : Hash + Eq, V] HashMap::update(
   f : (V?) -> V?,
 ) -> Unit {
   let hash = Hash::hash(key)
+  // SAFETY: masked probe index; see the note at the top of this file.
   let (idx, psl, new_value, push_away) = for psl = 0, idx = hash &
                                                self.capacity_mask {
-    match self.entries[idx] {
+    match self.entries.unsafe_get(idx) {
       Some(entry) => {
         if entry.hash == hash && entry.key == key {
           if f(Some(entry.value)) is Some(new_value) {
@@ -477,7 +498,7 @@ pub fn[K : Hash + Eq, V] HashMap::update(
     if push_away is Some(entry) {
       self.push_away(idx, entry)
     }
-    self.entries[idx] = Some({ psl, hash, key, value: new_value })
+    self.entries.unsafe_set(idx, Some({ psl, hash, key, value: new_value, }))
     self.size += 1
   }
 }
@@ -508,8 +529,9 @@ pub fn[K : Hash + Eq, V] HashMap::update_or_default(
   f : (V) -> V,
 ) -> Unit {
   let hash = Hash::hash(key)
+  // SAFETY: masked probe index; see the note at the top of this file.
   let (idx, psl, push_away) = for psl = 0, idx = hash & self.capacity_mask {
-    match self.entries[idx] {
+    match self.entries.unsafe_get(idx) {
       Some(entry) => {
         if entry.hash == hash && entry.key == key {
           entry.value = f(entry.value)
@@ -530,8 +552,8 @@ pub fn[K : Hash + Eq, V] HashMap::update_or_default(
     if push_away is Some(entry) {
       self.push_away(idx, entry)
     }
-    let entry = { psl, hash, key, value: default }
-    self.entries[idx] = Some(entry)
+    let entry = { psl, hash, key, value: default, }
+    self.entries.unsafe_set(idx, Some(entry))
     self.size += 1
   }
 }
@@ -564,8 +586,9 @@ pub fn[K : Hash + Eq, V] HashMap::get_or_default(
   default : V,
 ) -> V {
   let hash = Hash::hash(key)
+  // SAFETY: masked probe index; see the note at the top of this file.
   for i = 0, idx = hash & self.capacity_mask {
-    guard self.entries[idx] is Some(entry) else { break default }
+    guard self.entries.unsafe_get(idx) is Some(entry) else { break default }
     if entry.hash == hash && entry.key == key {
       break entry.value
     }
@@ -600,8 +623,9 @@ pub fn[K : Hash + Eq, V] HashMap::contains(
   key : K,
 ) -> Bool {
   let hash = Hash::hash(key)
+  // SAFETY: masked probe index; see the note at the top of this file.
   for i = 0, idx = hash & self.capacity_mask {
-    guard self.entries[idx] is Some(entry) else { return false }
+    guard self.entries.unsafe_get(idx) is Some(entry) else { return false }
     if entry.hash == hash && entry.key == key {
       return true
     }
@@ -642,8 +666,9 @@ pub fn[K : Hash + Eq, V : Eq] HashMap::contains_kv(
   value : V,
 ) -> Bool {
   let hash = Hash::hash(key)
+  // SAFETY: masked probe index; see the note at the top of this file.
   for i = 0, idx = hash & self.capacity_mask {
-    guard self.entries[idx] is Some(entry) else { return false }
+    guard self.entries.unsafe_get(idx) is Some(entry) else { return false }
     if entry.hash == hash && entry.key == key && entry.value == value {
       return true
     }
@@ -685,8 +710,9 @@ fn[K : Eq, V] HashMap::remove_with_hash(
   key : K,
   hash : Int,
 ) -> Unit {
+  // SAFETY: masked probe index; see the note at the top of this file.
   for i = 0, idx = hash & self.capacity_mask {
-    match self.entries[idx] {
+    match self.entries.unsafe_get(idx) {
       Some(entry) => {
         if entry.hash == hash && entry.key == key {
           self.shift_back(idx)
@@ -705,16 +731,22 @@ fn[K : Eq, V] HashMap::remove_with_hash(
 
 ///|
 fn[K, V] HashMap::shift_back(self : HashMap[K, V], idx : Int) -> Unit {
+  // SAFETY: the initial `cur` is in bounds by any of the three routes its
+  // callers take -- `update` and `remove_with_hash` pass a masked probe
+  // index, and `retain` (in utils.mbt) reaches here only after its own
+  // checked `entries[i]` read succeeded, with capacity never shrinking.
+  // `next` is re-masked each step, and later `cur` values are previous
+  // `next` values.
   for cur = idx {
     let next = (cur + 1) & self.capacity_mask
-    match self.entries[next] {
+    match self.entries.unsafe_get(next) {
       None | Some({ psl: 0, .. }) => {
-        self.entries[cur] = None
+        self.entries.unsafe_set(cur, None)
         break
       }
       Some(entry) => {
         entry.psl -= 1
-        self.entries[cur] = Some(entry)
+        self.entries.unsafe_set(cur, Some(entry))
         continue next
       }
     }
@@ -742,18 +774,19 @@ fn[K, V] HashMap::rehash_place_entry(
   entry : Entry[K, V],
 ) -> Unit {
   let hash = entry.hash
+  // SAFETY: masked probe index; see the note at the top of this file.
   for psl = 0, idx = hash & self.capacity_mask {
-    match self.entries[idx] {
+    match self.entries.unsafe_get(idx) {
       None => {
         entry.psl = psl
-        self.entries[idx] = Some(entry)
+        self.entries.unsafe_set(idx, Some(entry))
         return
       }
       Some(curr) =>
         if psl > curr.psl {
           self.push_away(idx, curr)
           entry.psl = psl
-          self.entries[idx] = Some(entry)
+          self.entries.unsafe_set(idx, Some(entry))
           return
         } else {
           continue psl + 1, (idx + 1) & self.capacity_mask
@@ -762,19 +795,6 @@ fn[K, V] HashMap::rehash_place_entry(
   }
 }
 
-///|
-/// Creates a new hash map from a fixed array of key-value pairs.
-///
-/// Parameters:
-///
-/// * `pairs` : A fixed array of tuples, where each tuple contains a key of type
-/// `K` and a value of type `V`. The key type must implement both `Eq` and `Hash`
-/// traits.
-///
-/// Returns a new hash map containing all the key-value pairs from the input
-/// array.
-///
-
 ///|
 test "of" {
   let m = from_array([(1, 2), (3, 4)])
@@ -804,8 +824,8 @@ pub fn[K, V, V2] HashMap::map(
     return other
   }
   for i in 0.. HashMap[K, V] {
     return other
   }
   for i in 0.. String {
-  m.to_string()
-}
-
-///|
-test "Show renders a hashmap as a from_array expression" {
-  let m : @hashmap.HashMap[Int, Int] = HashMap([])
-  m.set(1, 10)
-  m.set(2, 20)
-  let shown = show_map(m)
-  assert_true(shown.has_prefix("HashMap::from_array(["))
-  assert_true(shown.has_suffix("])"))
-  // two entries are separated by a comma
-  assert_true(shown.contains(", "))
-  // an empty map still renders the wrapper
-  let e : @hashmap.HashMap[Int, Int] = HashMap([])
-  inspect(show_map(e), content="HashMap::from_array([])")
-}
diff --git a/hashmap/hashmap_test.mbt b/hashmap/hashmap_test.mbt
index 14b39d88d2..bad59abeff 100644
--- a/hashmap/hashmap_test.mbt
+++ b/hashmap/hashmap_test.mbt
@@ -58,9 +58,9 @@ test "get_or_default" {
 ///|
 test "get_or_init" {
   let m : @hashmap.HashMap[String, Array[Int]] = HashMap([])
-  m.get_or_init("a", () => Array::new()).push(1)
-  m.get_or_init("b", () => Array::new()).push(2)
-  m.get_or_init("a", () => Array::new()).push(3)
+  m.get_or_init("a", () => Array()).push(1)
+  m.get_or_init("b", () => Array()).push(2)
+  m.get_or_init("a", () => Array()).push(3)
   @test.assert_eq(m.get("a"), Some([1, 3]))
   @test.assert_eq(m.get("b"), Some([2]))
   @test.assert_eq(m.length(), 2)
@@ -333,7 +333,7 @@ test "get_nonexistent_key_with_psl" {
 
 ///|
 test "from_iter multiple elements iter" {
-  let map = @hashmap.from_iter([(1, 1), (2, 2), (3, 3)].iter())
+  let map = @hashmap.from_iter([|(1, 1), (2, 2), (3, 3)|])
   guard map is { 1: 1, 2: 2, 3: 3, .. } else {
     fail("Map is not expected: \{Repr(map)}")
   }
@@ -343,7 +343,7 @@ test "from_iter multiple elements iter" {
 ///|
 test "from_iter single element iter" {
   debug_inspect(
-    @hashmap.from_iter([(1, 1)].iter()),
+    @hashmap.from_iter([|(1, 1)|]),
     content=(
       #|
     ),
@@ -352,7 +352,7 @@ test "from_iter single element iter" {
 
 ///|
 test "from_iter empty iter" {
-  let map : @hashmap.HashMap[Int, Int] = @hashmap.from_iter(Iter::empty())
+  let map : @hashmap.HashMap[Int, Int] = @hashmap.from_iter([||])
   debug_inspect(
     map,
     content=(
diff --git a/hashmap/json.mbt b/hashmap/json.mbt
index 5652eb719d..8d0255d561 100644
--- a/hashmap/json.mbt
+++ b/hashmap/json.mbt
@@ -23,3 +23,37 @@ pub impl[K : Show, V : ToJson] ToJson for HashMap[K, V] with fn to_json(self) {
     ),
   )
 }
+
+///|
+/// Decodes a `HashMap[String, V]` from a JSON object.
+///
+/// Each key in the JSON object becomes a `String` key in the map, and each
+/// value is decoded using `V`'s `FromJson` implementation.
+///
+/// Example:
+///
+/// ```mbt check
+/// test {
+///   let m : @hashmap.HashMap[String, Int] = @json.from_json({ "a": 1, "b": 2 })
+///   debug_inspect(m.get("a"), content="Some(1)")
+///   debug_inspect(m.get("b"), content="Some(2)")
+/// }
+/// ```
+pub impl[V : @json.FromJson] @json.FromJson for HashMap[String, V] with fn from_json(
+  json,
+  path,
+) {
+  guard json is Object(obj) else {
+    raise JsonDecodeError((path, "@hashmap.from_json: expected object"))
+  }
+  // The object's size is known, so size the table for it once instead of
+  // rehashing as the entries go in.
+  let res : HashMap[String, V] = HashMap(
+    [],
+    capacity=capacity_for_length(obj.length()),
+  )
+  for k, v in obj {
+    res[k] = V::from_json(v, path.add_key(k))
+  }
+  res
+}
diff --git a/hashmap/json_test.mbt b/hashmap/json_test.mbt
new file mode 100644
index 0000000000..c502466a09
--- /dev/null
+++ b/hashmap/json_test.mbt
@@ -0,0 +1,78 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+test "from_json round-trips whatever to_json produced" {
+  for map in (@quickcheck.samples(50) : Array[@hashmap.HashMap[String, Int]]) {
+    let restored : @hashmap.HashMap[String, Int] = @json.from_json(
+      @json.to_json(map),
+    )
+    @debug.assert_eq(restored, map)
+  }
+}
+
+///|
+test "from_json decodes an object" {
+  let restored : @hashmap.HashMap[String, Int] = @json.from_json({
+    "key1": 42,
+    "key2": 100,
+    "key3": -5,
+  })
+  inspect(restored.length(), content="3")
+  debug_inspect(restored.get("key1"), content="Some(42)")
+  debug_inspect(restored.get("key3"), content="Some(-5)")
+  debug_inspect(restored.get("missing"), content="None")
+}
+
+///|
+test "from_json decodes an empty object and nested values" {
+  let empty : @hashmap.HashMap[String, Int] = @json.from_json({})
+  inspect(empty.length(), content="0")
+  let nested : @hashmap.HashMap[String, Array[Int]] = @json.from_json({
+    "a": [1, 2],
+    "b": [],
+  })
+  debug_inspect(nested.get("a"), content="Some([1, 2])")
+  debug_inspect(nested.get("b"), content="Some([])")
+}
+
+///|
+test "from_json rejects a non-object" {
+  let bad : Json = 1
+  try (@json.from_json(bad) : @hashmap.HashMap[String, Int]) catch {
+    err =>
+      inspect(
+        err,
+        content="JsonDecodeError((, @hashmap.from_json: expected object))",
+      )
+  } noraise {
+    _ => fail("expected JsonDecodeError")
+  }
+}
+
+///|
+test "from_json reports the path of the value that failed" {
+  // The decoder extends the path with each key, so a failure inside one
+  // entry has to name that entry rather than the object as a whole.
+  let bad : Json = { "ok": 1, "wrong": "not an int" }
+  try (@json.from_json(bad) : @hashmap.HashMap[String, Int]) catch {
+    err =>
+      inspect(
+        err,
+        content="JsonDecodeError((/wrong, Int::from_json: expected number))",
+      )
+  } noraise {
+    _ => fail("expected JsonDecodeError")
+  }
+}
diff --git a/hashmap/moon.pkg b/hashmap/moon.pkg
index 1580cbb982..d1416bd5f9 100644
--- a/hashmap/moon.pkg
+++ b/hashmap/moon.pkg
@@ -4,10 +4,12 @@ import {
   "moonbitlang/core/debug",
   "moonbitlang/core/test",
   "moonbitlang/core/int",
+  "moonbitlang/core/json",
 }
 
 import {
   "moonbitlang/core/test",
   "moonbitlang/core/json",
   "moonbitlang/core/quickcheck",
+  "moonbitlang/core/bench",
 } for "test"
diff --git a/hashmap/pkg.generated.mbti b/hashmap/pkg.generated.mbti
index 018cbf63e9..c1b77ef829 100644
--- a/hashmap/pkg.generated.mbti
+++ b/hashmap/pkg.generated.mbti
@@ -3,6 +3,7 @@ package "moonbitlang/core/hashmap"
 
 import {
   "moonbitlang/core/debug",
+  "moonbitlang/core/json",
 }
 
 // Values
@@ -60,10 +61,9 @@ pub fn[K : Hash + Eq, V] HashMap::update_or_default(Self[K, V], K, V, (V) -> V)
 pub fn[K, V] HashMap::values(Self[K, V]) -> Iter[V]
 pub impl[K, V] Default for HashMap[K, V]
 pub impl[K : Hash + Eq, V : Eq] Eq for HashMap[K, V]
-#deprecated
-pub impl[K : Show, V : Show] Show for HashMap[K, V]
 pub impl[K : Show, V : ToJson] ToJson for HashMap[K, V]
 pub impl[K : @debug.Debug, V : @debug.Debug] @debug.Debug for HashMap[K, V]
+pub impl[V : @json.FromJson] @json.FromJson for HashMap[String, V]
 
 // Type aliases
 
diff --git a/hashmap/types.mbt b/hashmap/types.mbt
index 54fc365663..91a3b36609 100644
--- a/hashmap/types.mbt
+++ b/hashmap/types.mbt
@@ -13,7 +13,6 @@
 // limitations under the License.
 
 ///|
-#warnings("-deprecated_syntax")
 priv struct Entry[K, V] {
   mut psl : Int
   hash : Int
diff --git a/hashmap/utils.mbt b/hashmap/utils.mbt
index 5ba0bd2205..1ff349ac8c 100644
--- a/hashmap/utils.mbt
+++ b/hashmap/utils.mbt
@@ -122,7 +122,7 @@ pub fn[K, V] HashMap::iter2(self : HashMap[K, V]) -> Iter2[K, V] {
 ///
 /// ```mbt check
 /// test {
-///   let iter = Iter::singleton((1, "one")) + Iter::singleton((2, "two"))
+///   let iter = [|(1, "one"), (2, "two")|]
 ///   let map = @hashmap.from_iter(iter)
 ///   debug_inspect(map.get(1), content="Some(\"one\")")
 ///   debug_inspect(map.get(2), content="Some(\"two\")")
@@ -167,24 +167,19 @@ pub fn[K : Hash + Eq, V] HashMap::from_iter(
 /// }
 /// ```
 pub fn[K, V] HashMap::to_array(self : HashMap[K, V]) -> Array[(K, V)] {
-  let mut i = 0
-  let res = while i < self.capacity {
-    if self.entries[i] is Some({ key, value, .. }) {
-      i += 1
-      break Array::make(self.size, (key, value))
-    }
-    i += 1
-  } nobreak {
-    []
+  if self.size == 0 {
+    return []
   }
-  if !res.is_empty() {
-    let mut res_idx = 1
-    while res_idx < res.length() && i < self.capacity {
-      if self.entries[i] is Some({ key, value, .. }) {
-        res[res_idx] = (key, value)
-        res_idx += 1
+  let res = Array::unsafe_make_uninit(self.size)
+  let mut n = 0
+  for i in 0.. Int {
 }
 
 ///|
-/// Returns the current capacity of the hash map. The capacity is the number of
-/// key-value pairs the hash map can hold before it needs to reallocate its
-/// internal storage.
+/// Returns the current capacity of the hash map, that is, the length of its
+/// internal storage array. The map grows once it is half full, so it can hold
+/// at most `capacity / 2` key-value pairs before it reallocates.
 ///
 /// Parameters:
 ///
 /// * `map` : The hash map whose capacity is to be queried.
 ///
-/// Returns the number of key-value pairs that can be stored in the hash map
-/// before triggering a reallocation.
+/// Returns the length of the hash map's internal storage array.
 ///
 /// Example:
 ///
@@ -329,34 +323,6 @@ pub fn[K, V] HashMap::eachi(
   }
 }
 
-///|
-/// Provides string representation for hash maps.
-/// 
-/// Notice that the order of key-value pairs in the output string is not guaranteed
-///
-/// Parameters:
-///
-/// * `self` : The hash map to be converted to string.
-/// * `logger` : The buffer to write the string representation to.
-#deprecated("Use @debug.Debug instead of Show for debugging purposes. See https://github.com/moonbitlang/core/blob/main/debug/README.mbt.md")
-pub impl[K : Show, V : Show] Show for HashMap[K, V]
-
-///|
-pub impl[K : Show, V : Show] Show for HashMap[K, V] with fn output(self, logger) {
-  logger.write_string("HashMap::from_array([")
-  self.eachi((i, k, v) => {
-    if i > 0 {
-      logger.write_string(", ")
-    }
-    logger.write_string("(")
-    logger.write_object(k)
-    logger.write_string(", ")
-    logger.write_object(v)
-    logger.write_string(")")
-  })
-  logger.write_string("])")
-}
-
 ///|
 /// Returns an iterator over all keys in the hash map.
 ///
@@ -488,14 +454,14 @@ test "retain" {
     capacity_mask: 7,
     size: 4,
     entries: [
-      Some({ psl: 2, hash: 448974246, key: "c", value: 3 }),
-      Some({ psl: 2, hash: -136509641, key: "d", value: 4 }),
+      Some({ psl: 2, hash: 448974246, key: "c", value: 3, }),
+      Some({ psl: 2, hash: -136509641, key: "d", value: 4, }),
       None,
       None,
       None,
       None,
-      Some({ psl: 0, hash: 1614946358, key: "a", value: 1 }),
-      Some({ psl: 1, hash: -765931946, key: "b", value: 2 }),
+      Some({ psl: 0, hash: 1614946358, key: "a", value: 1, }),
+      Some({ psl: 1, hash: -765931946, key: "b", value: 2, }),
     ],
   }
   hashmap.retain((_k, v) => v % 2 == 0)
diff --git a/hashset/README.mbt.md b/hashset/README.mbt.md
index 560e16df66..a228717639 100644
--- a/hashset/README.mbt.md
+++ b/hashset/README.mbt.md
@@ -18,7 +18,7 @@ test {
 
 ## Insert & Contain
 
-You can use `insert()` to add a key to the set, and `contains()` to check whether a key exists.
+You can use `add()` to add a key to the set, and `contains()` to check whether a key exists.
 
 ```mbt check
 ///|
@@ -44,7 +44,7 @@ test {
 
 ## Size & Capacity
 
-You can use `size()` to get the number of keys in the set, or `capacity()` to get the current capacity.
+You can use `length()` to get the number of keys in the set, or `capacity()` to get the current capacity.
 
 ```mbt check
 ///|
@@ -149,7 +149,7 @@ test {
   arr.sort()
   @test.assert_eq(arr, [1, 2, 3])
   // from_iter
-  let set2 = @hashset.from_iter([4, 5, 6].iter())
+  let set2 = @hashset.from_iter([|4, 5, 6|])
   @test.assert_eq(set2.length(), 3)
 }
 ```
diff --git a/hashset/copy_test.mbt b/hashset/copy_test.mbt
new file mode 100644
index 0000000000..60fbe1c7e6
--- /dev/null
+++ b/hashset/copy_test.mbt
@@ -0,0 +1,96 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+/// A key whose hash maps every value into one of only 8 buckets, so
+/// insertions collide heavily and deterministically -- independent of the
+/// runtime hash seed, which is randomized on some targets. Without this,
+/// whether the test reaches `shift_back` at all would depend on how `Int`
+/// hashing happens to mix, and the regression could go unnoticed.
+priv struct Collide(Int) derive(Eq)
+
+///|
+impl Hash for Collide with fn hash(self) {
+  let Collide(x) = self
+  x % 8
+}
+
+///|
+impl Hash for Collide with fn hash_combine(self, hasher) {
+  let Collide(x) = self
+  hasher.combine_int(x % 8)
+}
+
+///|
+/// `copy` used to blit the entry references, leaving both sets sharing
+/// `Entry` objects. Because `Entry::psl` is mutable and `shift_back`
+/// decrements it, a removal on either set silently corrupted the other's
+/// probe sequences.
+test "HashSet::copy is independent of the original" {
+  let original = @hashset.HashSet([])
+  for i in 0..<32 {
+    original.add(Collide(i))
+  }
+  let duplicate = original.copy()
+  for i in 0..<32 {
+    duplicate.remove(Collide(i))
+  }
+  inspect(duplicate.length(), content="0")
+  inspect(original.length(), content="32")
+  let mut still_present = 0
+  for i in 0..<32 {
+    if original.contains(Collide(i)) {
+      still_present += 1
+    }
+  }
+  inspect(still_present, content="32")
+}
+
+///|
+/// The other direction, and through insertion rather than removal: adding to
+/// a copy can displace a shared entry and move it in the original's table.
+test "HashSet::copy leaves the original unaffected by later insertions" {
+  let original = @hashset.HashSet([])
+  for i in 0..<16 {
+    original.add(Collide(i))
+  }
+  // Only a few insertions, deliberately: enough to displace shared entries
+  // through `push_away`, but not enough to trigger a `grow` -- growth
+  // reassigns every PSL and would mask the corruption.
+  let duplicate = original.copy()
+  for i in 16..<20 {
+    duplicate.add(Collide(i))
+  }
+  let mut still_present = 0
+  for i in 0..<16 {
+    if original.contains(Collide(i)) {
+      still_present += 1
+    }
+  }
+  inspect(still_present, content="16")
+  inspect(original.length(), content="16")
+  inspect(original.contains(Collide(18)), content="false")
+  inspect(duplicate.contains(Collide(18)), content="true")
+}
+
+///|
+test "HashSet::copy on an empty set" {
+  let empty : Array[Collide] = []
+  let original = @hashset.HashSet(empty)
+  let duplicate = original.copy()
+  inspect(duplicate.length(), content="0")
+  duplicate.add(Collide(1))
+  inspect(original.length(), content="0")
+  inspect(duplicate.length(), content="1")
+}
diff --git a/hashset/hashset.mbt b/hashset/hashset.mbt
index e9d9f6340e..7cbe1fe131 100644
--- a/hashset/hashset.mbt
+++ b/hashset/hashset.mbt
@@ -94,7 +94,9 @@ fn[K : Eq] HashSet::add_with_hash(
     self.grow()
   }
   let (idx, psl) = for psl = 0, idx = hash & self.capacity_mask {
-    match self.entries[idx] {
+    // SAFETY: `idx` starts masked by `capacity_mask` and every step re-masks
+    // it, so it is in bounds for `entries`, whose length is `capacity`.
+    match self.entries.unsafe_get(idx) {
       None => break (idx, psl)
       Some(curr_entry) => {
         if curr_entry.hash == hash && curr_entry.key == key {
@@ -108,7 +110,7 @@ fn[K : Eq] HashSet::add_with_hash(
       }
     }
   }
-  let entry = { psl, key, hash }
+  let entry = { psl, key, hash, }
   self.set_entry(entry, idx)
   self.size += 1
 }
@@ -121,7 +123,8 @@ fn[K] HashSet::push_away(
   entry : Entry[K],
 ) -> Unit {
   for psl = entry.psl + 1, idx = (idx + 1) & self.capacity_mask, entry = entry {
-    match self.entries[idx] {
+    // SAFETY: same masked-index invariant as `add_with_hash`.
+    match self.entries.unsafe_get(idx) {
       None => {
         entry.psl = psl
         self.set_entry(entry, idx)
@@ -149,7 +152,9 @@ fn[K] HashSet::set_entry(
   entry : Entry[K],
   new_idx : Int,
 ) -> Unit {
-  self.entries[new_idx] = Some(entry)
+  // SAFETY: every caller supplies an index already proven in bounds -- from
+  // a masked probe, or from `shift_back`'s `cur`.
+  self.entries.unsafe_set(new_idx, Some(entry))
 }
 
 ///|
@@ -158,7 +163,8 @@ pub fn[K : Hash + Eq] HashSet::contains(self : HashSet[K], key : K) -> Bool {
   // inline lookup to avoid unnecessary allocations
   let hash = Hash::hash(key)
   for i = 0, idx = hash & self.capacity_mask {
-    guard self.entries[idx] is Some(entry) else { break false }
+    // SAFETY: same masked-index invariant as `add_with_hash`.
+    guard self.entries.unsafe_get(idx) is Some(entry) else { break false }
     if entry.hash == hash && entry.key == key {
       break true
     }
@@ -193,7 +199,8 @@ pub fn[K : Hash + Eq] HashSet::contains(self : HashSet[K], key : K) -> Bool {
 pub fn[K : Hash + Eq] HashSet::remove(self : HashSet[K], key : K) -> Unit {
   let hash = Hash::hash(key)
   for i = 0, idx = hash & self.capacity_mask {
-    guard self.entries[idx] is Some(entry) else { break }
+    // SAFETY: same masked-index invariant as `add_with_hash`.
+    guard self.entries.unsafe_get(idx) is Some(entry) else { break }
     if entry.hash == hash && entry.key == key {
       self.shift_back(idx)
       self.size -= 1
@@ -210,9 +217,13 @@ pub fn[K : Hash + Eq] HashSet::remove(self : HashSet[K], key : K) -> Unit {
 fn[K] HashSet::shift_back(self : HashSet[K], idx : Int) -> Unit {
   for cur = idx {
     let next = (cur + 1) & self.capacity_mask
-    match self.entries[next] {
+    // SAFETY: the initial `cur` is in bounds by either route its callers
+    // take -- `remove` derives it from a masked probe, and `retain` reaches
+    // here only after a checked `entries[i]` read succeeded. `next` is
+    // re-masked each step, and `cur` afterwards is a previous `next`.
+    match self.entries.unsafe_get(next) {
       None | Some({ psl: 0, .. }) => {
-        self.entries[cur] = None
+        self.entries.unsafe_set(cur, None)
         break
       }
       Some(entry) => {
@@ -253,7 +264,9 @@ fn[K] HashSet::grow(self : HashSet[K]) -> Unit {
 fn[K] HashSet::rehash_place_entry(self : HashSet[K], entry : Entry[K]) -> Unit {
   let hash = entry.hash
   for psl = 0, idx = hash & self.capacity_mask {
-    match self.entries[idx] {
+    // SAFETY: same masked-index invariant as `add_with_hash`; `grow` installs
+    // the new `entries` and `capacity_mask` together before rehashing.
+    match self.entries.unsafe_get(idx) {
       None => {
         entry.psl = psl
         self.set_entry(entry, idx)
@@ -704,7 +717,14 @@ pub fn[K] HashSet::copy(self : HashSet[K]) -> HashSet[K] {
     capacity_mask: self.capacity_mask,
     grow_at: self.grow_at,
   }
-  self.entries.blit_to(other.entries, len=self.capacity)
+  // Rebuild each entry rather than blitting the references: `Entry::psl` is
+  // mutable and `shift_back` decrements it, so sharing entries would let a
+  // removal on one set corrupt the probe sequences of the other.
+  for i in 0..
     ),
@@ -409,7 +409,7 @@ test "from_iter single element iter" {
 
 ///|
 test "from_iter empty iter" {
-  let map : @hashset.HashSet[Int] = @hashset.from_iter(Iter::empty())
+  let map : @hashset.HashSet[Int] = @hashset.from_iter([||])
   debug_inspect(
     map,
     content=(
diff --git a/hashset/moon.pkg b/hashset/moon.pkg
index 4a05c3d617..f0aa748704 100644
--- a/hashset/moon.pkg
+++ b/hashset/moon.pkg
@@ -10,4 +10,5 @@ import {
   "moonbitlang/core/int",
   "moonbitlang/core/json",
   "moonbitlang/core/quickcheck",
+  "moonbitlang/core/bench",
 } for "test"
diff --git a/hashset/types.mbt b/hashset/types.mbt
index 9c47f41758..b21dce8901 100644
--- a/hashset/types.mbt
+++ b/hashset/types.mbt
@@ -13,7 +13,6 @@
 // limitations under the License.
 
 ///|
-#warnings("-deprecated_syntax")
 priv struct Entry[K] {
   mut psl : Int
   hash : Int
diff --git a/immut/hashmap/HAMT.mbt b/immut/hashmap/HAMT.mbt
index f98a2ea6b9..72a4302f29 100644
--- a/immut/hashmap/HAMT.mbt
+++ b/immut/hashmap/HAMT.mbt
@@ -47,7 +47,7 @@ let bulk_build_threshold = 64
 /// Create a new instance.
 #as_free_fn
 pub fn[K, V] HashMap::new() -> HashMap[K, V] {
-  { data: None }
+  { data: None, }
 }
 
 ///|
@@ -55,7 +55,7 @@ pub fn[K, V] HashMap::new() -> HashMap[K, V] {
 #as_free_fn
 #owned(key, value)
 pub fn[K : Hash, V] HashMap::singleton(key : K, value : V) -> HashMap[K, V] {
-  { data: Some(Flat(key, value, @path.of(key))) }
+  { data: Some(Flat(key, value, @path.of(key))), }
 }
 
 ///|
@@ -79,6 +79,8 @@ pub fn[K : Eq + Hash, V] HashMap::get(self : HashMap[K, V], key : K) -> V? {
 
 ///|
 /// Get value with `at` access semantics.
+/// Aborts if the key is not present; use `get` for the `Option`-returning
+/// version.
 #alias("_[_]")
 pub fn[K : Eq + Hash, V] HashMap::at(self : HashMap[K, V], key : K) -> V {
   guard! self.data is Some(node)
@@ -331,7 +333,7 @@ fn[K : Eq + Hash, V] hash_map_from_array(
   let entries = Array::makei(arr.length(), i => {
     let kv = arr[i]
     let (k, v) = kv
-    { key: k, value: v, path: @path.of(k) }
+    { key: k, value: v, path: @path.of(k), }
   })
   {
     data: Some(build_hashmap_node_range(entries, 0, entries.length(), 0, true)),
@@ -796,9 +798,9 @@ pub fn[K : Eq, V] HashMap::difference(
   }
 
   match (self.data, other.data) {
-    (None, _) => { data: None }
+    (None, _) => { data: None, }
     (_, None) => self
-    (Some(a), Some(b)) => { data: go(a, b) }
+    (Some(a), Some(b)) => { data: go(a, b), }
   }
 }
 
@@ -900,10 +902,10 @@ pub fn[K : Eq + Hash, V] HashMap::from_iter(
     return hash_map_from_iter_by_add(iter)
   }
   let entries = match iter.size_hint() {
-    Some(len) => Array::new(capacity=len)
+    Some(len) => Array(capacity=len)
     None => []
   }
-  iter.each(e => entries.push({ key: e.0, value: e.1, path: @path.of(e.0) }))
+  iter.each(e => entries.push({ key: e.0, value: e.1, path: @path.of(e.0), }))
   if entries.is_empty() {
     new()
   } else {
diff --git a/immut/hashmap/HAMT_test.mbt b/immut/hashmap/HAMT_test.mbt
index 66464f1a8a..ef6eb78a52 100644
--- a/immut/hashmap/HAMT_test.mbt
+++ b/immut/hashmap/HAMT_test.mbt
@@ -163,7 +163,7 @@ test "HAMT::iter2 with early break" {
 
 ///|
 test "HAMT::from_iter" {
-  let iter = [(1, "one"), (2, "two"), (3, "three")].iter()
+  let iter = [|(1, "one"), (2, "two"), (3, "three")|]
   let map = @hashmap.from_iter(iter)
   @test.assert_eq(map.get(1), Some("one"))
   @test.assert_eq(map.get(2), Some("two"))
@@ -172,7 +172,7 @@ test "HAMT::from_iter" {
 
 ///|
 test "HAMT::from_iter empty" {
-  let map : @hashmap.HashMap[Int, Int] = @hashmap.from_iter(Iter::empty())
+  let map : @hashmap.HashMap[Int, Int] = @hashmap.from_iter([||])
   assert_eq(map.length(), 0)
 }
 
@@ -225,7 +225,7 @@ test "HAMT::from_array duplicate keeps first value" {
 
 ///|
 test "HAMT::from_iter duplicate keeps last value" {
-  let map = @hashmap.from_iter([(1, "first"), (1, "second")].iter())
+  let map = @hashmap.from_iter([|(1, "first"), (1, "second")|])
   @test.assert_eq(map.get(1), Some("second"))
 }
 
diff --git a/immut/hashmap/types.mbt b/immut/hashmap/types.mbt
index 64ba4ba273..fac53a28a4 100644
--- a/immut/hashmap/types.mbt
+++ b/immut/hashmap/types.mbt
@@ -13,7 +13,7 @@
 // limitations under the License.
 
 ///|
-/// An non-empty immutable hash set data structure
+/// A non-empty immutable hash map data structure
 #unsafe_cycle_free
 priv enum Node[K, V] {
   /// a subtree holding exactly one entry, at any depth; carries the
diff --git a/immut/hashset/HAMT.mbt b/immut/hashset/HAMT.mbt
index 1b159a76d0..796f91a51c 100644
--- a/immut/hashset/HAMT.mbt
+++ b/immut/hashset/HAMT.mbt
@@ -40,7 +40,7 @@ let bulk_build_threshold = 64
 /// Create a new instance.
 #as_free_fn
 pub fn[A] HashSet::new() -> HashSet[A] {
-  { data: None }
+  { data: None, }
 }
 
 ///|
@@ -274,7 +274,7 @@ fn[A : Eq + Hash] hash_set_from_array(arr : ArrayView[A]) -> HashSet[A] {
   }
   let entries = Array::makei(arr.length(), i => {
     let value = arr[i]
-    { value, path: @path.of(value) }
+    { value, path: @path.of(value), }
   })
   {
     data: Some(build_hashset_node_range(entries, 0, entries.length(), 0, true)),
@@ -496,9 +496,9 @@ pub fn[K : Eq] HashSet::difference(
   }
 
   match (self.data, other.data) {
-    (None, _) => { data: None }
+    (None, _) => { data: None, }
     (_, None) => self
-    (Some(a), Some(b)) => { data: go(a, b) }
+    (Some(a), Some(b)) => { data: go(a, b), }
   }
 }
 
@@ -590,10 +590,10 @@ pub fn[A : Eq + Hash] HashSet::from_iter(iter : Iter[A]) -> HashSet[A] {
     return hash_set_from_iter_by_add(iter)
   }
   let entries = match iter.size_hint() {
-    Some(len) => Array::new(capacity=len)
+    Some(len) => Array(capacity=len)
     None => []
   }
-  iter.each(value => entries.push({ value, path: @path.of(value) }))
+  iter.each(value => entries.push({ value, path: @path.of(value), }))
   if entries.is_empty() {
     new()
   } else {
diff --git a/immut/hashset/HAMT_test.mbt b/immut/hashset/HAMT_test.mbt
index 28d88734b8..44ae5b4c65 100644
--- a/immut/hashset/HAMT_test.mbt
+++ b/immut/hashset/HAMT_test.mbt
@@ -140,7 +140,7 @@ test "from_iter multiple elements iter" {
 ///|
 test "from_iter single element iter" {
   debug_inspect(
-    @hashset.from_iter([1].iter()),
+    @hashset.from_iter([|1|]),
     content=(
       #|
     ),
@@ -149,7 +149,7 @@ test "from_iter single element iter" {
 
 ///|
 test "from_iter empty iter" {
-  let pq : @hashset.HashSet[Int] = @hashset.from_iter(Iter::empty())
+  let pq : @hashset.HashSet[Int] = @hashset.from_iter([||])
   debug_inspect(
     pq,
     content=(
@@ -347,9 +347,7 @@ test "from_array duplicate keeps last representative" {
 
 ///|
 test "from_iter duplicate keeps first representative" {
-  let set = @hashset.from_iter(
-    [SameKey(1, "first"), SameKey(1, "second")].iter(),
-  )
+  let set = @hashset.from_iter([|SameKey(1, "first"), SameKey(1, "second")|])
   let values = set.iter().to_array()
   @test.assert_eq(values.length(), 1)
   let SameKey(_, label) = values[0]
diff --git a/immut/hashset/README.mbt.md b/immut/hashset/README.mbt.md
index c27a871ce3..d12611389a 100644
--- a/immut/hashset/README.mbt.md
+++ b/immut/hashset/README.mbt.md
@@ -18,12 +18,12 @@ test "creating immutable sets" {
   let from_array_result = @hashset.HashSet([1, 2, 3, 2, 1]) // Duplicates removed
   inspect(from_array_result.length(), content="3")
 
-  // From fixed array
+  // From an array of distinct values
   let from_fixed = @hashset.HashSet([10, 20, 30])
   inspect(from_fixed.length(), content="3")
 
   // From iterator
-  let from_iter = @hashset.from_iter([40, 50, 60].iter())
+  let from_iter = @hashset.from_iter([|40, 50, 60|])
   inspect(from_iter.length(), content="3")
 }
 ```
diff --git a/immut/hashset/types.mbt b/immut/hashset/types.mbt
index 304867b136..07845566af 100644
--- a/immut/hashset/types.mbt
+++ b/immut/hashset/types.mbt
@@ -13,7 +13,7 @@
 // limitations under the License.
 
 ///|
-/// An non-empty immutable hash set data structure
+/// A non-empty immutable hash set data structure
 #unsafe_cycle_free
 priv enum Node[A] {
   /// a subtree holding exactly one element, at any depth; carries the
diff --git a/immut/internal/path/extends.mbt b/immut/internal/path/extends.mbt
index d539300e1c..a42cb2cdef 100644
--- a/immut/internal/path/extends.mbt
+++ b/immut/internal/path/extends.mbt
@@ -12,8 +12,6 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-// Every trait-method promotion below is deprecated (none are kept as regular methods).
-
 // --- promoted: kept as regular methods ---
 
 ///|
diff --git a/immut/internal/path/path.mbt b/immut/internal/path/path.mbt
index f67fb7b072..0339eb40fa 100644
--- a/immut/internal/path/path.mbt
+++ b/immut/internal/path/path.mbt
@@ -50,7 +50,8 @@ pub fn[A : Hash] of(key : A) -> Path {
 const MAX_TAIL : UInt = 0xffffffffU >> (SEGMENT_LENGTH * (SEGMENT_NUM - 1))
 
 ///|
-/// Returns if the path contains only a single segment.
+/// Returns if at most one index segment remains in the path. This also holds
+/// for the exhausted path, which has no segment left.
 pub fn Path::is_last(self : Path) -> Bool {
   let Path(self) = self
   self <= MAX_TAIL
diff --git a/immut/internal/sparse_array/sparse_array.mbt b/immut/internal/sparse_array/sparse_array.mbt
index e108143e1a..1d654df64c 100644
--- a/immut/internal/sparse_array/sparse_array.mbt
+++ b/immut/internal/sparse_array/sparse_array.mbt
@@ -24,14 +24,14 @@ pub struct SparseArray[X] {
 ///|
 /// Return an empty value.
 pub fn[X] empty() -> SparseArray[X] {
-  { elem_info: empty_bitset, data: [] }
+  { elem_info: empty_bitset, data: [], }
 }
 
 ///|
 /// Create a singleton value.
 #owned(value)
 pub fn[X] singleton(idx : Int, value : X) -> SparseArray[X] {
-  { elem_info: empty_bitset.add(idx), data: [value] }
+  { elem_info: empty_bitset.add(idx), data: [value], }
 }
 
 ///|
@@ -82,7 +82,7 @@ pub fn[X] from_sorted_fixed_array(
   for idx in indices {
     elem_info = elem_info.add(idx)
   }
-  { elem_info, data }
+  { elem_info, data, }
 }
 
 ///|
@@ -112,7 +112,7 @@ pub fn[X] SparseArray::add(
     pos_of_new_item,
     old_len - pos_of_new_item,
   )
-  { elem_info: self.elem_info.add(idx), data: new_data }
+  { elem_info: self.elem_info.add(idx), data: new_data, }
 }
 
 ///|
@@ -138,7 +138,7 @@ pub fn[X] SparseArray::remove(
     pos_of_removed_item + 1,
     old_len - pos_of_removed_item - 1,
   )
-  { elem_info: self.elem_info.remove(idx), data: new_data }
+  { elem_info: self.elem_info.remove(idx), data: new_data, }
 }
 
 ///|
@@ -162,7 +162,7 @@ pub fn[X] SparseArray::union(
     }
     continue rest.remove(idx), index + 1
   }
-  { elem_info: union_elem_info, data }
+  { elem_info: union_elem_info, data, }
 }
 
 ///|
@@ -196,15 +196,16 @@ pub fn[X] SparseArray::intersection(
     if elem_info == empty_bitset {
       None
     } else if elem_info == inter_elem_info {
-      Some({ elem_info, data })
+      Some({ elem_info, data, })
     } else {
-      Some({ elem_info, data: data.copy_prefix(len=index) })
+      Some({ elem_info, data: data.copy_prefix(len=index), })
     }
   }
 }
 
 ///|
-/// Keep indices and values only present in self but not in other.
+/// Keep every index of self: an index absent from other keeps its value, an
+/// index present in both keeps f's result, or is dropped when f returns None.
 pub fn[X] SparseArray::difference(
   self : SparseArray[X],
   other : SparseArray[X],
@@ -233,9 +234,9 @@ pub fn[X] SparseArray::difference(
     if elem_info == empty_bitset {
       None
     } else if elem_info == self_elem_info {
-      Some({ elem_info, data })
+      Some({ elem_info, data, })
     } else {
-      Some({ elem_info, data: data.copy_prefix(len=index) })
+      Some({ elem_info, data: data.copy_prefix(len=index), })
     }
   }
 }
@@ -246,7 +247,7 @@ pub fn[X, Y] SparseArray::map(
   self : SparseArray[X],
   f : (X) -> Y raise?,
 ) -> SparseArray[Y] raise? {
-  { elem_info: self.elem_info, data: self.data.map(f) }
+  { elem_info: self.elem_info, data: self.data.map(f), }
 }
 
 ///|
@@ -271,9 +272,9 @@ pub fn[X] SparseArray::filter(
     if elem_info == empty_bitset {
       None
     } else if elem_info == self_elem_info {
-      Some({ elem_info, data })
+      Some({ elem_info, data, })
     } else {
-      Some({ elem_info, data: data.copy_prefix(len=index) })
+      Some({ elem_info, data: data.copy_prefix(len=index), })
     }
   }
 }
@@ -290,7 +291,7 @@ pub fn[X] SparseArray::replace(
 ) -> SparseArray[X] {
   let new_data = self.data.copy()
   new_data[self.elem_info.index_of(idx)] = value
-  { elem_info: self.elem_info, data: new_data }
+  { elem_info: self.elem_info, data: new_data, }
 }
 
 ///|
diff --git a/immut/priority_queue/README.mbt.md b/immut/priority_queue/README.mbt.md
index fb62104702..f722d6ff1b 100644
--- a/immut/priority_queue/README.mbt.md
+++ b/immut/priority_queue/README.mbt.md
@@ -6,7 +6,7 @@ A priority queue is a data structure capable of maintaining maximum/minimum valu
 
 ## Create
 
-You can use `PriorityQueue([])` or `of()` to create an immutable priority queue.
+You can use `PriorityQueue([])` or `from_array()` to create an immutable priority queue.
 
 ```mbt check
 ///|
diff --git a/immut/priority_queue/deprecated.mbt b/immut/priority_queue/deprecated.mbt
index 02b311d3be..d206fafc69 100644
--- a/immut/priority_queue/deprecated.mbt
+++ b/immut/priority_queue/deprecated.mbt
@@ -19,5 +19,5 @@
 #deprecated("Use `PriorityQueue([])` instead")
 #as_free_fn(deprecated="Use `PriorityQueue([])` instead")
 pub fn[A] PriorityQueue::new() -> PriorityQueue[A] {
-  { node: Empty, size: 0 }
+  { node: Empty, size: 0, }
 }
diff --git a/immut/priority_queue/priority_queue.mbt b/immut/priority_queue/priority_queue.mbt
index 186587568f..618f75ce91 100644
--- a/immut/priority_queue/priority_queue.mbt
+++ b/immut/priority_queue/priority_queue.mbt
@@ -67,9 +67,9 @@ pub fn[A : Compare] PriorityQueue::PriorityQueue(
 /// by `pop`/`peek`.
 fn[A : Compare] from_array_in_place(heap : MutArrayView[A]) -> PriorityQueue[A] {
   let len = heap.length()
-  guard len > 0 else { return { node: Empty, size: 0 } }
+  guard len > 0 else { return { node: Empty, size: 0, } }
   heapify(heap)
-  { node: priority_queue_node_from_heap(heap, 0, len), size: len }
+  { node: priority_queue_node_from_heap(heap, 0, len), size: len, }
 }
 
 ///|
@@ -219,10 +219,10 @@ pub fn[A : Compare] PriorityQueue::pop(
 ) -> PriorityQueue[A]? {
   match self.node {
     Empty => None
-    Leaf(_) => Some({ node: Empty, size: 0 })
+    Leaf(_) => Some({ node: Empty, size: 0, })
     Branch(_) => {
       let (value, temp) = self.node.remove_last_leaf(path(self.size))
-      Some({ node: temp.change_and_down(value), size: self.size - 1 })
+      Some({ node: temp.change_and_down(value), size: self.size - 1, })
     }
   }
 }
@@ -291,10 +291,10 @@ pub fn[A : Compare] PriorityQueue::unsafe_pop(
 ) -> PriorityQueue[A] {
   match self.node {
     Empty => abort("Priority queue is empty!")
-    Leaf(_) => { node: Empty, size: 0 }
+    Leaf(_) => { node: Empty, size: 0, }
     Branch(_) => {
       let (value, temp) = self.node.remove_last_leaf(path(self.size))
-      { node: temp.change_and_down(value), size: self.size - 1 }
+      { node: temp.change_and_down(value), size: self.size - 1, }
     }
   }
 }
@@ -315,10 +315,10 @@ pub fn[A : Compare] PriorityQueue::push(
   value : A,
 ) -> PriorityQueue[A] {
   match self.node {
-    Empty => { node: Leaf(value), size: 1 }
+    Empty => { node: Leaf(value), size: 1, }
     Leaf(_) | Branch(_) => {
       let size = self.size + 1
-      { node: self.node.push(value, path(size)), size }
+      { node: self.node.push(value, path(size)), size, }
     }
   }
 }
@@ -356,8 +356,7 @@ fn[A : Compare] Node::push(self : Node[A], value : A, path : Path) -> Node[A] {
 pub fn[A] PriorityQueue::peek(self : PriorityQueue[A]) -> A? {
   match self.node {
     Empty => None
-    Leaf(a) => Some(a)
-    Branch(a, ..) => Some(a)
+    Leaf(a) | Branch(a, ..) => Some(a)
   }
 }
 
@@ -428,8 +427,8 @@ pub impl[A : Hash + Compare] Hash for PriorityQueue[A] with fn hash_combine(
 ///
 /// Parameters:
 ///
-/// * `self` : The first list to compare.
-/// * `other` : The second list to compare.
+/// * `self` : The first priority queue to compare.
+/// * `other` : The second priority queue to compare.
 ///
 /// Returns an integer that indicates the relative order:
 ///
diff --git a/immut/priority_queue/priority_queue_test.mbt b/immut/priority_queue/priority_queue_test.mbt
index 9747cbe874..6a1bbec2f7 100644
--- a/immut/priority_queue/priority_queue_test.mbt
+++ b/immut/priority_queue/priority_queue_test.mbt
@@ -62,10 +62,10 @@ test "to_array" {
 test "as_iter" {
   let buf = StringBuilder(size_hint=20)
   let v = @priority_queue.from_array([1, 2, 3])
-  v.iter().each(e => buf.write_string("[\{e}]"))
+  v.iter().each(e => buf <+ "[\{e}]")
   inspect(buf, content="[3][2][1]")
   buf.reset()
-  v.iter().take(2).each(e => buf.write_string("[\{e}]"))
+  v.iter().take(2).each(e => buf <+ "[\{e}]")
   inspect(buf, content="[3][2]")
 }
 
@@ -73,10 +73,10 @@ test "as_iter" {
 test "iter" {
   let buf = StringBuilder(size_hint=20)
   let v = @priority_queue.from_array([1, 2, 3])
-  v.iter().each(e => buf.write_string("[\{e}]"))
+  v.iter().each(e => buf <+ "[\{e}]")
   inspect(buf, content="[3][2][1]")
   buf.reset()
-  v.iter().take(2).each(e => buf.write_string("[\{e}]"))
+  v.iter().take(2).each(e => buf <+ "[\{e}]")
   inspect(buf, content="[3][2]")
 }
 
@@ -151,7 +151,7 @@ test "length" {
 ///|
 test "from_iter multiple elements iter" {
   debug_inspect(
-    @priority_queue.PriorityQueue::from_iter([1, 2, 3].iter()),
+    @priority_queue.PriorityQueue::from_iter([|1, 2, 3|]),
     content=(
       #|
     ),
@@ -161,7 +161,7 @@ test "from_iter multiple elements iter" {
 ///|
 test "from_iter single element iter" {
   debug_inspect(
-    @priority_queue.PriorityQueue::from_iter([1].iter()),
+    @priority_queue.PriorityQueue::from_iter([|1|]),
     content=(
       #|
     ),
@@ -170,11 +170,9 @@ test "from_iter single element iter" {
 
 ///|
 test "from_iter empty iter" {
-  let pq : @priority_queue.PriorityQueue[Int] = @priority_queue.PriorityQueue::from_iter(
-    Iter::empty(),
-  )
+  let empty : Iter[Int] = [||]
   debug_inspect(
-    pq,
+    @priority_queue.PriorityQueue::from_iter(empty),
     content=(
       #|
     ),
diff --git a/immut/sorted_map/README.mbt.md b/immut/sorted_map/README.mbt.md
index 8d8296f0c7..732a0b5181 100644
--- a/immut/sorted_map/README.mbt.md
+++ b/immut/sorted_map/README.mbt.md
@@ -19,7 +19,7 @@ test {
 }
 ```
 
-Also, you can construct it from an array using `of()` or `from_array()`.
+Also, you can construct it from an array using `SortedMap([...])`.
 
 ```mbt check
 ///|
@@ -33,7 +33,7 @@ test {
 ## Insert & Lookup
 
 You can use `add()` to add a key-value pair to the map and create a new map. Or
-use `lookup()` to get the value associated with a key.
+use `get()` to get the value associated with a key.
 
 ```mbt check
 ///|
@@ -72,7 +72,7 @@ test {
 
 ## Size
 
-You can use `size()` to get the number of key-value pairs in the map.
+You can use `length()` to get the number of key-value pairs in the map.
 
 ```mbt check
 ///|
@@ -125,7 +125,7 @@ test {
 ```
 
 Use `fold()` to fold over the key-value pairs of the map. The default order is
-Pre-order; use `rev_fold()` for a Post-order fold.
+ascending by key; use `rev_fold()` to fold in descending key order.
 
 ```mbt check
 ///|
@@ -171,7 +171,7 @@ test {
 }
 ```
 
-Use `keys()` to get all keys of the map in ascending order.
+Use `keys_as_iter()` to get all keys of the map in ascending order.
 
 ```mbt check
 ///|
diff --git a/immut/sorted_map/map_test.mbt b/immut/sorted_map/map_test.mbt
index 19fd9534b4..565dbb3e81 100644
--- a/immut/sorted_map/map_test.mbt
+++ b/immut/sorted_map/map_test.mbt
@@ -12,6 +12,26 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+///|
+priv struct SameRankKey {
+  rank : Int
+  label : String
+}
+
+///|
+/// `Compare` orders on `rank` alone, so `Eq` has to agree with it: `compare`
+/// returning zero must mean equal, or the `Compare : Eq` contract is broken.
+/// Tests read `label` directly rather than through `==`, so it stays
+/// observable.
+impl Eq for SameRankKey with fn equal(self, other) {
+  self.rank == other.rank
+}
+
+///|
+impl Compare for SameRankKey with fn compare(self, other) {
+  self.rank.compare(other.rank)
+}
+
 ///|
 test "lookup" {
   let m = @sorted_map.SortedMap([
@@ -48,6 +68,54 @@ test "is_empty" {
   inspect(m.is_empty(), content="false")
 }
 
+///|
+test "SortedMap ordered inputs" {
+  let ascending = @sorted_map.SortedMap([(1, "a"), (2, "b"), (3, "c")])
+  let descending = @sorted_map.SortedMap([(3, "c"), (2, "b"), (1, "a")])
+  @test.assert_eq(ascending.to_array(), [(1, "a"), (2, "b"), (3, "c")])
+  @test.assert_eq(descending.to_array(), [(1, "a"), (2, "b"), (3, "c")])
+}
+
+///|
+test "SortedMap ordered duplicate keeps last value" {
+  let m = @sorted_map.SortedMap([(1, "a"), (1, "b"), (2, "c")])
+  @test.assert_eq(m.to_array(), [(1, "b"), (2, "c")])
+}
+
+///|
+test "SortedMap ordered duplicate keeps first key and last value" {
+  let m = @sorted_map.SortedMap([
+    ({ rank: 1, label: "first", }, "a"),
+    ({ rank: 1, label: "second", }, "b"),
+    ({ rank: 2, label: "third", }, "c"),
+  ])
+  @test.assert_eq(m.to_array().map(kv => (kv.0.label, kv.1)), [
+    ("first", "b"),
+    ("third", "c"),
+  ])
+}
+
+///|
+test "SortedMap large unordered duplicate keeps first key and last value" {
+  let items = Array::makei(70, i => {
+    if i == 5 {
+      ({ rank: 10, label: "first", }, "a")
+    } else if i == 50 {
+      ({ rank: 10, label: "second", }, "b")
+    } else {
+      ({ rank: 1000 - i, label: "other-\{i}", }, "other")
+    }
+  })
+  let m = @sorted_map.SortedMap(items)
+  let matched = []
+  for k, v in m {
+    if k.rank == 10 {
+      matched.push((k.label, v))
+    }
+  }
+  @test.assert_eq(matched, [("first", "b")])
+}
+
 ///|
 test "iter" {
   let m = @sorted_map.SortedMap([
@@ -84,7 +152,7 @@ test "iter_take_each" {
   .iter()
   .each(e => {
     let (k, v) = e
-    buf.write_string("[\{k}-\{v}]")
+    buf <+ "[\{k}-\{v}]"
   })
   inspect(buf, content="[1-one][2-two][3-three]")
   buf.reset()
@@ -93,7 +161,7 @@ test "iter_take_each" {
   .take(2)
   .each(e => {
     let (k, v) = e
-    buf.write_string("[\{k}-\{v}]")
+    buf <+ "[\{k}-\{v}]"
   })
   inspect(buf, content="[1-one][2-two]")
 }
@@ -195,7 +263,10 @@ test "compare" {
   let xss : Array[@sorted_map.SortedMap[Int, Int]] = @quickcheck.samples(5)
   for xs in xss {
     for ys in xss {
-      @test.assert_eq(xs.compare(ys), xs.to_array().compare(ys.to_array()))
+      @test.assert_eq(
+        xs.compare(ys).compare(0),
+        xs.to_array().compare(ys.to_array()).compare(0),
+      )
     }
   }
 }
@@ -208,7 +279,7 @@ test "iterator" {
   .iter()
   .each(e => {
     let (k, v) = e
-    buf.write_string("[\{k}-\{v}]")
+    buf <+ "[\{k}-\{v}]"
   })
   inspect(buf, content="[1-one][2-two][3-three]")
   buf.reset()
@@ -217,7 +288,7 @@ test "iterator" {
   .take(2)
   .each(e => {
     let (k, v) = e
-    buf.write_string("[\{k}-\{v}]")
+    buf <+ "[\{k}-\{v}]"
   })
   inspect(buf, content="[1-one][2-two]")
 }
@@ -236,17 +307,53 @@ test "show" {
 ///|
 test "from_iter multiple elements iter" {
   debug_inspect(
-    @sorted_map.from_iter([(1, 1), (2, 2), (3, 3)].iter()),
+    @sorted_map.from_iter([|(1, 1), (2, 2), (3, 3)|]),
     content=(
       #|
     ),
   )
 }
 
+///|
+test "from_iter duplicate keeps first key and last value" {
+  let m = @sorted_map.from_iter(
+    [
+      ({ rank: 1, label: "first", }, "a"),
+      ({ rank: 1, label: "second", }, "b"),
+      ({ rank: 2, label: "third", }, "c"),
+    ].iter(),
+  )
+  @test.assert_eq(m.to_array().map(kv => (kv.0.label, kv.1)), [
+    ("first", "b"),
+    ("third", "c"),
+  ])
+}
+
+///|
+test "from_iter large unordered duplicate keeps first key and last value" {
+  let items = Array::makei(70, i => {
+    if i == 5 {
+      ({ rank: 10, label: "first", }, "a")
+    } else if i == 50 {
+      ({ rank: 10, label: "second", }, "b")
+    } else {
+      ({ rank: 1000 - i, label: "other-\{i}", }, "other")
+    }
+  })
+  let m = @sorted_map.from_iter(items.iter())
+  let matched = []
+  for k, v in m {
+    if k.rank == 10 {
+      matched.push((k.label, v))
+    }
+  }
+  @test.assert_eq(matched, [("first", "b")])
+}
+
 ///|
 test "from_iter single element iter" {
   debug_inspect(
-    @sorted_map.from_iter([(1, 1)].iter()),
+    @sorted_map.from_iter([|(1, 1)|]),
     content=(
       #|
     ),
@@ -255,9 +362,7 @@ test "from_iter single element iter" {
 
 ///|
 test "from_iter empty iter" {
-  let pq : @sorted_map.SortedMap[Int, Int] = @sorted_map.from_iter(
-    Iter::empty(),
-  )
+  let pq : @sorted_map.SortedMap[Int, Int] = @sorted_map.from_iter([||])
   debug_inspect(
     pq,
     content=(
@@ -266,6 +371,37 @@ test "from_iter empty iter" {
   )
 }
 
+///|
+test "from_iter descending keys build ascending" {
+  debug_inspect(
+    @sorted_map.from_iter([(3, 3), (2, 2), (1, 1)].iter()),
+    content=(
+      #|
+    ),
+  )
+}
+
+///|
+test "from_iter descending duplicate keeps first key and last value" {
+  let m = @sorted_map.from_iter(
+    [
+      ({ rank: 2, label: "top", }, "a"),
+      ({ rank: 1, label: "first", }, "b"),
+      ({ rank: 1, label: "second", }, "c"),
+    ].iter(),
+  )
+  @test.assert_eq(m.to_array().map(kv => (kv.0.label, kv.1)), [
+    ("first", "c"),
+    ("top", "a"),
+  ])
+}
+
+///|
+test "from_iter small unsorted duplicate keeps last value" {
+  let m = @sorted_map.from_iter([(3, "a"), (1, "b"), (3, "c"), (2, "d")].iter())
+  @test.assert_eq(m.to_array(), [(1, "b"), (2, "d"), (3, "c")])
+}
+
 ///|
 test "hash" {
   let map1 = @sorted_map.SortedMap([("one", 1), ("two", 2)])
diff --git a/immut/sorted_map/sorted_map_build_bench_test.mbt b/immut/sorted_map/sorted_map_build_bench_test.mbt
new file mode 100644
index 0000000000..1d95160e61
--- /dev/null
+++ b/immut/sorted_map/sorted_map_build_bench_test.mbt
@@ -0,0 +1,93 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+let sorted_map_build_bench_size = 10_000
+
+///|
+fn make_sorted_map_ordered_pairs() -> Array[(Int, Int)] {
+  Array::makei(sorted_map_build_bench_size, i => (i, i))
+}
+
+///|
+fn make_sorted_map_reverse_pairs() -> Array[(Int, Int)] {
+  Array::makei(sorted_map_build_bench_size, i => {
+    let k = sorted_map_build_bench_size - i - 1
+    (k, k)
+  })
+}
+
+///|
+fn make_sorted_map_almost_ordered_pairs() -> Array[(Int, Int)] {
+  Array::makei(sorted_map_build_bench_size, i => {
+    let k = if i == sorted_map_build_bench_size - 2 {
+      sorted_map_build_bench_size - 1
+    } else if i == sorted_map_build_bench_size - 1 {
+      sorted_map_build_bench_size - 2
+    } else {
+      i
+    }
+    (k, k)
+  })
+}
+
+///|
+fn make_sorted_map_random_pairs() -> Array[(Int, Int)] {
+  Array::makei(sorted_map_build_bench_size, i => {
+    let k = (i * 1103515245 + 12345) & 0x7fffffff
+    (k, k)
+  })
+}
+
+///|
+test "bench SortedMap constructor reverse n=10000" (it : @bench.T) {
+  let pairs = make_sorted_map_reverse_pairs()
+  it.bench(fn() { it.keep(@sorted_map.SortedMap(pairs)) })
+}
+
+///|
+test "bench SortedMap::from_iter ordered n=10000" (it : @bench.T) {
+  let pairs = make_sorted_map_ordered_pairs()
+  it.bench(fn() { it.keep(@sorted_map.from_iter(pairs.iter())) })
+}
+
+///|
+test "bench SortedMap::from_iter almost ordered n=10000" (it : @bench.T) {
+  let pairs = make_sorted_map_almost_ordered_pairs()
+  it.bench(fn() { it.keep(@sorted_map.from_iter(pairs.iter())) })
+}
+
+///|
+test "bench SortedMap::from_iter random n=10000" (it : @bench.T) {
+  let pairs = make_sorted_map_random_pairs()
+  it.bench(fn() { it.keep(@sorted_map.from_iter(pairs.iter())) })
+}
+
+///|
+test "bench SortedMap constructor ordered n=10000" (it : @bench.T) {
+  let pairs = make_sorted_map_ordered_pairs()
+  it.bench(fn() { it.keep(@sorted_map.SortedMap(pairs)) })
+}
+
+///|
+test "bench SortedMap constructor almost ordered n=10000" (it : @bench.T) {
+  let pairs = make_sorted_map_almost_ordered_pairs()
+  it.bench(fn() { it.keep(@sorted_map.SortedMap(pairs)) })
+}
+
+///|
+test "bench SortedMap constructor random n=10000" (it : @bench.T) {
+  let pairs = make_sorted_map_random_pairs()
+  it.bench(fn() { it.keep(@sorted_map.SortedMap(pairs)) })
+}
diff --git a/immut/sorted_map/utils.mbt b/immut/sorted_map/utils.mbt
index c8e253e856..b72a372ca1 100644
--- a/immut/sorted_map/utils.mbt
+++ b/immut/sorted_map/utils.mbt
@@ -108,6 +108,8 @@ pub fn[K : Compare, V] SortedMap::get(self : SortedMap[K, V], key : K) -> V? {
 
 ///|
 /// Get the value associated with a key.
+/// Aborts if the key is not present; use `get` for the `Option`-returning
+/// version.
 /// O(log n).
 #alias("_[_]")
 pub fn[K : Compare, V] SortedMap::at(self : SortedMap[K, V], key : K) -> V {
@@ -179,7 +181,7 @@ pub fn[K, X, Y] SortedMap::map(
 }
 
 ///|
-/// Post-order fold.
+/// Fold over the key-value pairs in descending key order.
 /// O(n).
 #alias(foldr_with_key)
 pub fn[K, V, A] SortedMap::rev_fold(
@@ -198,7 +200,7 @@ pub fn[K, V, A] SortedMap::rev_fold(
 }
 
 ///|
-/// Pre-order fold.
+/// Fold over the key-value pairs in ascending key order.
 /// O(n).
 #alias(foldl_with_key, deprecated)
 pub fn[K, V, A] SortedMap::fold(
@@ -228,15 +230,101 @@ fn[K : Show, V : Show] SortedMap::debug_tree(self : SortedMap[K, V]) -> String {
   }
 }
 
+///|
+
 ///|
 /// Build a map from an array of key-value pairs.
-/// O(n*log n).
+/// O(n) when the input is already monotonic by key, otherwise O(n*log n).
 #as_free_fn(deprecated="Use @immut/sorted_map.SortedMap([...]) instead")
 #alias(of, deprecated="Use @immut/sorted_map.SortedMap([...]) instead")
 #as_free_fn(of, deprecated="Use @immut/sorted_map.SortedMap([...]) instead")
 #deprecated("Use @immut/sorted_map.SortedMap([...]) instead")
 pub fn[K : Compare, V] SortedMap::from_array(
   array : ArrayView[(K, V)],
+) -> SortedMap[K, V] {
+  sorted_map_from_array(array)
+}
+
+///|
+fn[K : Compare, V] sorted_map_from_array(
+  array : ArrayView[(K, V)],
+) -> SortedMap[K, V] {
+  let (order, has_duplicates) = sorted_map_array_order(array)
+  if order == 1 {
+    if has_duplicates {
+      let compacted = sorted_map_compact_ordered_array(array, true)
+      return sorted_map_from_ascending_array(compacted, 0, compacted.length())
+    }
+    return sorted_map_from_ascending_array(array, 0, array.length())
+  } else if order == -1 {
+    if has_duplicates {
+      let compacted = sorted_map_compact_ordered_array(array, false)
+      return sorted_map_from_ascending_array(compacted, 0, compacted.length())
+    }
+    return sorted_map_from_descending_array(array, 0, array.length())
+  }
+  if array.length() >= sorted_map_sort_build_threshold {
+    sorted_map_from_unsorted_array(array)
+  } else {
+    sorted_map_from_array_by_add(array)
+  }
+}
+
+///|
+let sorted_map_sort_build_threshold = 64
+
+///|
+/// A key-value pair tagged with its original position, used to sort an unsorted
+/// input by key while breaking ties on insertion order.
+priv struct SortEntry[K, V] {
+  key : K
+  value : V
+  index : Int
+}
+
+///|
+impl[K : Compare, V] Eq for SortEntry[K, V] with fn equal(self, other) {
+  self.key == other.key && self.index == other.index
+}
+
+///|
+// Ordering by key, then by original position, so that a plain (unstable) `sort`
+// still keeps duplicate keys in insertion order — the last one wins on compact.
+impl[K : Compare, V] Compare for SortEntry[K, V] with fn compare(self, other) {
+  let c = self.key.compare(other.key)
+  if c == 0 {
+    self.index.compare(other.index)
+  } else {
+    c
+  }
+}
+
+///|
+fn[K : Compare, V] sorted_map_from_unsorted_array(
+  array : ArrayView[(K, V)],
+) -> SortedMap[K, V] {
+  let entries : FixedArray[SortEntry[K, V]] = FixedArray::makei(
+    array.length(),
+    i => { key: array[i].0, value: array[i].1, index: i, },
+  )
+  sorted_map_from_unsorted_entries(entries.mut_view())
+}
+
+///|
+/// Sort a `SortEntry` buffer in place, drop duplicate keys, and build the tree
+/// from its unique prefix. The caller owns the backing buffer, which is permuted
+/// in place.
+fn[K : Compare, V] sorted_map_from_unsorted_entries(
+  entries : MutArrayView[SortEntry[K, V]],
+) -> SortedMap[K, V] {
+  entries.sort()
+  let len = sorted_map_compact_entries_in_place(entries)
+  sorted_map_from_sorted_entries(entries.view(), 0, len)
+}
+
+///|
+fn[K : Compare, V] sorted_map_from_array_by_add(
+  array : ArrayView[(K, V)],
 ) -> SortedMap[K, V] {
   for item in array; mp = Empty {
     let (k, v) = item
@@ -248,7 +336,7 @@ pub fn[K : Compare, V] SortedMap::from_array(
 
 ///|
 /// Build a map from an array of key-value pairs.
-/// O(n*log n).
+/// O(n) when the input is already monotonic by key, otherwise O(n*log n).
 ///
 /// # Example
 ///
@@ -262,11 +350,154 @@ pub fn[K : Compare, V] SortedMap::from_array(
 pub fn[K : Compare, V] SortedMap::SortedMap(
   array : ArrayView[(K, V)],
 ) -> SortedMap[K, V] {
-  for item in array; mp = Empty {
-    let (k, v) = item
-    continue mp.add(k, v)
-  } nobreak {
-    mp
+  sorted_map_from_array(array)
+}
+
+///|
+// Deduplicate a key-sorted `SortEntry` buffer in place, keeping the last value
+// seen for each key (last writer wins, matching `add`). The unique entries are
+// packed at the front and their count is returned; the tail is left untouched
+// and must be ignored by the caller. Indices are monotonic (write <= start <=
+// end < len), so the unchecked accessors stay in bounds.
+fn[K : Compare, V] sorted_map_compact_entries_in_place(
+  entries : MutArrayView[SortEntry[K, V]],
+) -> Int {
+  let len = entries.length()
+  let mut write = 0
+  for start = 0; start < len; {
+    let end = for end = start; end + 1 < len &&
+                 entries.unsafe_get(end).key.compare(
+                   entries.unsafe_get(end + 1).key,
+                 ) ==
+                 0; {
+      continue end + 1
+    } nobreak {
+      end
+    }
+    entries.unsafe_set(write, {
+      key: entries.unsafe_get(start).key,
+      value: entries.unsafe_get(end).value,
+      index: entries.unsafe_get(start).index,
+    })
+    write += 1
+    continue end + 1
+  }
+  write
+}
+
+///|
+fn[K : Compare, V] sorted_map_array_order(
+  array : ArrayView[(K, V)],
+) -> (Int, Bool) {
+  let len = array.length()
+  guard len > 1 else { return (1, false) }
+  let mut order = 0
+  let mut has_duplicates = false
+  for i in 1.. Array[(K, V)] {
+  let result = []
+  let len = array.length()
+  if ascending {
+    for start = 0; start < len; {
+      let end = for end = start; end + 1 < len &&
+                   array[end].0.compare(array[end + 1].0) == 0; {
+        continue end + 1
+      } nobreak {
+        end
+      }
+      result.push((array[start].0, array[end].1))
+      continue end + 1
+    }
+  } else {
+    for end = len - 1; end >= 0; {
+      let start = for start = end; start > 0 &&
+                     array[start - 1].0.compare(array[start].0) == 0; {
+        continue start - 1
+      } nobreak {
+        start
+      }
+      result.push((array[start].0, array[end].1))
+      continue start - 1
+    }
+  }
+  result
+}
+
+///|
+/// Build a balanced tree from a slice sorted in ascending key order.
+/// The ascending case of a monotonic array is exactly this construction.
+fn[K, V] sorted_map_from_ascending_array(
+  array : ArrayView[(K, V)],
+  start : Int,
+  end : Int,
+) -> SortedMap[K, V] {
+  if start >= end {
+    Empty
+  } else {
+    let mid = (start + end) / 2
+    let (key, value) = array[mid]
+    let left = sorted_map_from_ascending_array(array, start, mid)
+    let right = sorted_map_from_ascending_array(array, mid + 1, end)
+    make_tree(key, value, left, right)
+  }
+}
+
+///|
+/// Build a balanced tree from a slice sorted in descending key order, without
+/// materializing a reversed copy: the middle element is still the subtree root,
+/// but the smaller keys sit at the higher indices, so the halves are swapped.
+fn[K, V] sorted_map_from_descending_array(
+  array : ArrayView[(K, V)],
+  start : Int,
+  end : Int,
+) -> SortedMap[K, V] {
+  if start >= end {
+    Empty
+  } else {
+    let mid = (start + end) / 2
+    let (key, value) = array[mid]
+    let left = sorted_map_from_descending_array(array, mid + 1, end)
+    let right = sorted_map_from_descending_array(array, start, mid)
+    make_tree(key, value, left, right)
+  }
+}
+
+///|
+/// Build a balanced tree from the `[start, end)` slice of a key-sorted,
+/// duplicate-free `SortEntry` buffer, reading keys and values off the entries
+/// directly so no `(K, V)` copy is materialized.
+fn[K, V] sorted_map_from_sorted_entries(
+  entries : ArrayView[SortEntry[K, V]],
+  start : Int,
+  end : Int,
+) -> SortedMap[K, V] {
+  if start >= end {
+    Empty
+  } else {
+    let mid = (start + end) / 2
+    let entry = entries[mid]
+    let left = sorted_map_from_sorted_entries(entries, start, mid)
+    let right = sorted_map_from_sorted_entries(entries, mid + 1, end)
+    make_tree(entry.key, entry.value, left, right)
   }
 }
 
@@ -399,6 +630,10 @@ pub fn[K : Compare, V] SortedMap::range(
 pub fn[K : Compare, V] SortedMap::from_iter(
   iter : Iter[(K, V)],
 ) -> SortedMap[K, V] {
+  // Folded rather than collected on purpose: bulk-building would have to
+  // materialize the whole iterator, so live memory would grow with the input
+  // length instead of the number of distinct keys. Callers who want the bulk
+  // path and can afford the buffer can write `SortedMap(iter.to_array())`.
   iter.fold(init=new(), (m, e) => m.add(e.0, e.1))
 }
 
diff --git a/immut/sorted_map/utils_test.mbt b/immut/sorted_map/utils_test.mbt
index 6a7c382764..67d801ebf7 100644
--- a/immut/sorted_map/utils_test.mbt
+++ b/immut/sorted_map/utils_test.mbt
@@ -131,14 +131,10 @@ test "iter" {
     |> @sorted_map.SortedMap
   let buf = StringBuilder()
   for k, v in map {
-    buf.write_object(k)
-    buf.write_string(": ")
-    buf.write_object(v)
-    buf.write_string("\n")
+    buf <+ "\{k}: \{v}\n"
   }
   for k, v in map {
-    buf.write_string("(\{k}, \{Repr(v)})")
-    buf.write_string("\n")
+    buf <+ "(\{k}, \{Repr(v)})\n"
   }
   inspect(
     buf,
@@ -367,7 +363,7 @@ test "range matching all elements" {
   let map = @sorted_map.SortedMap([(1, "a"), (2, "b"), (3, "c")])
   let buf = StringBuilder()
   for k, v in map.range(low=0, high=10) {
-    buf.write_string("[\{k},\{v}]")
+    buf <+ "[\{k},\{v}]"
   }
   inspect(buf, content="[1,a][2,b][3,c]")
 }
@@ -383,7 +379,7 @@ test "range partial match" {
   ])
   let buf = StringBuilder()
   for k, v in map.range(low=2, high=4) {
-    buf.write_string("[\{k},\{v}]")
+    buf <+ "[\{k},\{v}]"
   }
   inspect(buf, content="[2,b][3,c][4,d]")
 }
@@ -399,7 +395,7 @@ test "range with single element" {
   ])
   let buf = StringBuilder()
   for k, v in map.range(low=3, high=3) {
-    buf.write_string("[\{k},\{v}]")
+    buf <+ "[\{k},\{v}]"
   }
   inspect(buf, content="[3,c]")
 }
@@ -415,7 +411,7 @@ test "range at left boundary" {
   ])
   let buf = StringBuilder()
   for k, v in map.range(low=1, high=2) {
-    buf.write_string("[\{k},\{v}]")
+    buf <+ "[\{k},\{v}]"
   }
   inspect(buf, content="[1,a][2,b]")
 }
@@ -431,7 +427,7 @@ test "range at right boundary" {
   ])
   let buf = StringBuilder()
   for k, v in map.range(low=4, high=5) {
-    buf.write_string("[\{k},\{v}]")
+    buf <+ "[\{k},\{v}]"
   }
   inspect(buf, content="[4,d][5,e]")
 }
@@ -447,7 +443,7 @@ test "range with gaps in data" {
   ])
   let buf = StringBuilder()
   for k, v in map.range(low=2, high=8) {
-    buf.write_string("[\{k},\{v}]")
+    buf <+ "[\{k},\{v}]"
   }
   inspect(buf, content="[3,c][5,e][7,g]")
 }
@@ -507,7 +503,7 @@ test "range with non-existent boundaries" {
   let map = @sorted_map.SortedMap([(2, "b"), (4, "d"), (6, "f"), (8, "h")])
   let buf = StringBuilder()
   for k, v in map.range(low=3, high=7) {
-    buf.write_string("[\{k},\{v}]")
+    buf <+ "[\{k},\{v}]"
   }
   inspect(buf, content="[4,d][6,f]")
 }
diff --git a/immut/sorted_set/README.mbt.md b/immut/sorted_set/README.mbt.md
index 1a1b796563..de0e1df788 100644
--- a/immut/sorted_set/README.mbt.md
+++ b/immut/sorted_set/README.mbt.md
@@ -96,7 +96,7 @@ test {
 }
 ```
 
-At the same time, you can use union and inter to take the union or intersection of two sets.
+At the same time, you can use `union` and `intersection` to take the union or intersection of two sets.
 
 ```mbt check
 ///|
@@ -108,7 +108,7 @@ test {
 }
 ```
 
-You can also use the `diff` function to obtain the difference between two sets.
+You can also use the `difference` function to obtain the difference between two sets.
 
 ```mbt check
 ///|
@@ -131,7 +131,7 @@ test {
 
 ## Subset & Disjoint
 
-You can use `subsets` and `disjoint` to determine the inclusion and separation relationship between two sets
+You can use `subset` and `disjoint` to determine the inclusion and separation relationship between two sets
 
 ```mbt check
 ///|
diff --git a/immut/sorted_set/bulk_build_quickcheck_test.mbt b/immut/sorted_set/bulk_build_quickcheck_test.mbt
new file mode 100644
index 0000000000..0b477cdbfa
--- /dev/null
+++ b/immut/sorted_set/bulk_build_quickcheck_test.mbt
@@ -0,0 +1,218 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// The bulk builder behind `SortedSet(..)` dispatches on the shape of its
+// input: strictly ascending, ascending with duplicates, strictly descending,
+// descending with duplicates, or unordered — and unordered splits again at a
+// length threshold, taking the bulk-sort path at or above it and the `add`
+// fold below. Every one of those paths must produce the same set, so
+// `bulk_set_shapes` names one input per path and the properties drive them all
+// from a single model.
+//
+// `from_iter` deliberately does NOT bulk-build: it folds `add`, so its live
+// memory stays proportional to the distinct elements rather than to the length
+// of the iterator. It is checked here anyway, because the two entry points
+// disagree about which of several equal elements survives — the constructor
+// keeps the LAST, `from_iter` the FIRST — and that difference is exactly what a
+// future attempt to share their implementations would break. With `Int`
+// elements it is invisible, so `BElem` compares on `id` only and carries a
+// `tag` that makes the survivor observable.
+//
+// Note that quickcheck's generated arrays cannot reach the duplicate-free
+// paths on their own — it sizes element `i` by `i`, so an `Array[Int]` of
+// length two or more always starts `[0, 0, ..]`. The strictly monotonic and
+// small-unordered shapes below are therefore built explicitly rather than
+// derived from the generated array.
+//
+// These properties pass unchanged on the pre-bulk-build implementation too, so
+// they pin the contract that was already there rather than blessing whatever
+// the new code happens to do.
+
+///|
+/// Compares on `id` only; `tag` records where the element came from.
+priv struct BElem {
+  id : Int
+  tag : Int
+}
+
+///|
+impl Eq for BElem with fn equal(self, other) {
+  self.id == other.id
+}
+
+///|
+impl Compare for BElem with fn compare(self, other) {
+  self.id.compare(other.id)
+}
+
+///|
+/// `keep_last=true` models the constructor, `false` models `from_iter`.
+fn bulk_set_model(values : ArrayView[BElem], keep_last~ : Bool) -> Array[BElem] {
+  let out : Array[BElem] = []
+  for value in values {
+    let mut seen = false
+    for i, entry in out {
+      if entry.id == value.id {
+        if keep_last {
+          out[i] = value
+        }
+        seen = true
+        break
+      }
+    }
+    if !seen {
+      out.push(value)
+    }
+  }
+  out.sort_by((a, b) => a.id.compare(b.id))
+  out
+}
+
+///|
+/// Full equality including `tag`, which `BElem::equal` deliberately ignores,
+/// plus a strictly ascending check — misordering, or the wrong duplicate
+/// representative surviving, shows up here even when the ids alone still look
+/// right.
+fn bulk_set_agrees(actual : Array[BElem], expect : Array[BElem]) -> Bool {
+  if actual.length() != expect.length() {
+    return false
+  }
+  for i in 0..= actual[i].id {
+      return false
+    }
+  }
+  true
+}
+
+///|
+/// A small id space keeps duplicates common; `tag` is the original position.
+fn bulk_set_values(raw : ArrayView[Int]) -> Array[BElem] {
+  Array::makei(raw.length(), i => {
+    let id = raw[i] % 37
+    { id: if id < 0 { id + 37 } else { id }, tag: i, }
+  })
+}
+
+///|
+/// One input per dispatch path. The generated array supplies the unordered and
+/// duplicate-heavy cases; the strictly monotonic and small-unordered shapes are
+/// explicit, since generated arrays never have distinct leading elements. The
+/// 80-element shapes sit above the length threshold, the 9-element one below.
+fn bulk_set_shapes(raw : ArrayView[Int]) -> Array[(String, Array[BElem])] {
+  let base = bulk_set_values(raw)
+  let ascending_with_duplicates = base.copy()
+  ascending_with_duplicates.sort_by((a, b) => a.id.compare(b.id))
+  let descending_with_duplicates = ascending_with_duplicates.rev()
+  let n = base.length()
+  let strict_ascending = Array::makei(80, i => { id: 100 + i, tag: n + i, })
+  let strict_descending = strict_ascending.rev()
+  let long_ascending_duplicates = Array::makei(80, i => {
+    id: 100 + i / 2,
+    tag: n + i,
+  })
+  let long_descending_duplicates = long_ascending_duplicates.rev()
+  let long_unordered = Array::makei(80, i => {
+    id: (i * 37 + 11) % 41,
+    tag: n + i,
+  })
+  // Below the threshold, so this one takes the `add` fold rather than the
+  // bulk sort.
+  let small_unordered = Array::makei(9, i => {
+    id: (i * 5 + 3) % 7,
+    tag: n + i,
+  })
+  [
+    ("as generated", base),
+    ("ascending with duplicates", ascending_with_duplicates),
+    ("descending with duplicates", descending_with_duplicates),
+    ("strictly ascending", strict_ascending),
+    ("strictly descending", strict_descending),
+    ("long ascending with duplicates", long_ascending_duplicates),
+    ("long descending with duplicates", long_descending_duplicates),
+    ("long unordered", long_unordered),
+    ("small unordered", small_unordered),
+    ("generated then strictly ascending", base + strict_ascending),
+    ("generated then strictly descending", base + strict_descending),
+  ]
+}
+
+///|
+test "quickcheck: bulk set build agrees with the model on every input shape" {
+  @quickcheck.check(
+    (raw : Array[Int]) => {
+      for shape in bulk_set_shapes(raw) {
+        let (_, values) = shape
+        // The constructor keeps the last equal element...
+        if !bulk_set_agrees(
+            @sorted_set.SortedSet(values).to_array(),
+            bulk_set_model(values, keep_last=true),
+          ) {
+          return false
+        }
+        // ...while `from_iter` keeps the first. Both must still be strictly
+        // ascending and hold the same ids.
+        if !bulk_set_agrees(
+            @sorted_set.from_iter(values.iter()).to_array(),
+            bulk_set_model(values, keep_last=false),
+          ) {
+          return false
+        }
+      }
+      true
+    },
+    count=200,
+  )
+}
+
+///|
+test "quickcheck: bulk set build matches inserting one at a time" {
+  @quickcheck.check(
+    (raw : Array[Int]) => {
+      for shape in bulk_set_shapes(raw) {
+        let (_, values) = shape
+        let built = @sorted_set.SortedSet(values)
+        // Same size as folding `add`, and every element reachable through the
+        // tree rather than merely present in `to_array`.
+        let empty : @sorted_set.SortedSet[BElem] = @sorted_set.new()
+        let folded = for value in values; s = empty {
+          continue s.add(value)
+        } nobreak {
+          s
+        }
+        if built.length() != folded.length() {
+          return false
+        }
+        for value in values {
+          if !built.contains(value) || !folded.contains(value) {
+            return false
+          }
+        }
+        let ids = built.to_array()
+        for i in 1..= ids[i].id {
+            return false
+          }
+        }
+      }
+      true
+    },
+    count=200,
+  )
+}
diff --git a/immut/sorted_set/generic.mbt b/immut/sorted_set/generic.mbt
index 7404953ff5..32702a6648 100644
--- a/immut/sorted_set/generic.mbt
+++ b/immut/sorted_set/generic.mbt
@@ -90,6 +90,10 @@ pub fn[A] SortedSet::iter(self : SortedSet[A]) -> Iter[A] {
 #alias(from_iterator, deprecated)
 #as_free_fn(from_iterator, deprecated)
 pub fn[A : Compare] SortedSet::from_iter(iter : Iter[A]) -> SortedSet[A] {
+  // Folded rather than collected on purpose: bulk-building would have to
+  // materialize the whole iterator, so live memory would grow with the input
+  // length instead of the number of distinct elements. Callers who want the
+  // bulk path and can afford the buffer can write `SortedSet(iter.to_array())`.
   iter.fold(init=new(), (s, e) => s.add(e))
 }
 
diff --git a/immut/sorted_set/immutable_set.mbt b/immut/sorted_set/immutable_set.mbt
index 578e7cd47f..87fc9a9d68 100644
--- a/immut/sorted_set/immutable_set.mbt
+++ b/immut/sorted_set/immutable_set.mbt
@@ -40,11 +40,83 @@ pub fn[A] SortedSet::singleton(value : A) -> SortedSet[A] {
 
 ///|
 /// Initialize a `SortedSet[A]` from an array.
+/// O(n) when the input is already monotonic, otherwise O(n*log n).
 #as_free_fn(deprecated="Use @immut/sorted_set.SortedSet([...]) instead")
 #alias(of, deprecated="Use @immut/sorted_set.SortedSet([...]) instead")
 #as_free_fn(of, deprecated="Use @immut/sorted_set.SortedSet([...]) instead")
 #deprecated("Use @immut/sorted_set.SortedSet([...]) instead")
 pub fn[A : Compare] SortedSet::from_array(array : ArrayView[A]) -> SortedSet[A] {
+  sorted_set_from_array(array)
+}
+
+///|
+fn[A : Compare] sorted_set_from_array(array : ArrayView[A]) -> SortedSet[A] {
+  let (order, has_duplicates) = sorted_set_array_order(array)
+  if order == 1 {
+    if has_duplicates {
+      let compacted = sorted_set_compact_ordered_array(array, true)
+      return sorted_set_from_ascending_array(compacted, 0, compacted.length())
+    }
+    return sorted_set_from_ascending_array(array, 0, array.length())
+  } else if order == -1 {
+    if has_duplicates {
+      let compacted = sorted_set_compact_ordered_array(array, false)
+      return sorted_set_from_ascending_array(compacted, 0, compacted.length())
+    }
+    return sorted_set_from_descending_array(array, 0, array.length())
+  }
+  if array.length() >= sorted_set_sort_build_threshold {
+    // `array` is borrowed, so copy it into a buffer we own before sorting.
+    sorted_set_from_unsorted_buffer(array.to_owned().mut_view())
+  } else {
+    sorted_set_from_array_by_add(array)
+  }
+}
+
+///|
+let sorted_set_sort_build_threshold = 64
+
+///|
+/// Sort an owned buffer in place and build a set from it, keeping the LAST
+/// element of each equal run. `stable_sort` preserves the insertion order of
+/// equal elements, so that representative matches building the set by repeated
+/// `add` in reverse, which is what the array constructor does. The caller must
+/// own `buf` and not use it afterwards, since the elements are permuted in
+/// place.
+fn[A : Compare] sorted_set_from_unsorted_buffer(
+  buf : MutArrayView[A],
+) -> SortedSet[A] {
+  buf.stable_sort()
+  let len = sorted_set_dedupe_in_place(buf)
+  sorted_set_from_ascending_array(buf.view(start=0, end=len), 0, len)
+}
+
+///|
+// Drop duplicate runs from a sorted buffer in place, keeping the LAST element
+// of each run. The unique elements are packed at the front and their count is
+// returned; the tail is left untouched. Indices are monotonic
+// (write <= start <= end < len), so the unchecked accessors stay in bounds.
+fn[A : Compare] sorted_set_dedupe_in_place(buf : MutArrayView[A]) -> Int {
+  let len = buf.length()
+  let mut write = 0
+  for start = 0; start < len; {
+    let end = for end = start; end + 1 < len &&
+                 buf.unsafe_get(end).compare(buf.unsafe_get(end + 1)) == 0; {
+      continue end + 1
+    } nobreak {
+      end
+    }
+    buf.unsafe_set(write, buf.unsafe_get(end))
+    write += 1
+    continue end + 1
+  }
+  write
+}
+
+///|
+fn[A : Compare] sorted_set_from_array_by_add(
+  array : ArrayView[A],
+) -> SortedSet[A] {
   for i = array.length() - 1, set = Empty; i >= 0; {
     continue i - 1, set.add(array[i])
   } nobreak {
@@ -63,10 +135,100 @@ pub fn[A : Compare] SortedSet::from_array(array : ArrayView[A]) -> SortedSet[A]
 /// }
 /// ```
 pub fn[A : Compare] SortedSet::SortedSet(array : ArrayView[A]) -> SortedSet[A] {
-  for i = array.length() - 1, set = Empty; i >= 0; {
-    continue i - 1, set.add(array[i])
-  } nobreak {
-    set
+  sorted_set_from_array(array)
+}
+
+///|
+fn[A : Compare] sorted_set_array_order(array : ArrayView[A]) -> (Int, Bool) {
+  let len = array.length()
+  guard len > 1 else { return (1, false) }
+  let mut order = 0
+  let mut has_duplicates = false
+  for i in 1.. Array[A] {
+  let result = []
+  let len = array.length()
+  if ascending {
+    for start = 0; start < len; {
+      let end = for end = start; end + 1 < len &&
+                   array[end].compare(array[end + 1]) == 0; {
+        continue end + 1
+      } nobreak {
+        end
+      }
+      result.push(array[end])
+      continue end + 1
+    }
+  } else {
+    for end = len - 1; end >= 0; {
+      let start = for start = end; start > 0 &&
+                     array[start - 1].compare(array[start]) == 0; {
+        continue start - 1
+      } nobreak {
+        start
+      }
+      result.push(array[end])
+      continue start - 1
+    }
+  }
+  result
+}
+
+///|
+/// Build a balanced tree from a slice sorted in ascending order.
+/// The ascending case of a monotonic array is exactly this construction.
+fn[A] sorted_set_from_ascending_array(
+  array : ArrayView[A],
+  start : Int,
+  end : Int,
+) -> SortedSet[A] {
+  if start >= end {
+    Empty
+  } else {
+    let mid = (start + end) / 2
+    let value = array[mid]
+    let left = sorted_set_from_ascending_array(array, start, mid)
+    let right = sorted_set_from_ascending_array(array, mid + 1, end)
+    create(left, value, right)
+  }
+}
+
+///|
+/// Build a balanced tree from a slice sorted in descending order, without
+/// materializing a reversed copy: the middle element is still the subtree root,
+/// but the smaller values sit at the higher indices, so the halves are swapped.
+fn[A] sorted_set_from_descending_array(
+  array : ArrayView[A],
+  start : Int,
+  end : Int,
+) -> SortedSet[A] {
+  if start >= end {
+    Empty
+  } else {
+    let mid = (start + end) / 2
+    let value = array[mid]
+    let left = sorted_set_from_descending_array(array, mid + 1, end)
+    let right = sorted_set_from_descending_array(array, start, mid)
+    create(left, value, right)
   }
 }
 
@@ -159,7 +321,7 @@ pub fn[A : Compare] SortedSet::add(
 }
 
 ///|
-/// Remove n value from the ImmutableSet.
+/// Remove a value from the ImmutableSet.
 ///
 /// # Example
 ///
@@ -898,7 +1060,7 @@ fn[A : Compare] SortedSet::split_bis(
 }
 
 ///|
-/// Get the height of set.
+/// Returns the number of elements in the set.
 #alias(size, deprecated)
 #inline
 pub fn[A] SortedSet::length(self : SortedSet[A]) -> Int {
diff --git a/immut/sorted_set/immutable_set_test.mbt b/immut/sorted_set/immutable_set_test.mbt
index ceb84b0e41..fb190d14b4 100644
--- a/immut/sorted_set/immutable_set_test.mbt
+++ b/immut/sorted_set/immutable_set_test.mbt
@@ -16,6 +16,26 @@
 // The types stored in set need to implement the Compare trait.
 // All operations over sets are purely applicative (no side-effects).
 
+///|
+priv struct SameRankValue {
+  rank : Int
+  label : String
+}
+
+///|
+/// `Compare` orders on `rank` alone, so `Eq` has to agree with it: `compare`
+/// returning zero must mean equal, or the `Compare : Eq` contract is broken.
+/// Tests read `label` directly rather than through `==`, so it stays
+/// observable.
+impl Eq for SameRankValue with fn equal(self, other) {
+  self.rank == other.rank
+}
+
+///|
+impl Compare for SameRankValue with fn compare(self, other) {
+  self.rank.compare(other.rank)
+}
+
 ///|
 test "new" {
   let empty : @sorted_set.SortedSet[Int] = @sorted_set.new()
@@ -341,6 +361,48 @@ test "SortedSet constructor" {
   )
 }
 
+///|
+test "SortedSet ordered inputs" {
+  @test.assert_eq(@sorted_set.SortedSet([1, 2, 3]).to_array(), [1, 2, 3])
+  @test.assert_eq(@sorted_set.SortedSet([3, 2, 1]).to_array(), [1, 2, 3])
+}
+
+///|
+test "SortedSet ordered duplicates" {
+  @test.assert_eq(@sorted_set.SortedSet([1, 1, 2, 3]).to_array(), [1, 2, 3])
+}
+
+///|
+test "SortedSet ordered duplicates keep reverse insertion representative" {
+  let set = @sorted_set.SortedSet([
+    { rank: 1, label: "first", },
+    { rank: 1, label: "second", },
+    { rank: 2, label: "third", },
+  ])
+  @test.assert_eq(set.to_array().map(v => v.label), ["second", "third"])
+}
+
+///|
+test "SortedSet large unordered duplicates keep reverse insertion representative" {
+  let values = Array::makei(70, i => {
+    if i == 5 {
+      { rank: 10, label: "first", }
+    } else if i == 50 {
+      { rank: 10, label: "second", }
+    } else {
+      { rank: 1000 - i, label: "other-\{i}", }
+    }
+  })
+  let set = @sorted_set.SortedSet(values)
+  let matched = []
+  for value in set {
+    if value.rank == 10 {
+      matched.push(value.label)
+    }
+  }
+  @test.assert_eq(matched, ["second"])
+}
+
 ///|
 test "remove_min" {
   debug_inspect(
@@ -542,7 +604,10 @@ test "compare" {
   let xss : Array[@sorted_set.SortedSet[Int]] = @quickcheck.samples(5)
   for xs in xss {
     for ys in xss {
-      @test.assert_eq(xs.compare(ys), xs.to_array().compare(ys.to_array()))
+      @test.assert_eq(
+        xs.compare(ys).compare(0),
+        xs.to_array().compare(ys.to_array()).compare(0),
+      )
     }
   }
 }
@@ -731,17 +796,50 @@ test "disjoint with different sets" {
 ///|
 test "from_iter multiple elements iter" {
   debug_inspect(
-    @sorted_set.from_iter([1, 2, 3].iter()),
+    @sorted_set.from_iter([|1, 2, 3|]),
     content=(
       #|
     ),
   )
 }
 
+///|
+test "from_iter ordered duplicates keep first representative" {
+  let set = @sorted_set.from_iter(
+    [
+      { rank: 1, label: "first", },
+      { rank: 1, label: "second", },
+      { rank: 2, label: "third", },
+    ].iter(),
+  )
+  @test.assert_eq(set.to_array().map(v => v.label), ["first", "third"])
+}
+
+///|
+test "from_iter large unordered duplicates keep first representative" {
+  let values = Array::makei(70, i => {
+    if i == 5 {
+      { rank: 10, label: "first", }
+    } else if i == 50 {
+      { rank: 10, label: "second", }
+    } else {
+      { rank: 1000 - i, label: "other-\{i}", }
+    }
+  })
+  let set = @sorted_set.from_iter(values.iter())
+  let matched = []
+  for value in set {
+    if value.rank == 10 {
+      matched.push(value.label)
+    }
+  }
+  @test.assert_eq(matched, ["first"])
+}
+
 ///|
 test "from_iter single element iter" {
   debug_inspect(
-    @sorted_set.from_iter([1].iter()),
+    @sorted_set.from_iter([|1|]),
     content=(
       #|
     ),
@@ -750,7 +848,7 @@ test "from_iter single element iter" {
 
 ///|
 test "from_iter empty iter" {
-  let pq : @sorted_set.SortedSet[Int] = @sorted_set.from_iter(Iter::empty())
+  let pq : @sorted_set.SortedSet[Int] = @sorted_set.from_iter([||])
   debug_inspect(
     pq,
     content=(
diff --git a/immut/sorted_set/moon.pkg b/immut/sorted_set/moon.pkg
index 8c27fa8f64..793db1a36b 100644
--- a/immut/sorted_set/moon.pkg
+++ b/immut/sorted_set/moon.pkg
@@ -7,6 +7,7 @@ import {
 }
 
 import {
+  "moonbitlang/core/bench",
   "moonbitlang/core/quickcheck",
   "moonbitlang/core/test",
 } for "test"
diff --git a/immut/sorted_set/sorted_set_build_bench_test.mbt b/immut/sorted_set/sorted_set_build_bench_test.mbt
new file mode 100644
index 0000000000..72325df57b
--- /dev/null
+++ b/immut/sorted_set/sorted_set_build_bench_test.mbt
@@ -0,0 +1,96 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+let sorted_set_build_bench_size = 10_000
+
+///|
+fn make_sorted_set_ordered_values() -> Array[Int] {
+  Array::makei(sorted_set_build_bench_size, i => i)
+}
+
+///|
+fn make_sorted_set_reverse_values() -> Array[Int] {
+  Array::makei(sorted_set_build_bench_size, i => {
+    sorted_set_build_bench_size - i - 1
+  })
+}
+
+///|
+fn make_sorted_set_almost_ordered_values() -> Array[Int] {
+  Array::makei(sorted_set_build_bench_size, i => {
+    if i == sorted_set_build_bench_size - 2 {
+      sorted_set_build_bench_size - 1
+    } else if i == sorted_set_build_bench_size - 1 {
+      sorted_set_build_bench_size - 2
+    } else {
+      i
+    }
+  })
+}
+
+///|
+fn make_sorted_set_random_values() -> Array[Int] {
+  Array::makei(sorted_set_build_bench_size, i => {
+    (i * 1103515245 + 12345) & 0x7fffffff
+  })
+}
+
+///|
+test "bench SortedSet constructor reverse n=10000" (it : @bench.T) {
+  let values = make_sorted_set_reverse_values()
+  it.bench(fn() { it.keep(@sorted_set.SortedSet(values)) })
+}
+
+///|
+test "bench SortedSet::from_iter ordered n=10000" (it : @bench.T) {
+  let values = make_sorted_set_ordered_values()
+  it.bench(fn() { it.keep(@sorted_set.from_iter(values.iter())) })
+}
+
+///|
+test "bench SortedSet::from_iter almost ordered n=10000" (it : @bench.T) {
+  let values = make_sorted_set_almost_ordered_values()
+  it.bench(fn() { it.keep(@sorted_set.from_iter(values.iter())) })
+}
+
+///|
+test "bench SortedSet::from_iter rev almost ordered n=10000" (it : @bench.T) {
+  let values = make_sorted_set_almost_ordered_values()
+  it.bench(fn() { it.keep(@sorted_set.from_iter(values.rev_iter())) })
+}
+
+///|
+test "bench SortedSet::from_iter random n=10000" (it : @bench.T) {
+  let values = make_sorted_set_random_values()
+  it.bench(fn() { it.keep(@sorted_set.from_iter(values.iter())) })
+}
+
+///|
+test "bench SortedSet constructor ordered n=10000" (it : @bench.T) {
+  let values = make_sorted_set_ordered_values()
+  it.bench(fn() { it.keep(@sorted_set.SortedSet(values)) })
+}
+
+///|
+test "bench SortedSet constructor almost ordered n=10000" (it : @bench.T) {
+  let values = make_sorted_set_almost_ordered_values()
+  it.bench(fn() { it.keep(@sorted_set.SortedSet(values)) })
+}
+
+///|
+test "bench SortedSet constructor random n=10000" (it : @bench.T) {
+  let values = make_sorted_set_random_values()
+  it.bench(fn() { it.keep(@sorted_set.SortedSet(values)) })
+}
diff --git a/immut/sorted_set/types.mbt b/immut/sorted_set/types.mbt
index b864e44238..d1e12cf9f1 100644
--- a/immut/sorted_set/types.mbt
+++ b/immut/sorted_set/types.mbt
@@ -17,7 +17,10 @@
 // All operations over sets are purely applicative (no side-effects).
 
 ///|
-/// ImmutableSets are represented by balanced binary trees (the heights of the children differ by at most 2).
+/// ImmutableSets are represented by size balanced binary trees: in every subtree
+/// of more than two elements, neither child's size exceeds 5 times the other's.
+/// Subtrees of one or two elements are exempt, so a two-element set may have
+/// children of size 1 and 0.
 enum SortedSet[A] {
   Empty
   Node(left~ : SortedSet[A], right~ : SortedSet[A], size~ : Int, value~ : A)
diff --git a/immut/vector/concat_random_access_test.mbt b/immut/vector/concat_random_access_test.mbt
index 91fcb84345..c33afefefa 100644
--- a/immut/vector/concat_random_access_test.mbt
+++ b/immut/vector/concat_random_access_test.mbt
@@ -56,3 +56,57 @@ test "concat random access matches iteration for many chunkings (issue #3721)" {
     }
   }
 }
+
+///|
+/// Regression test for https://github.com/moonbitlang/core/issues/4086.
+///
+/// `Tree::concat_with_suffix` splices the left vector's tail in as a third
+/// leaf, so the merge behind `rebalance` can hand it `31 + 3 + 31 = 65`
+/// children. Splitting those into a fixed *two* nodes left the second one 33
+/// wide, past `BRANCHING_FACTOR`.
+///
+/// A 33-wide node only misreads once it is also classified radix, because that
+/// is what makes indexing mask the child index with `BRANCHING_FACTOR - 1` and
+/// wrap child 32 back to 0. That needs every one of the 65 leaves full, which
+/// in turn needs the left vector's tail to be a full chunk - so the operand has
+/// to be push-built, at a length whose tree is exactly 32 full leaves and whose
+/// tail is exactly full (1024 + 32). Iteration walks in order rather than by
+/// index, so it stayed correct throughout.
+test "concat of a push-built vector keeps random access (issue #4086)" {
+  for left_len in [1056, 1057, 2080] {
+    let mut left : @vector.Vector[Int] = @vector.new()
+    for i in 0.. left_len + i))
+      let total = left_len + right_len
+      let expected = Array::makei(total, i => i)
+      @debug.assert_eq(joined.length(), total)
+      @debug.assert_eq(joined.iter().to_array(), expected)
+      @debug.assert_eq(Array::makei(total, j => joined[j]), expected)
+    }
+  }
+}
+
+///|
+/// The same 65-wide merge reached with bulk-built operands. These do *not*
+/// misread even with the old `rebalance`: the resulting 33-wide node holds a
+/// partial leaf, so it keeps its sizes array and indexing goes through the
+/// search path rather than the masking one. Kept as a wider guard - the shape
+/// is one reclassification away from breaking - with the structural check
+/// living in `invariants_wbtest.mbt`, which does fail on the 33-wide node.
+test "concat random access across large operands (issue #4086)" {
+  for left_len in [1025, 1056, 1057, 1088, 2049, 2080] {
+    for right_len in [992, 1024, 1056] {
+      let left = @vector.makei(left_len, i => i)
+      let right = @vector.makei(right_len, i => left_len + i)
+      let joined = left.concat(right)
+      let total = left_len + right_len
+      let expected = Array::makei(total, i => i)
+      @debug.assert_eq(joined.length(), total)
+      @debug.assert_eq(joined.iter().to_array(), expected)
+      @debug.assert_eq(Array::makei(total, j => joined[j]), expected)
+    }
+  }
+}
diff --git a/immut/vector/deprecated.mbt b/immut/vector/deprecated.mbt
index 216401e456..a83e472653 100644
--- a/immut/vector/deprecated.mbt
+++ b/immut/vector/deprecated.mbt
@@ -13,9 +13,8 @@
 // limitations under the License.
 
 ///|
-/// Physically copy the vector.
-/// Since it is an immutable data structure,
-/// it is rarely the case that you would need this function.
+/// Returns the vector itself. Since it is an immutable data structure,
+/// nothing is copied and there is no reason to call this function.
 /// 
 #deprecated("We don't copy immutable vector")
 #coverage.skip
diff --git a/immut/vector/invariants_wbtest.mbt b/immut/vector/invariants_wbtest.mbt
new file mode 100644
index 0000000000..67fd9826cd
--- /dev/null
+++ b/immut/vector/invariants_wbtest.mbt
@@ -0,0 +1,491 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/// Structural invariant checks for the RRB tree behind `Vector`.
+///
+/// The property tests in `quickcheck_test.mbt` compare a vector against an
+/// `Array` model, which catches wrong *elements*. They cannot see a vector
+/// whose contents are currently right but whose tree is malformed — a stale
+/// `sizes` array, a leaf above shift 0, a radix (`None`) node that is not
+/// actually radix-indexable. Those only surface later, from a *different*
+/// operation, as a wrong answer or an `abort`. `check_invariants` closes that
+/// gap by validating the representation itself after every step.
+
+//-----------------------------------------------------------------------------
+// The checker
+//-----------------------------------------------------------------------------
+
+///|
+/// Recompute the number of elements under `t` from the leaves up, ignoring
+/// every cached `sizes` array. This is the reference `Tree::size` is checked
+/// against.
+fn[A] true_size(t : Tree[A]) -> Int {
+  match t {
+    Empty => 0
+    Leaf(l) => l.length()
+    Node(children, _) => children.fold(init=0, (acc, c) => acc + true_size(c))
+  }
+}
+
+///|
+/// `Tree::get`/`Tree::set` descend a `Node(_, None)` with `get_radix`, which
+/// aborts as soon as it meets a node carrying explicit sizes. So a radix node
+/// is only usable if its *whole* subtree is radix.
+fn[A] all_radix(t : Tree[A]) -> Bool {
+  match t {
+    Empty | Leaf(_) => true
+    Node(_, Some(_)) => false
+    Node(children, None) =>
+      for c in children {
+        guard all_radix(c) else { break false }
+      } nobreak {
+        true
+      }
+  }
+}
+
+///|
+/// Is there a node that is exactly full yet still carries a sizes array?
+///
+/// Such a node is legal — `append_right_leaf` and `push_end` patch a relaxed
+/// node's sizes as it grows and never re-derive them — but it is the shape
+/// that used to make `compute_sizes` mark the parent radix. Kept so the
+/// regression test below can assert it really built one.
+fn[A] has_full_but_relaxed(t : Tree[A], shift : Int) -> Bool {
+  match t {
+    Empty | Leaf(_) => false
+    Node(children, sizes) => {
+      if sizes is Some(_) && true_size(t) == BRANCHING_FACTOR << shift {
+        return true
+      }
+      for c in children {
+        guard !has_full_but_relaxed(c, shift - NUM_BITS) else { break true }
+      } nobreak {
+        false
+      }
+    }
+  }
+}
+
+///|
+/// `@test.assert_true` takes no message and a bare `fail` inside a loop loses
+/// the index, so thread the description through explicitly.
+fn ensure(cond : Bool, msg : String) -> Unit raise {
+  guard cond else { fail(msg) }
+}
+
+///|
+/// Walk the tree, checking every structural invariant. `path` names the node
+/// being checked so a failure points at it.
+fn[A] check_tree(t : Tree[A], shift : Int, path : String) -> Unit raise {
+  match t {
+    Empty => fail("\{path}: Empty may only appear as the whole tree")
+    Leaf(l) => {
+      ensure(shift == 0, "\{path}: Leaf at shift \{shift}, expected 0")
+      ensure(l.length() > 0, "\{path}: empty Leaf")
+      ensure(
+        l.length() <= BRANCHING_FACTOR,
+        "\{path}: Leaf holds \{l.length()} > \{BRANCHING_FACTOR} elements",
+      )
+    }
+    Node(children, sizes) => {
+      let len = children.length()
+      ensure(
+        shift >= NUM_BITS,
+        "\{path}: Node at shift \{shift}, expected >= \{NUM_BITS}",
+      )
+      ensure(len > 0, "\{path}: Node with no children")
+      ensure(
+        len <= BRANCHING_FACTOR,
+        "\{path}: Node has \{len} > \{BRANCHING_FACTOR} children",
+      )
+      let child_shift = shift - NUM_BITS
+      let full_child = BRANCHING_FACTOR << child_shift
+      match sizes {
+        Some(sz) => {
+          ensure(
+            sz.length() == len,
+            "\{path}: sizes has \{sz.length()} entries for \{len} children",
+          )
+          let mut acc = 0
+          for i in 0.. {
+          // Radix indexing assumes every child but the last is exactly full...
+          for i in 0.. 0 && sz <= full_child,
+                "\{path}: radix last child holds \{sz}, expected 1..=\{full_child}",
+              )
+            }
+          }
+          // ...and that no descendant needs a sizes array, because `get_radix`
+          // aborts on the first one it meets.
+          ensure(
+            all_radix(t),
+            "\{path}: radix (None) node has a descendant carrying sizes; `get_radix` aborts on it",
+          )
+        }
+      }
+      ensure(
+        t.size(shift) == true_size(t),
+        "\{path}: Tree::size = \{t.size(shift)}, recomputed \{true_size(t)}",
+      )
+      for i in 0.. Unit raise {
+  ensure(v.size >= 0, "\{what}: negative size \{v.size}")
+  ensure(
+    v.tail.length() <= BRANCHING_FACTOR,
+    "\{what}: tail holds \{v.tail.length()} > \{BRANCHING_FACTOR} elements",
+  )
+  ensure(v.shift >= 0, "\{what}: negative shift \{v.shift}")
+  ensure(
+    v.shift % NUM_BITS == 0,
+    "\{what}: shift \{v.shift} is not a multiple of \{NUM_BITS}",
+  )
+  match v.tree {
+    Empty =>
+      ensure(
+        v.shift == 0,
+        "\{what}: Empty tree with shift \{v.shift}, expected 0",
+      )
+    _ => check_tree(v.tree, v.shift, "\{what}.tree")
+  }
+  let tree_size = true_size(v.tree)
+  ensure(
+    v.size == tree_size + v.tail.length(),
+    "\{what}: size \{v.size} != tree \{tree_size} + tail \{v.tail.length()}",
+  )
+}
+
+///|
+/// Read every index through the public path, which is what a malformed tree
+/// eventually breaks.
+fn check_contents(
+  v : Vector[Int],
+  model : Array[Int],
+  what : String,
+) -> Unit raise {
+  ensure(
+    v.length() == model.length(),
+    "\{what}: length \{v.length()} != model \{model.length()}",
+  )
+  for i in 0.. Vector[Int] {
+  makei(1024, i => i)
+  .slice(0, 1020)
+  .concat(makei(4, i => 1020 + i))
+  .concat(makei(30, i => 1024 + i))
+}
+
+///|
+/// `compute_sizes` used to mark a node radix from its children's *sizes*
+/// alone. Feeding it the full-but-relaxed node above (all children full) made
+/// it drop the parent's sizes array, and the radix descent in `get_radix`
+/// then hit a child that still had one and aborted with
+/// "Node should not have sizes in get_radix".
+test "radix classification survives a full-but-relaxed node" {
+  let base = full_but_relaxed_vector()
+  ensure(
+    has_full_but_relaxed(base.tree, base.shift),
+    "base: expected a full node that still carries sizes",
+  )
+  check_invariants(base, "base")
+
+  // Grow until the root has three children: the full-but-relaxed node, a
+  // second full node, and a third holding a single leaf.
+  let mut v = base
+  let model = Array::makei(base.size, i => i)
+  for k in 0..<1027 {
+    v = v.push(base.size + k)
+    model.push(base.size + k)
+  }
+  check_invariants(v, "grown")
+
+  // Popping the last element empties that third child, so the root is rebuilt
+  // from two children that are both exactly full.
+  let p = v.pop().unwrap()
+  let _ = model.pop()
+  ensure(
+    has_full_but_relaxed(p.tree, p.shift),
+    "popped: expected the full-but-relaxed node to survive the pop",
+  )
+  check_invariants(p, "popped")
+  check_contents(p, model, "popped")
+
+  // The reads that used to abort.
+  @test.assert_eq(p[0], 0)
+  @test.assert_eq(p[500], 500)
+  @test.assert_eq(p[p.length() - 1], model[model.length() - 1])
+  let updated = p.set(0, -1)
+  check_invariants(updated, "set")
+  @test.assert_eq(updated[0], -1)
+  @test.assert_eq(p[0], 0)
+  // `take` at the same boundary rebuilds the root the same way
+  let t = v.take(2048)
+  check_invariants(t, "take")
+  @test.assert_eq(t[0], 0)
+  @test.assert_eq(t.length(), 2048)
+}
+
+//-----------------------------------------------------------------------------
+// Regression: a short remainder attached one level too high
+//-----------------------------------------------------------------------------
+
+///|
+/// `makei`, `make` and `from_iter` all build their tree with `from_leaves`,
+/// whose height is fixed by the capacity it is given. It used to take a
+/// shortcut whenever at most `BRANCHING_FACTOR` leaves were left, building a
+/// height-1 node no matter how tall the subtree should have been. From three
+/// levels up (> 32768 elements) with a remainder of at most 1024 elements,
+/// that attached a leaf above shift 0: radix descent then read the wrong
+/// element and `Tree::size` reported garbage — `makei(32832, i => i)[32800]`
+/// returned 32768.
+test "bulk constructors put every leaf at the bottom" {
+  let level3 = BRANCHING_FACTOR * BRANCHING_FACTOR * BRANCHING_FACTOR
+  for
+    n in [
+      level3,
+      level3 + BRANCHING_FACTOR,
+      level3 + 2 * BRANCHING_FACTOR,
+      level3 + 1024,
+      level3 + 1032,
+      level3 + 2048,
+      2 * level3 + BRANCHING_FACTOR,
+    ] {
+    for mode in 0..<3 {
+      let v = match mode {
+        0 => makei(n, i => i)
+        1 => from_iter(Array::makei(n, i => i).iter())
+        _ => make(n, 7)
+      }
+      let label = "n=\{n} mode=\{mode}"
+      check_invariants(v, label)
+      ensure(v.length() == n, "\{label}: length \{v.length()}")
+      if mode != 2 {
+        check_contents(v, Array::makei(n, i => i), label)
+      }
+    }
+  }
+}
+
+//-----------------------------------------------------------------------------
+// Regression: a node wider than the branching factor
+//-----------------------------------------------------------------------------
+
+///|
+/// `rebalance` used to split a wide redistribution result into exactly two
+/// nodes, which holds for `Tree::concat` (its leaf case hands over a center of
+/// at most 2 children, so the merge is at most `31 + 2 + 31 = 64`) but not for
+/// `Tree::concat_with_suffix`, whose center can be three leaves. At `65` the
+/// second node came out 33 wide, and `radix_indexing` masks the child index
+/// with `BITMASK`, so index 32 of that node wrapped back to 0.
+test "concat never builds a node wider than the branching factor" {
+  for l in [1025, 1056, 1057, 1088, 2049, 2080, 2081] {
+    for r in [992, 1024, 1056] {
+      let joined = makei(l, i => i).concat(makei(r, i => l + i))
+      check_invariants(joined, "l=\{l} r=\{r}")
+      check_contents(joined, Array::makei(l + r, i => i), "l=\{l} r=\{r}")
+    }
+  }
+}
+
+//-----------------------------------------------------------------------------
+// Randomized structural sweep
+//-----------------------------------------------------------------------------
+
+///|
+/// Build `offset ..< offset + len` through one of the construction paths, so
+/// the sweep sees the different tree shapes each of them leaves behind.
+fn build_by(mode : Int, len : Int, offset : Int) -> Vector[Int] {
+  match mode % 5 {
+    0 => makei(len, i => offset + i)
+    1 => {
+      let mut v : Vector[Int] = new()
+      for i in 0.. from_iter(Array::makei(len, i => offset + i).iter())
+    3 => makei(len + 40, i => offset + i - 17).slice(17, 17 + len)
+    // a take that ends inside the tree leaves a partial right-most leaf and an
+    // empty tail: the shape that feeds `append_right_leaf`'s top-up path
+    _ => makei(len + BRANCHING_FACTOR * 3, i => offset + i).take(len)
+  }
+}
+
+///|
+fn replace_model(model : Array[Int], next : Array[Int]) -> Unit {
+  model.clear()
+  for x in next {
+    model.push(x)
+  }
+}
+
+///|
+test "tree invariants hold across mixed operation sequences" {
+  let rng = Random(Ref(20260816UL))
+  for trial in 0..<60 {
+    let mut v : Vector[Int] = new()
+    let model : Array[Int] = []
+    let mut next = 0
+    for step in 0..<20 {
+      let op = rng.int(limit=7)
+      match op {
+        0 => {
+          // a run of pushes, so the right spine actually grows
+          let n = 1 + rng.int(limit=70)
+          for _ in 0.. {
+          let len = rng.int(limit=1200)
+          v = v.concat(build_by(rng.int(limit=5), len, next))
+          for i in 0.. {
+          // tail-only concat: routes through `normalize_tree`
+          let len = 1 + rng.int(limit=BRANCHING_FACTOR)
+          v = v.concat(makei(len, i => next + i))
+          for i in 0..
+          if model.length() > 0 {
+            let i = rng.int(limit=model.length())
+            v = v.set(i, next)
+            model[i] = next
+            next += 1
+          }
+        4 =>
+          for _ in 0..<(1 + rng.int(limit=40)) {
+            if model.length() > 0 {
+              v = v.pop().unwrap()
+              let _ = model.pop()
+            }
+          }
+        5 => {
+          let n = model.length()
+          let s = if n == 0 { 0 } else { rng.int(limit=n + 1) }
+          let e = if n == 0 { 0 } else { s + rng.int(limit=n - s + 1) }
+          v = v.slice(s, e)
+          replace_model(model, model[s:e].to_owned())
+        }
+        _ => {
+          // cut at a chunk boundary: `slice_right` can then return a child
+          // untouched and hand `compute_sizes` an all-full children array
+          let n = model.length()
+          let k = if n == 0 {
+            0
+          } else {
+            min(n, rng.int(limit=n / BRANCHING_FACTOR + 2) * BRANCHING_FACTOR)
+          }
+          v = v.take(k)
+          replace_model(model, model[0:k].to_owned())
+        }
+      }
+      check_invariants(v, "trial \{trial} step \{step} op \{op}")
+      check_contents(v, model, "trial \{trial} step \{step} op \{op}")
+    }
+  }
+}
+
+///|
+/// `normalize_tree` reaches `Tree::push_end` only when the left vector's tail
+/// holds a single element, which forces the right vector to be exactly one
+/// full chunk. Nothing used to construct that pair, so the whole of
+/// `push_end` was dead in the test suite.
+test "single-element tail routes through push_end" {
+  let mut exercised = 0
+  for base in [33, 65, 97, 1025, 1057, 2049] {
+    for mode in 0..<5 {
+      let v = build_by(mode, base, 0)
+      if v.tail.length() != 1 {
+        continue
+      }
+      exercised += 1
+      let c = v.concat(makei(BRANCHING_FACTOR, i => 1000000 + i))
+      check_invariants(c, "push_end base=\{base} mode=\{mode}")
+      check_contents(
+        c,
+        Array::makei(base + BRANCHING_FACTOR, i => {
+          if i < base {
+            i
+          } else {
+            1000000 + i - base
+          }
+        }),
+        "push_end base=\{base} mode=\{mode}",
+      )
+      // the result must keep behaving
+      check_invariants(c.push(-1), "push_end then push")
+      check_invariants(c.pop().unwrap(), "push_end then pop")
+      check_invariants(
+        c.concat(makei(1000, i => 5000000 + i)),
+        "push_end then concat",
+      )
+    }
+  }
+  ensure(exercised > 0, "no single-element-tail vector was built")
+}
diff --git a/immut/vector/moon.pkg b/immut/vector/moon.pkg
index 2672a8f5eb..234e9d4814 100644
--- a/immut/vector/moon.pkg
+++ b/immut/vector/moon.pkg
@@ -7,6 +7,7 @@ import {
 
 import {
   "moonbitlang/core/bench",
+  "moonbitlang/core/json",
   "moonbitlang/core/quickcheck",
   "moonbitlang/core/test",
 } for "test"
diff --git a/immut/vector/overflow_wbtest.mbt b/immut/vector/overflow_wbtest.mbt
new file mode 100644
index 0000000000..c33e737aa2
--- /dev/null
+++ b/immut/vector/overflow_wbtest.mbt
@@ -0,0 +1,89 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+test "tree shift is derived from leaf count without element-capacity overflow" {
+  @debug.debug_inspect(
+    [
+      shift_of_leaf_count(1),
+      shift_of_leaf_count(32),
+      shift_of_leaf_count(33),
+      shift_of_leaf_count(33_554_432),
+      shift_of_leaf_count(33_554_433),
+      shift_of_leaf_count(67_108_863),
+    ],
+    content="[0, 5, 10, 25, 30, 30]",
+  )
+}
+
+///|
+test "tree element count is derived from leaf count" {
+  @debug.debug_inspect(
+    [
+      tree_len_of_leaf_count(0),
+      tree_len_of_leaf_count(1),
+      tree_len_of_leaf_count(33_554_432),
+      tree_len_of_leaf_count(67_108_863),
+    ],
+    content="[0, 32, 1073741824, 2147483616]",
+  )
+}
+
+///|
+test "push accepts the maximum Int length" {
+  let mut v = singleton(false)
+  for _ in 0..<30 {
+    v = v.concat(v).push(false)
+  }
+  inspect(v.length(), content="2147483647")
+  inspect(v.is_empty(), content="false")
+  inspect(
+    v[0],
+    content=(
+      #|false
+    ),
+  )
+  inspect(
+    v[2_147_483_646],
+    content=(
+      #|false
+    ),
+  )
+}
+
+///|
+test "concat accepts the maximum Int length" {
+  let mut left = singleton(false)
+  for _ in 0..<30 {
+    left = left.concat(left)
+  }
+  let mut right = singleton(false)
+  for _ in 0..<29 {
+    right = right.concat(right).push(false)
+  }
+  let v = left.concat(right)
+  inspect(v.length(), content="2147483647")
+  inspect(
+    v[0],
+    content=(
+      #|false
+    ),
+  )
+  inspect(
+    v[2_147_483_646],
+    content=(
+      #|false
+    ),
+  )
+}
diff --git a/immut/vector/panic_test.mbt b/immut/vector/panic_test.mbt
index 82f953f7e9..c3337cdd1f 100644
--- a/immut/vector/panic_test.mbt
+++ b/immut/vector/panic_test.mbt
@@ -50,3 +50,21 @@ test "panic at on empty vector" {
   let v : @vector.Vector[Int] = @vector.new()
   v[0] |> ignore
 }
+
+///|
+test "panic concat length overflow" {
+  let mut v = @vector.singleton(true)
+  for _ in 0..<30 {
+    v = v.concat(v)
+  }
+  v.concat(v) |> ignore
+}
+
+///|
+test "panic push length overflow" {
+  let mut v = @vector.singleton(true)
+  for _ in 0..<30 {
+    v = v.concat(v).push(true)
+  }
+  v.push(true) |> ignore
+}
diff --git a/immut/vector/panic_wbtest.mbt b/immut/vector/panic_wbtest.mbt
index 89d277b1ef..1cc1571675 100644
--- a/immut/vector/panic_wbtest.mbt
+++ b/immut/vector/panic_wbtest.mbt
@@ -17,3 +17,8 @@ test "panic get on empty tree should panic" {
   let tree : Tree[Int] = Empty
   tree.get(0, 5) |> ignore
 }
+
+///|
+test "panic leaf count whose element count overflows" {
+  tree_len_of_leaf_count(67_108_864) |> ignore
+}
diff --git a/immut/vector/quickcheck_test.mbt b/immut/vector/quickcheck_test.mbt
index 6d8a00fe81..e4143fad49 100644
--- a/immut/vector/quickcheck_test.mbt
+++ b/immut/vector/quickcheck_test.mbt
@@ -21,26 +21,47 @@
 ///|
 /// Map a random seed to a length, biased toward the tree-shape boundaries:
 /// an empty vector, a tail-only vector, one full leaf plus/minus one, two
-/// leaves, and the depth-two transition around 1024.
+/// leaves, the depth-two transition around 1024, and the merge widths around
+/// 1056 and 2080 that `concat` rebalancing is sensitive to.
 fn pick_len(seed : Int) -> Int {
   let n = seed & 0x7fffffff
   let boundaries : ReadOnlyArray[Int] = [
-    0, 1, 2, 31, 32, 33, 63, 64, 65, 1023, 1024, 1025,
+    0, 1, 2, 31, 32, 33, 63, 64, 65, 1023, 1024, 1025, 1055, 1056, 1057, 2048, 2080,
   ]
   if n % 3 != 0 {
     boundaries[n / 3 % boundaries.length()]
   } else {
-    n / 3 % 1200
+    n / 3 % 2200
   }
 }
 
 ///|
 /// Build a vector holding `offset, offset + 1, ..., offset + len - 1` by one
-/// of three construction paths: bulk `makei`, element-wise `push`, or slicing
-/// out of a larger vector — the last one leaves behind the irregular tree
-/// shapes that stress `concat`'s rebalancing.
+/// of seven construction paths.
+///
+/// This is what stands between these properties and the `Arbitrary` instance,
+/// which builds every vector with `from_array` and so only ever produces the
+/// bulk-constructor shape, at sizes far below the level boundaries. The three
+/// bugs this package has had (#4083, #4084, #4086) all sat outside that reach
+/// — #4083 and #4086 in relaxed and trimmed shapes `from_array` cannot build,
+/// #4084 in a `from_array` shape the generator's sizes never got near. The
+/// modes:
+///
+/// - 0: bulk `makei` — the `Arbitrary` shape;
+/// - 1: element-wise `push` — tail-flush spine growth;
+/// - 2: `slice` out of a larger vector — partial leaves at both edges;
+/// - 3: `concat` of uneven chunks, so the seams fall on no particular
+///   boundary and the rebalancing planner runs as the tree grows;
+/// - 4: `take` from a larger vector — an empty tail at *unaligned* lengths
+///   (`from_array` leaves one too, but only at multiples of 32);
+/// - 5: `pop` back down from a larger vector — tail refilled out of the tree;
+/// - 6: a push-built prefix ending on a full tail, joined to the rest by one
+///   `concat` — the operand pair that drives `concat_with_suffix`'s widest
+///   merges (65 children at `cut = 1056`, the #4086 family).
+///
+/// Modes 2, 3, 4 and 6 all leave relaxed (`Some(sizes)`) nodes behind.
 fn build_vec(len : Int, offset : Int, mode : Int) -> @vector.Vector[Int] {
-  match (mode & 0x7fffffff) % 3 {
+  match (mode & 0x7fffffff) % 7 {
     0 => @vector.makei(len, i => offset + i)
     1 => {
       let mut v : @vector.Vector[Int] = @vector.new()
@@ -49,7 +70,38 @@ fn build_vec(len : Int, offset : Int, mode : Int) -> @vector.Vector[Int] {
       }
       v
     }
-    _ => @vector.makei(len + 50, i => offset + i - 33).slice(33, 33 + len)
+    2 => @vector.makei(len + 50, i => offset + i - 33).slice(33, 33 + len)
+    3 => {
+      let mut v : @vector.Vector[Int] = @vector.new()
+      for lo = 0; lo < len; {
+        let step = 1 + (lo * 7 + 13) % 47
+        let hi = if lo + step > len { len } else { lo + step }
+        v = v.concat(@vector.makei(hi - lo, i => offset + lo + i))
+        continue hi
+      }
+      v
+    }
+    4 => @vector.makei(len + 97, i => offset + i).take(len)
+    5 => {
+      let mut v = @vector.makei(len + 40, i => offset + i)
+      for _ in 0..<40 {
+        v = v.pop().unwrap()
+      }
+      v
+    }
+    _ => {
+      // A push-built vector whose length is a multiple of 32 carries a full
+      // tail, which `concat_with_suffix` splices in as a third centre leaf.
+      // At `cut = 1056` (a full 1024-tree plus a full tail) against a right
+      // operand of 1024+, that merge is 31 + 3 + 31 = 65 children wide — the
+      // widest the splice can produce, and the one #4086 mishandled.
+      let cut = if len >= 2080 { 1056 } else { len / 32 * 32 }
+      let mut v : @vector.Vector[Int] = @vector.new()
+      for i in 0.. offset + cut + i))
+    }
   }
 }
 
@@ -294,6 +346,123 @@ test "quickcheck: pop walks back through every push boundary" {
   )
 }
 
+///|
+/// `slice`, tail-overflowing `concat`, `push` and `pop` each rebuild the tree
+/// a different way, and only their *combination* reaches some node shapes: a
+/// slice leaves a partial right-most leaf, a tail-overflowing concat folds the
+/// tail into it, pushes fill the level up, and a pop then rebuilds the root
+/// from what is left. Random access after that pipeline is the cheapest way to
+/// notice from outside the package that the tree came out malformed — a
+/// mis-rebuilt node shows up as a wrong element or an abort, not as a wrong
+/// length.
+test "quickcheck: random access survives slice/concat/push/pop pipelines" {
+  // A chunk is one leaf, a node is one full level of the trie. Which rebuild
+  // path each step takes is decided by alignment to these, so the seeds below
+  // become offsets *from* them rather than free lengths - a uniformly random
+  // length almost never lands on a transition.
+  let chunk = 32
+  let node = chunk * chunk
+  let offsets : ReadOnlyArray[Int] = [0, 1, chunk, chunk + 1, 2 * chunk]
+  @quickcheck.check(
+    (seeds : (Int, Int, Int, Int)) => {
+      let (s0, s1, s2, s3) = seeds
+      // `gap` leaves the tree's right-most leaf `chunk - gap` long...
+      let gap = 1 + (s1 & 0x7fffffff) % (chunk - 1)
+      let base = node * (1 + (s0 & 0x7fffffff) % 2)
+      // ...so a concat of `gap` parks exactly that many elements in the tail,
+      // and a second one that overflows the tail makes `normalize_tree` fold
+      // the first back in, topping the partial leaf up to a full `chunk`.
+      let second = chunk - gap + 1 + (s2 & 0x7fffffff) % gap
+      let model : Array[Int] = []
+      let mut v = @vector.makei(base, i => i).slice(0, base - gap)
+      for i in 0..<(base - gap) {
+        model.push(i)
+      }
+      for len in [gap, second] {
+        let start = 1000000 + model.length()
+        v = v.concat(@vector.makei(len, i => start + i))
+        for i in 0.. {
+      let (s0, s1) = seed
+      let n = level * (1 + (s0 & 0x7fffffff) % 2) +
+        offsets[(s1 & 0x7fffffff) % offsets.length()]
+      let model = Array::makei(n, i => i)
+      let built = [
+        @vector.makei(n, i => i),
+        @vector.from_iter(model.iter()),
+        @vector.from_array(model),
+      ]
+      for v in built {
+        guard v.length() == n else { return false }
+        guard v.to_array() == model else { return false }
+        // `to_array` walks the tree in order, so it stays right even when the
+        // shape is wrong; indexing is what actually descends by radix
+        for i = 0, step = 1; i < n; {
+          guard v.at(i) == i else { return false }
+          continue i + step, step + 1
+        }
+        // the boundary itself, and every index of the short remainder
+        let remainder_start = n - n % level
+        for i in remainder_start.. {
@@ -308,3 +477,153 @@ test "quickcheck: Eq and Compare agree with the array model" {
     a == a && a.compare(a) == 0 && a == rebuilt && a.compare(rebuilt) == 0
   })
 }
+
+///|
+/// One content, seven construction paths: whatever shape the tree took, the
+/// vector must be indistinguishable through the public API. The per-index
+/// reads are the point — this package's bugs have all been trees that kept
+/// iterating correctly (iteration walks in order) while `at`/`get` descended
+/// by radix into the wrong slot.
+///
+/// This property sweeps that observable across random shapes; it is not the
+/// deterministic guard for the known bugs. Those live elsewhere: the #4083
+/// pipeline and the #4084 level boundary have dedicated properties above, and
+/// the #4086 merge has its regression in `concat_random_access_test.mbt`
+/// (mode 6 can reproduce that operand pair, but only when the seeds line up).
+/// `contains` is here too, since no other property exercises it.
+test "quickcheck: the same content is one vector, whatever built it" {
+  @quickcheck.check(
+    (seeds : (Int, Int, Int)) => {
+      let (sl, sa, sb) = seeds
+      let n = pick_len(sl)
+      let a = build_vec(n, 7, sa)
+      let b = build_vec(n, 7, sb)
+      let model = Array::makei(n, i => 7 + i)
+      guard a == b && a.compare(b) == 0 && a.hash() == b.hash() else {
+        return false
+      }
+      guard a.to_array() == model && a.iter().to_array() == model else {
+        return false
+      }
+      for i in 0.. 0 {
+        guard a.contains(7) && a.contains(7 + n - 1) else { return false }
+      }
+      guard !a.contains(6) && !a.contains(7 + n) else { return false }
+      a.peek() == (if n == 0 { None } else { Some(7 + n - 1) })
+    },
+    count=60,
+  )
+}
+
+///|
+/// `set` has a radix branch and a search branch, and the existing set
+/// property only ever feeds it `from_array` vectors — dense trees whose
+/// search branch never runs. Irregular shapes exercise both.
+test "quickcheck: set agrees with the array model on relaxed trees" {
+  @quickcheck.check(
+    (input : (Int, Int, Array[(Int, Int)])) => {
+      let (sl, sm, updates) = input
+      let n = pick_len(sl)
+      guard n > 0 else { return true }
+      let v = build_vec(n, 0, sm)
+      let model = Array::makei(n, i => i)
+      let mut cur = v
+      for u in updates {
+        let (i_raw, x) = u
+        let i = (i_raw & 0x7fffffff) % n
+        cur = cur.set(i, x)
+        model[i] = x
+        guard cur.at(i) == x else { return false }
+      }
+      guard cur.to_array() == model else { return false }
+      // path copying must not have touched the original
+      v.to_array() == Array::makei(n, i => i)
+    },
+    count=40,
+  )
+}
+
+///|
+/// Taking a slice of a slice is the same as taking the composed slice
+/// directly. Slicing a slice re-runs `slice_left`/`slice_right` over the
+/// truncated, size-carrying nodes the first slice produced — arithmetic no
+/// single slice reaches.
+test "quickcheck: slicing composes" {
+  @quickcheck.check(
+    (seeds : (Int, Int, (Int, Int), (Int, Int))) => {
+      let (sl, sm, outer, inner) = seeds
+      let n = pick_len(sl)
+      let v = build_vec(n, 0, sm)
+      let a = (outer.0 & 0x7fffffff) % (n + 1)
+      let b = a + (outer.1 & 0x7fffffff) % (n - a + 1)
+      let c = (inner.0 & 0x7fffffff) % (b - a + 1)
+      let d = c + (inner.1 & 0x7fffffff) % (b - a - c + 1)
+      let twice = v.slice(a, b).slice(c, d)
+      guard twice == v.slice(a + c, a + d) else { return false }
+      twice.to_array() == Array::makei(d - c, i => a + c + i)
+    },
+    count=60,
+  )
+}
+
+///|
+/// `rev` is an involution, and it turns `concat` around.
+test "quickcheck: rev is an involution and reverses concat" {
+  @quickcheck.check(
+    (seeds : (Int, Int, Int, Int)) => {
+      let (sa, ma, sb, mb) = seeds
+      let a = build_vec(pick_len(sa), 0, ma)
+      let b = build_vec(pick_len(sb), 1000000, mb)
+      guard a.rev().rev() == a else { return false }
+      a.concat(b).rev() == b.rev().concat(a.rev())
+    },
+    count=40,
+  )
+}
+
+///|
+/// `Vector::iter` is a hand-written external iterator that keeps its own
+/// descent stack. `Iter` is single-pass, so `next()` must hand out the
+/// elements one at a time and leave the iterator positioned on element `k` —
+/// mid-leaf, mid-node, or in the tail, depending on where `k` falls in the
+/// shape at hand. (`next()` is the resumption primitive here; the adapter
+/// methods like `take` document that the source must not be reused.)
+test "quickcheck: the iterator resumes where it stopped" {
+  @quickcheck.check(
+    (seeds : (Int, Int, Int)) => {
+      let (sl, sm, sk) = seeds
+      let n = pick_len(sl)
+      let v = build_vec(n, 0, sm)
+      let model = Array::makei(n, i => i)
+      let k = if n == 0 { 0 } else { (sk & 0x7fffffff) % (n + 1) }
+      let it = v.iter()
+      guard it.size_hint() == Some(n) else { return false }
+      for i in 0.. {
+      let (sl, sm) = seeds
+      let v = build_vec(pick_len(sl), 0, sm)
+      let back : @vector.Vector[Int] = @json.from_json(Json(v))
+      back == v
+    },
+    count=40,
+  )
+}
diff --git a/immut/vector/tree.mbt b/immut/vector/tree.mbt
index b5af97ad62..fb3cb4683a 100644
--- a/immut/vector/tree.mbt
+++ b/immut/vector/tree.mbt
@@ -13,6 +13,72 @@
 // limitations under the License.
 
 /// A tree data structure that backs the `immut/vector`.
+///
+/// # Representation contract
+///
+/// The shape invariants live on `Tree` in `types.mbt`; this is why they are
+/// what they are. `invariants_wbtest.mbt` checks them mechanically, and is the
+/// place to extend if you add a shape.
+///
+/// ## Two ways down
+///
+/// Indexing has a fast path and a general one, and `sizes` selects between
+/// them:
+///
+/// - `None` — *radix descent* (`get_radix` / `set_radix`). The child index at
+///   each level is read straight out of the index's bits,
+///   `(index >> shift) & BITMASK`. No arithmetic on the way down, no search.
+/// - `Some(cumulative)` — *search descent*. `get_branch_index` looks the child
+///   up in the cumulative sizes, and the index is rebased for the recursion.
+///
+/// `get_radix` **aborts** the moment it meets a node carrying sizes: it has
+/// already consumed the bits for that level, so it cannot switch strategies.
+/// That is the whole reason `None` has to mean "this subtree is radix all the
+/// way down" and not merely "this node is radix" — the property is inherited
+/// by every reader, so it must hold transitively.
+///
+/// ## Full and radix are independent
+///
+/// Neither implies the other, and conflating them has bitten this file twice:
+///
+/// - *Radix does not imply full.* A radix node may have a partial **last**
+///   child; that is exactly what `from_leaves` builds for a vector whose size
+///   is not a power of 32. Radix indexing still works, because only the
+///   children to the left of the target need to be full.
+/// - *Full does not imply radix.* `append_right_leaf` and `push_end` grow a
+///   relaxed node by patching its sizes array (`append_sizes_last`,
+///   `push_sizes_last`, `update_sizes_last`) instead of re-deriving it, so a
+///   node can reach exactly its full size while still carrying sizes. Marking
+///   its parent radix on the strength of the child's *size* is what made `at`
+///   abort in #4083; `compute_sizes` now also requires `Tree::is_radix`.
+///
+/// ## Who may produce `None`
+///
+/// Keeping the transitive property true means every construction site has to
+/// earn it. There are only six:
+///
+/// - `compute_sizes` — the general case; requires every child full **and**
+///   radix.
+/// - `new_branch_left` — only for a full leaf, and it builds the whole spine.
+/// - `radix_append_sizes` — only when a full leaf was appended, and only from
+///   an already-radix node.
+/// - `from_leaves` — builds nothing but radix nodes, top to bottom.
+/// - `append_right_leaf`'s fresh root — only when `self` is already radix and
+///   the appended leaf is full; it checks both inline instead of going through
+///   `radix_append_sizes`.
+/// - `rebalance`'s non-`top` wrapper — the one unchecked site. It is safe only
+///   because the wrapper never escapes: the calling frame feeds it straight to
+///   `tri_merge`, which keeps the child and drops the wrapper. See the comment
+///   there.
+///
+/// ## Height comes from the caller
+///
+/// Nothing in a `Tree` records its own height; every walk is handed a `shift`
+/// and decrements it by `NUM_BITS` per level. So a subtree's height is fixed by
+/// where it is attached, and a builder has to place leaves at the depth the
+/// capacity implies rather than at the depth the remaining data suggests —
+/// attaching a short remainder one level too high is #4084. `Tree::size` makes
+/// the same assumption for radix nodes, so a misplaced leaf corrupts sizes too.
 
 //-----------------------------------------------------------------------------
 // Hyperparameters
@@ -169,7 +235,6 @@ fn[T] Tree::set(self : Tree[T], index : Int, shift : Int, value : T) -> Tree[T]
 /// 
 /// Precondition:
 /// - The height of `self` = `shift` / `NUM_BITS` (the height starts from 0).
-/// - `length` is the number of elements in the tree.
 #owned(value)
 fn[T] Tree::push_end(self : Tree[T], shift : Int, value : T) -> (Tree[T], Int) {
   fn update_sizes_last(sizes : FixedArray[Int]?) -> FixedArray[Int]? {
@@ -442,7 +507,25 @@ fn[A] Tree::concat(
 }
 
 ///|
-/// Given three `Node`s of the same height (`shift` / `NUM_BITS`), rebalance them into two.
+/// Given three `Node`s of the same height (`shift` / `NUM_BITS`), rebalance
+/// them into as few nodes as their children need.
+///
+/// How wide can the merge get? `tri_merge` drops `left`'s last child and
+/// `right`'s first, so it yields
+/// `(left_arity - 1) + center_arity + (right_arity - 1)`, and `redis_plan` only
+/// ever shortens that. With `left_arity` and `right_arity` at most
+/// `BRANCHING_FACTOR`:
+///
+/// - from `Tree::concat`, whose leaf case hands over a two-child center, at
+///   most `31 + 2 + 31 = 64` — two nodes;
+/// - from `Tree::concat_with_suffix`, which splices the left vector's tail in
+///   as a third leaf, at most `31 + 3 + 31 = 65` — three.
+///
+/// So the result is one node, or `ceil(n / BRANCHING_FACTOR)` of them, which is
+/// never more than three. Assuming two was #4086: the leftover 33rd child made
+/// a node wider than the radix index can address, and `radix_indexing` masked
+/// child 32 back to 0.
+///
 /// `top` is `true` if the resulting node has no upper node.
 /// Returns the new node and its shift.
 fn[A] rebalance(
@@ -461,6 +544,12 @@ fn[A] rebalance(
     // All nodes can be accommodated in a single node
     let node = Node(new_t, compute_sizes(new_t, shift - NUM_BITS)) // node of H height
     if !top {
+      // This wrapper is the one place that puts `None` over a child without
+      // checking it, so it would break radix descent if it ever reached a
+      // finished tree. It cannot: `!top` means the caller is another
+      // `Tree::concat`/`concat_with_suffix` frame, whose `rebalance` feeds it
+      // straight to `tri_merge` and keeps only the child. The layer exists
+      // solely to match the height the caller asserts on.
       return (Node(FixedArray::from_array([node]), None), shift + NUM_BITS)
       // return (H+1) height node, add another layer to align with the case at the end of the thisfunction
     } else {
@@ -468,19 +557,20 @@ fn[A] rebalance(
       // return H height node, no upper node so no need to add another layer on top of it
     }
   } else {
-    let new_child_1 = FixedArray::makei(BRANCHING_FACTOR, i => new_t[i])
-    let new_child_2 = FixedArray::makei(new_t.length() - BRANCHING_FACTOR, i => {
-      new_t[i + BRANCHING_FACTOR]
+    // Pack the redistributed children into as many nodes as they need.
+    //
+    // Two is not always enough: `Tree::concat`'s leaf case hands us a center of
+    // at most 2 children, so `tri_merge` yields at most 31 + 2 + 31 = 64 - but
+    // `Tree::concat_with_suffix` splices the left vector's tail in as a third
+    // leaf, and 31 + 3 + 31 = 65 leaves a 33rd child over. A node that wide
+    // breaks radix indexing, which masks the child index with `BITMASK`.
+    let count = (new_t.length() + BRANCHING_FACTOR - 1) / BRANCHING_FACTOR
+    let new_children = FixedArray::makei(count, i => {
+      let lo = i * BRANCHING_FACTOR
+      let hi = min(lo + BRANCHING_FACTOR, new_t.length())
+      let chunk = FixedArray::makei(hi - lo, j => new_t[lo + j])
+      Node(chunk, compute_sizes(chunk, shift - NUM_BITS)) // height H
     })
-    let new_node_1 = Node(
-      new_child_1,
-      compute_sizes(new_child_1, shift - NUM_BITS),
-    ) // height H
-    let new_node_2 = Node(
-      new_child_2,
-      compute_sizes(new_child_2, shift - NUM_BITS),
-    ) // height H
-    let new_children = FixedArray::from_array([new_node_1, new_node_2])
     return (
       Node(new_children, compute_sizes(new_children, shift)),
       shift + NUM_BITS,
@@ -541,6 +631,29 @@ fn[A] tri_merge(
 
 ///|
 /// Create a redistribution plan for the tree.
+///
+/// Returns the per-node child counts and how many of them are live: the first
+/// `new_len` entries of the returned array describe the new nodes, and
+/// anything past that is scratch left over from the merge.
+///
+/// The plan preserves the total number of children and only ever shortens the
+/// list, merging short nodes rightwards until the length is within `e_max_2`
+/// of the optimum. Two things about the loop are worth spelling out, because
+/// both look out of bounds and are not:
+///
+/// - `node_counts[i + 1]` (the carry step) can name the slot just past the live
+///   prefix. It never does. The carry only advances while
+///   `remaining + node_counts[i + 1] > BRANCHING_FACTOR`, so reaching the last
+///   live slot with a carry would mean every slot before it holds a full
+///   `BRANCHING_FACTOR`, hence `opt_len >= new_len - 1` — which contradicts the
+///   `opt_len + e_max_2 < new_len` that let us into the loop at all.
+/// - the leading scan `while node_counts[i] > BRANCHING_FACTOR - e_max_2` is
+///   bounded for the same reason: if every live node were full there would be
+///   no slack and the outer loop would not have been entered.
+///
+/// Termination: each pass drops `new_len` by one and `new_len` is bounded
+/// below by `opt_len + e_max_2`. `i` is only rewound by one per pass, after
+/// having advanced by at least one, so it never goes negative.
 fn[A] redis_plan(t : FixedArray[Tree[A]]) -> (FixedArray[Int], Int) {
   let node_counts = FixedArray::makei(t.length(), i => t[i].local_size())
   let total_nodes = node_counts.fold(init=0, (acc, x) => acc + x)
@@ -578,10 +691,18 @@ fn[A] redis_plan(t : FixedArray[Tree[A]]) -> (FixedArray[Int], Int) {
 /// - forall i in 0..node_nums, old_t[i] != Empty.
 /// - `old_t` contains a list of trees, each of (`shift` / `NUM_BITS`) height.
 /// - `node_counts` contains the number of children of each node in `new_t` (the redistributed version of `old_t`).
-/// - `node_nums` is the length of `node_counts`.
+/// - `node_nums` is the number of live entries at the front of `node_counts` (`redis_plan`'s `new_len`);
+///   `node_counts` itself may be longer, with scratch left over from the merge past that prefix.
 /// 
 /// Postcondition:
 /// - The resulting trees in `new_t` are of the same height as trees in `old_t`.
+///
+/// The `guard! j < old_len` in each branch is a leftover: `old_t[j]` is already
+/// indexed on the line above it, so a plan that overran would fault there
+/// first and the guard can never fire. It is harmless because the only plans
+/// that reach here come from `redis_plan`, which conserves the child total and
+/// emits positive counts, so the cursor is exhausted exactly when the source
+/// is. Do not read it as an active bounds check.
 fn[A] redis(
   old_t : FixedArray[Tree[A]],
   node_counts : FixedArray[Int],
@@ -677,6 +798,27 @@ fn[A] redis(
   new_t
 }
 
+///|
+/// Can `self` be descended by radix indexing, i.e. without consulting a sizes
+/// array?
+///
+/// This is a *shallow* test, and that is enough: a node is only ever built
+/// with `None` when its whole subtree is radix-indexable, so `None` here
+/// implies `None` all the way down.
+///
+/// Note that being exactly full is **not** sufficient. `append_right_leaf`
+/// and `push_end` grow a relaxed node by patching its sizes array, so a node
+/// can reach its full size while still carrying one; `get_radix` aborts the
+/// moment it meets such a node.
+fn[A] Tree::is_radix(self : Tree[A]) -> Bool {
+  match self {
+    Leaf(_) => true
+    Node(_, None) => true
+    Node(_, Some(_)) => false
+    Empty => false
+  }
+}
+
 ///|
 /// Given a list of trees as `children` with heights of (`shift` / `NUM_BITS`), compute the sizes array of the subtrees.
 fn[A] compute_sizes(
@@ -689,8 +831,11 @@ fn[A] compute_sizes(
   let mut flag = true
   let full_subtree_size = BRANCHING_FACTOR << shift
   for i in 0.. FixedArray[A] {
 ///|
 fn[A] Tree::pop_rightmost_leaf(self : Tree[A], shift : Int) -> Tree[A] {
   match self {
-    Empty => Empty
-    Leaf(_) => Empty
+    Empty | Leaf(_) => Empty
     Node(children, _) => {
       let last_index = children.length() - 1
       let new_last = children[last_index].pop_rightmost_leaf(shift - NUM_BITS)
diff --git a/immut/vector/types.mbt b/immut/vector/types.mbt
index 21636018af..fe547b26e6 100644
--- a/immut/vector/types.mbt
+++ b/immut/vector/types.mbt
@@ -18,7 +18,9 @@
 /// - `size` = the total number of elements in `tree` and `tail`.
 /// - `tail` stores the right-most chunk and has at most `BRANCHING_FACTOR` elements.
 /// - `tree` stores the prefix before `tail`.
-/// - `shift` is not used when `tree` is `Empty`.
+/// - `shift` is 0 when `tree` is `Empty`, and otherwise names the height of
+///   `tree` exactly: every operation that walks the tree is handed this `shift`
+///   and decrements it by `NUM_BITS` per level, so a `Leaf` must sit at shift 0.
 struct Vector[A] {
   tree : Tree[A]
   tail : FixedArray[A]
@@ -27,8 +29,26 @@ struct Vector[A] {
 }
 
 ///|
-/// Invariants:
-/// - For `Node`, the sizes array is `None` if the tree is full, i.e., we can use radix indexing.
+/// The RRB tree behind `Vector`.
+///
+/// Invariants (see the "Representation contract" block in `tree.mbt` for the
+/// reasoning, and `invariants_wbtest.mbt` for the executable version):
+///
+/// - `Empty` appears only as a whole tree, never as a child.
+/// - A `Leaf` holds 1..=`BRANCHING_FACTOR` elements and only ever sits at
+///   shift 0.
+/// - A `Node` has 1..=`BRANCHING_FACTOR` children, all of the same height.
+/// - `sizes` is `Some(cumulative)` — a running total, so the last entry is the
+///   subtree's element count — or `None` to mean **the whole subtree can be
+///   descended by radix indexing**: every child but the last is exactly full,
+///   *and* no descendant carries a sizes array of its own.
+///
+/// Note what `None` does **not** mean. It is not "this node is full": a radix
+/// node may have a partial last child (that is what a freshly built tree looks
+/// like), and conversely a node can be exactly full and still carry sizes,
+/// because `append_right_leaf` and `push_end` grow a relaxed node by patching
+/// its sizes array rather than re-deriving it. Treating full as equivalent to
+/// radix is what made `at` abort in #4083.
 #unsafe_cycle_free
 priv enum Tree[A] {
   Empty
diff --git a/immut/vector/vector.mbt b/immut/vector/vector.mbt
index a18bfc1778..cc456c1227 100644
--- a/immut/vector/vector.mbt
+++ b/immut/vector/vector.mbt
@@ -56,8 +56,8 @@ pub fn[A] Vector::make(len : Int, value : A) -> Vector[A] {
       tree_len / BRANCHING_FACTOR,
       FixedArray::make(BRANCHING_FACTOR, value),
     )
-    let (shift, cap) = shift_cap_of_size(tree_len)
-    (from_leaves(leaves, cap), shift)
+    let shift = shift_of_leaf_count(leaves.length())
+    (from_leaves(leaves, shift), shift)
   }
   make_t(tree, tail, len, shift)
 }
@@ -80,8 +80,8 @@ pub fn[A] Vector::makei(len : Int, f : (Int) -> A raise?) -> Vector[A] raise? {
         f(k * BRANCHING_FACTOR + i)
       })
     }
-    let (shift, cap) = shift_cap_of_size(tree_len)
-    (from_leaves(leaves, cap), shift)
+    let shift = shift_of_leaf_count(leaves.length())
+    (from_leaves(leaves, shift), shift)
   }
   make_t(tree, tail, len, shift)
 }
@@ -106,6 +106,9 @@ pub fn[A] Vector::Vector(arr : ArrayView[A]) -> Vector[A] {
 
 ///|
 /// Creates an immutable vector from an iterator of values.
+///
+/// Aborts if the iterator yields more elements than a Vector length can
+/// represent with `Int`.
 #as_free_fn
 #alias(from_iterator, deprecated)
 #as_free_fn(from_iterator, deprecated)
@@ -139,14 +142,14 @@ pub fn[A] Vector::from_iter(iter : Iter[A]) -> Vector[A] {
   } else {
     FixedArray::make_and_blit(buf, allocate_len=index, init=buf[0], len=index)
   }
-  let tree_len = leaves.length() * BRANCHING_FACTOR
+  let tree_len = tree_len_of_leaf_count(leaves.length())
   let (tree, shift) = if tree_len == 0 {
     (Tree::empty(), 0)
   } else {
-    let (shift, cap) = shift_cap_of_size(tree_len)
-    (from_leaves(leaves, cap), shift)
+    let shift = shift_of_leaf_count(leaves.length())
+    (from_leaves(leaves, shift), shift)
   }
-  make_t(tree, tail, tree_len + tail.length(), shift)
+  make_t(tree, tail, checked_size_add(tree_len, tail.length()), shift)
 }
 
 //-----------------------------------------------------------------------------
@@ -314,6 +317,8 @@ pub fn[A] Vector::set(self : Vector[A], index : Int, value : A) -> Vector[A] {
 ///|
 /// Push a value to the end of the vector.
 ///
+/// Aborts if the resulting length cannot be represented by `Int`.
+///
 /// # Example
 /// ```mbt check
 /// test {
@@ -323,20 +328,16 @@ pub fn[A] Vector::set(self : Vector[A], index : Int, value : A) -> Vector[A] {
 /// ```
 #owned(value)
 pub fn[A] Vector::push(self : Vector[A], value : A) -> Vector[A] {
+  let size = checked_size_add(self.size, 1)
   if self.tail.length() < BRANCHING_FACTOR {
-    make_t(
-      self.tree,
-      immutable_push(self.tail, value),
-      self.size + 1,
-      self.shift,
-    )
+    make_t(self.tree, immutable_push(self.tail, value), size, self.shift)
   } else {
     let (tree, shift) = if self.tree is Empty {
       (Leaf(self.tail), 0)
     } else {
       self.tree.append_right_leaf(self.shift, self.tail)
     }
-    make_t(tree, [value], self.size + 1, shift)
+    make_t(tree, [value], size, shift)
   }
 }
 
@@ -393,7 +394,22 @@ pub fn[A] Vector::pop(self : Vector[A]) -> Vector[A]? {
 }
 
 ///|
-/// Given two trees, concatenate them into a new tree.
+/// Concatenate two vectors.
+///
+/// Aborts if the resulting length cannot be represented by `Int`.
+///
+/// The result always takes `other`'s tail, so the work is deciding what
+/// happens to `self`'s. In order of cost:
+///
+/// 1. `other.tree` is `Empty` and the two tails fit in one chunk — splice
+///    the tails, leave `self.tree` alone. No tree work at all.
+/// 2. Same, but the tails overflow — `normalize_tree` folds `self`'s tail into
+///    its tree, then `other`'s tail becomes the new tail.
+/// 3. `other` has a tree and `self` has no tail — a plain `Tree::concat`.
+/// 4. Both have substance — `Tree::concat_with_suffix` threads `self`'s tail
+///    through the splice as an extra leaf, rather than normalizing first and
+///    concatenating in two passes. This is the case that can widen a merge to
+///    65 children; see `rebalance`.
 pub fn[A] Vector::concat(self : Vector[A], other : Vector[A]) -> Vector[A] {
   if self.is_empty() {
     return other
@@ -401,7 +417,7 @@ pub fn[A] Vector::concat(self : Vector[A], other : Vector[A]) -> Vector[A] {
   if other.is_empty() {
     return self
   }
-  let size = self.size + other.size
+  let size = checked_size_add(self.size, other.size)
   if other.tree is Empty {
     let combined_tail_len = self.tail.length() + other.tail.length()
     if combined_tail_len <= BRANCHING_FACTOR {
@@ -657,7 +673,7 @@ pub fn[A] Vector::filter(
   self : Vector[A],
   f : (A) -> Bool raise?,
 ) -> Vector[A] raise? {
-  let arr : Array[A] = Array::new(capacity=self.size)
+  let arr : Array[A] = Array(capacity=self.size)
   self.each(value => if f(value) { arr.push(value) })
   from_array(arr)
 }
@@ -742,7 +758,7 @@ pub impl[A : @json.FromJson] @json.FromJson for Vector[A] with fn from_json(
   }
   let len = arr.length()
   guard len != 0 else { return new() }
-  let values : Array[A] = Array::new(capacity=len)
+  let values : Array[A] = Array(capacity=len)
   for i, value in arr {
     values.push(A::from_json(value, path.add_index(i)))
   }
@@ -798,6 +814,17 @@ pub impl[A : Compare] Compare for Vector[A] with fn compare(self, other) {
 // For Internal Use
 //-----------------------------------------------------------------------------
 
+///|
+/// Add a non-negative element count without allowing it to wrap. This mirrors
+/// `Array` growth: a collection operation aborts instead of returning a value
+/// whose stored length no longer describes its contents.
+#inline
+fn checked_size_add(size : Int, added : Int) -> Int {
+  let required = size + added
+  guard required >= size else { abort("Vector capacity overflow") }
+  required
+}
+
 ///|
 #owned(tree, tail)
 fn[A] make_t(
@@ -806,39 +833,64 @@ fn[A] make_t(
   size : Int,
   shift : Int,
 ) -> Vector[A] {
-  { tree, tail, size, shift }
+  { tree, tail, size, shift, }
 }
 
 ///|
-fn[A] from_leaves(leaves : ArrayView[FixedArray[A]], cap : Int) -> Tree[A] {
-  if cap == BRANCHING_FACTOR {
-    Leaf(leaves[0])
-  } else if leaves.length() <= BRANCHING_FACTOR {
-    let arr = FixedArray::make(leaves.length(), Empty)
-    for i, leaf in leaves {
-      arr[i] = Leaf(leaf)
-    }
-    Node(arr, None)
+/// Build a radix tree of height `shift / NUM_BITS` over non-empty `leaves`.
+/// Each leaf holds `BRANCHING_FACTOR` elements except possibly the last.
+///
+/// The caller fixes the height, so every level has to recurse even when few
+/// leaves are left: a short remainder still belongs at the bottom, not one
+/// level down from wherever it is. Getting that wrong yields a leaf above shift
+/// 0, which throws off both radix descent and `Tree::size`.
+///
+/// Keeping capacity in leaf units is also important at the upper boundary. A
+/// valid `Int`-sized vector may need a root with shift 30, whose element
+/// capacity is 2^35 and cannot itself be represented by `Int`; its leaf count
+/// and shift still can.
+fn[A] from_leaves(leaves : ArrayView[FixedArray[A]], shift : Int) -> Tree[A] {
+  guard shift > 0 else { return Leaf(leaves[0]) }
+  let child_shift = shift - NUM_BITS
+  let leaves_per_child = 1 << child_shift
+  let full = leaves.length() / leaves_per_child
+  let len = if leaves.length() % leaves_per_child == 0 {
+    full
   } else {
-    let len = leaves.length() * BRANCHING_FACTOR
-    let child_cap = cap / BRANCHING_FACTOR
-    let quot = len / child_cap
-    let rem = len % child_cap
-    let times = child_cap / BRANCHING_FACTOR
-    let arr = if rem == 0 {
-      FixedArray::makei(quot, i => {
-        from_leaves(leaves[i * times:(i + 1) * times], child_cap)
-      })
-    } else {
-      let arr = FixedArray::make(quot + 1, Tree::Empty)
-      for i in 0.. {
+    let lo = i * leaves_per_child
+    let hi = min(lo + leaves_per_child, leaves.length())
+    from_leaves(leaves[lo:hi], child_shift)
+  })
+  Node(arr, None)
+}
+
+///|
+/// Return the smallest tree shift whose capacity can hold `leaf_count` leaves.
+/// Capacity is intentionally measured in leaves: unlike the corresponding
+/// element capacity, it remains representable for every valid Vector length.
+fn shift_of_leaf_count(leaf_count : Int) -> Int {
+  guard leaf_count >= 0 else { abort("Vector capacity overflow") }
+  for capacity = 1, shift = 0; capacity < leaf_count; {
+    let next = capacity * BRANCHING_FACTOR
+    guard next > capacity else { abort("Vector capacity overflow") }
+    continue next, shift + NUM_BITS
+  } nobreak {
+    shift
+  }
+}
+
+///|
+/// Convert a number of full leaves to an element count without wrapping.
+fn tree_len_of_leaf_count(leaf_count : Int) -> Int {
+  guard leaf_count >= 0 else { abort("Vector capacity overflow") }
+  let len = leaf_count * BRANCHING_FACTOR
+  guard len / BRANCHING_FACTOR == leaf_count else {
+    abort("Vector capacity overflow")
   }
+  len
 }
 
 ///|
@@ -856,6 +908,21 @@ fn tail_len_of_size(size : Int) -> Int {
 }
 
 ///|
+/// Fold the tail into the tree and return the combined tree with its shift, so
+/// the caller can treat the vector as tree-only.
+///
+/// This is the only route by which a *partial* leaf reaches
+/// `append_right_leaf`: `push` always flushes a tail that is exactly
+/// `BRANCHING_FACTOR` long, whereas here the delta is whatever the tail happens
+/// to hold. That is what lets a partial right-most leaf be topped up in place —
+/// and, because the top-up patches the parent's sizes array rather than
+/// re-deriving it, what can leave a node exactly full while still relaxed
+/// (see the representation contract in `tree.mbt`).
+///
+/// A single-element tail goes through `push_end` instead, which is otherwise
+/// unreachable: `Vector::concat` only calls this when the two tails overflow
+/// `BRANCHING_FACTOR` together, so a 1-element left tail forces the right
+/// operand to be exactly one full chunk.
 fn[A] Vector::normalize_tree(self : Vector[A]) -> (Tree[A], Int) {
   if self.size == 0 {
     (Tree::empty(), 0)
@@ -912,12 +979,3 @@ fn[A] Vector::slice_unchecked(
     }
   }
 }
-
-///|
-fn shift_cap_of_size(size : Int) -> (Int, Int) {
-  for cap = BRANCHING_FACTOR, depth = 0; cap < size; {
-    continue cap * BRANCHING_FACTOR, depth + 1
-  } nobreak {
-    (NUM_BITS * depth, cap)
-  }
-}
diff --git a/immut/vector/vector_test.mbt b/immut/vector/vector_test.mbt
index a42437a66c..94387343c6 100644
--- a/immut/vector/vector_test.mbt
+++ b/immut/vector/vector_test.mbt
@@ -135,10 +135,10 @@ test "from_json rejects invalid input" {
 test "iter" {
   let buf = StringBuilder(size_hint=20)
   let v = @vector.from_array([1, 2, 3])
-  v.iter().each(e => buf.write_string("[\{e}]"))
+  v.iter().each(e => buf <+ "[\{e}]")
   inspect(buf, content="[1][2][3]")
   buf.reset()
-  v.iter().take(2).each(e => buf.write_string("[\{e}]"))
+  v.iter().take(2).each(e => buf <+ "[\{e}]")
   inspect(buf, content="[1][2]")
 }
 
@@ -586,7 +586,7 @@ test "new_with" {
 ///|
 test "from_iter multiple elements iter" {
   debug_inspect(
-    @vector.from_iter([1, 2, 3].iter()),
+    @vector.from_iter([|1, 2, 3|]),
     content=(
       #|
     ),
@@ -677,7 +677,7 @@ test "from_iter multiple elements iter" {
 ///|
 test "from_iter single element iter" {
   debug_inspect(
-    @vector.from_iter([1].iter()),
+    @vector.from_iter([|1|]),
     content=(
       #|
     ),
@@ -686,7 +686,7 @@ test "from_iter single element iter" {
 
 ///|
 test "from_iter empty iter" {
-  let pq : @vector.Vector[Int] = @vector.from_iter(Iter::empty())
+  let pq : @vector.Vector[Int] = @vector.from_iter([||])
   debug_inspect(
     pq,
     content=(
diff --git a/immut/vector_map/README.mbt.md b/immut/vector_map/README.mbt.md
new file mode 100644
index 0000000000..19ad7fae09
--- /dev/null
+++ b/immut/vector_map/README.mbt.md
@@ -0,0 +1,210 @@
+# Immutable VectorMap
+
+A persistent map that iterates in **insertion order** — the immutable counterpart of the built-in insertion-ordered `Map`. Updates return a map and never disturb the receiver: a new one when anything changed, and the receiver itself for a handful of no-ops spelled out below.
+
+Reach for it over `@immut/hashmap` when the order in which entries were added is part of what you are storing: rendering a list, replaying a log, or anything whose output a reader would notice being shuffled. Reach for `@immut/sorted_map` instead when you want entries in *key* order, and for `@immut/hashmap` when order does not matter at all — it is the leaner structure.
+
+## Create
+
+```mbt check
+///|
+test "create" {
+  let empty : @vector_map.VectorMap[String, Int] = @vector_map.new()
+  inspect(empty.is_empty(), content="true")
+  let one = @vector_map.singleton("a", 1)
+  inspect(one.length(), content="1")
+  // The array keeps its order — this is not sorted by key.
+  let m = @vector_map.VectorMap([("c", 3), ("a", 1), ("b", 2)])
+  debug_inspect(
+    m.keys().to_array(),
+    content=(
+      #|["c", "a", "b"]
+    ),
+  )
+}
+```
+
+## Order
+
+A key already in the map keeps its position when its value is replaced; a key that is new goes to the end. Removing a key and adding it back therefore moves it.
+
+```mbt check
+///|
+test "order" {
+  let m = @vector_map.VectorMap([("a", 1), ("b", 2), ("c", 3)])
+  // replacing in place: "a" stays first
+  debug_inspect(
+    m.add("a", 10).keys().to_array(),
+    content=(
+      #|["a", "b", "c"]
+    ),
+  )
+  // a fresh key is appended
+  debug_inspect(
+    m.add("d", 4).keys().to_array(),
+    content=(
+      #|["a", "b", "c", "d"]
+    ),
+  )
+  // remove-then-add moves it to the end
+  debug_inspect(
+    m.remove("a").add("a", 1).keys().to_array(),
+    content=(
+      #|["b", "c", "a"]
+    ),
+  )
+}
+```
+
+## Add, get, remove
+
+```mbt check
+///|
+test "add_get_remove" {
+  let m = @vector_map.new().add("a", 1).add("b", 2)
+  debug_inspect(m.get("a"), content="Some(1)")
+  inspect(m["b"], content="2")
+  inspect(m.contains("z"), content="false")
+  let smaller = m.remove("a")
+  debug_inspect(smaller.get("a"), content="None")
+  // the original is unchanged
+  debug_inspect(m.get("a"), content="Some(1)")
+}
+```
+
+`update` covers insert, replace and remove in one call, which is convenient when folding a change into existing state:
+
+```mbt check
+///|
+test "update" {
+  let m = @vector_map.VectorMap([("hits", 1)])
+  let bumped = m.update("hits", n => Some(n.unwrap_or(0) + 1))
+  debug_inspect(bumped.get("hits"), content="Some(2)")
+  let fresh = m.update("misses", n => Some(n.unwrap_or(0) + 1))
+  debug_inspect(fresh.get("misses"), content="Some(1)")
+  // returning None removes the key
+  inspect(m.update("hits", _ => None).is_empty(), content="true")
+}
+```
+
+## Traverse
+
+Every traversal yields entries in insertion order.
+
+```mbt check
+///|
+test "traverse" {
+  let m = @vector_map.VectorMap([("c", 3), ("a", 1), ("b", 2)])
+  debug_inspect(
+    m.to_array(),
+    content=(
+      #|[("c", 3), ("a", 1), ("b", 2)]
+    ),
+  )
+  inspect(m.fold(init=0, (acc, _, v) => acc + v), content="6")
+  let labelled = []
+  m.eachi((i, k, _) => labelled.push("\{i}:\{k}"))
+  debug_inspect(
+    labelled,
+    content=(
+      #|["0:c", "1:a", "2:b"]
+    ),
+  )
+  for k, v in m {
+    ignore((k, v))
+  }
+}
+```
+
+## Transform
+
+`map` rewrites values, keeping every key where it is. `filter` keeps the entries you select, in their existing relative order.
+
+```mbt check
+///|
+test "transform" {
+  let m = @vector_map.VectorMap([("c", 3), ("a", 1), ("b", 2)])
+  debug_inspect(
+    m.map((_, v) => v * 10).to_array(),
+    content=(
+      #|[("c", 30), ("a", 10), ("b", 20)]
+    ),
+  )
+  debug_inspect(
+    m.filter((_, v) => v > 1).to_array(),
+    content=(
+      #|[("c", 3), ("b", 2)]
+    ),
+  )
+}
+```
+
+## Equality, hashing and JSON
+
+Order is part of the content: two maps holding the same entries in different orders are **not** equal, and generally do not hash alike. That is what you want when a reordering is a visible change.
+
+```mbt check
+///|
+test "equality" {
+  let ab = @vector_map.VectorMap([("a", 1), ("b", 2)])
+  let ba = @vector_map.VectorMap([("b", 2), ("a", 1)])
+  inspect(ab == ba, content="false")
+  inspect(ab == @vector_map.new().add("a", 1).add("b", 2), content="true")
+}
+```
+
+JSON is an array of `[key, value]` pairs rather than an object, so that the order survives the round trip and keys keep their type.
+
+```mbt check
+///|
+test "json" {
+  let m = @vector_map.VectorMap([(30, "c"), (10, "a")])
+  json_inspect(m, content=[[30, "c"], [10, "a"]])
+  let back : @vector_map.VectorMap[Int, String] = @json.from_json(Json(m))
+  inspect(back == m, content="true")
+}
+```
+
+## Skipping work on an unchanged map
+
+Two operations are guaranteed to hand back the receiver itself rather than an equal copy: removing a key that is not there, and filtering with a predicate that rejects nothing. `update` inherits the first, since returning `None` for a key that is absent goes through `remove`. A caller that memoises on physical identity — re-rendering only when the map is a different object — is therefore not woken by any of them.
+
+```mbt check
+///|
+test "no_op" {
+  let m = @vector_map.VectorMap([("a", 1)])
+  inspect(physical_equal(m.remove("absent"), m), content="true")
+  inspect(physical_equal(m.filter((_, _) => true), m), content="true")
+}
+```
+
+No such promise is made beyond those, and in particular `add` always builds a new map even when the value it stores equals the one already there. Deciding otherwise would mean comparing values, and the only generic way to do that cheaply — `physical_equal` — is explicitly not something to hang semantics on. Check `get` yourself first if you need to skip a redundant write.
+
+## How it works, and what it costs
+
+Entries live in a persistent vector — the *spine* — in insertion order, and a persistent hash map — the *index* — maps each key to its slot. Iteration reads the spine straight through and never descends the hash trie, which is what makes traversing the whole map cheap; a lookup pays for both structures.
+
+| Operation | Cost |
+| --- | --- |
+| `contains` | one trie descent |
+| `get` | one trie descent, then one vector descent |
+| `add` on an existing key | one trie descent, one vector descent to recover the stored key, then one vector path copied |
+| `add` on a new key | one trie descent, one trie insertion, and usually only the vector's tail rewritten |
+| `remove` | one trie descent, one trie removal, one vector write — plus the trimming or rebuild described below |
+| `length`, `is_empty` | `O(1)` |
+| iteration, `map`, `filter`, `to_array` | `O(n)` |
+
+A trie descent is `O(log n)` for keys whose hashes are reasonably distributed. Keys that collide on the full hash share a bucket that is searched linearly, so an adversarial or badly written `Hash` degrades lookup, `add` and `remove` towards `O(n)` — this map inherits that from `@immut/hashmap` and does nothing to make it worse.
+
+Removal punches a hole in the spine rather than shifting the entries after it, since shifting would invalidate the index entry for every one of them. Under continued removal, holes are reclaimed two ways:
+
+- **Trimming.** A hole at the end of the spine is dropped at once, and dropping it can uncover more. Trimming `k` holes costs `k` vector pops, each of which may copy a tree path, so a single `remove` can cost `O(k log n)`.
+- **Rebuilding.** Holes in the middle accumulate until the spine is **both** at least 32 slots long **and** holding more holes than live entries. Below that length the ratio is not worth acting on, so a small map really can sit on nine holes and one entry. When it does fire, the spine is rebuilt dense and the index renumbered onto the new slots, at `O(n)`.
+
+Outside that cycle, a `filter` that rejects anything rebuilds the spine dense whatever its length, clearing every hole as a side effect; a `filter` that rejects nothing returns the receiver untouched, holes included.
+
+Both reclamation paths above are amortised only along a single line of descent. Every hole trimmed or reclaimed was paid for by the removal that made it, but a program that repeatedly returns to a version from just before a trim or a rebuild and removes again will pay the same cost each time. This is a good trade when a map's history moves mostly forward — state that is updated, occasionally rewound — and a poor one under heavy branching with heavy deletion in each branch.
+
+There is deliberately no positional lookup by rank. A slot is a physical position, holes and all, so exposing one would be either misleading or `O(n)`; iterate instead.
+
+The design follows Immutable.js's `OrderedMap`, and answers the same problem as Scala's `VectorMap`.
diff --git a/strconv/extends.mbt b/immut/vector_map/extends.mbt
similarity index 59%
rename from strconv/extends.mbt
rename to immut/vector_map/extends.mbt
index 7bbcad60fc..cbb78cf2b7 100644
--- a/strconv/extends.mbt
+++ b/immut/vector_map/extends.mbt
@@ -15,29 +15,34 @@
 // --- promoted: kept as regular methods ---
 
 ///|
-pub extend Decimal with Show::{to_string}
+pub extend VectorMap with Eq::{equal}
 
 ///|
-pub extend StrConvError with Show::{to_string}
+pub extend VectorMap with Hash::{hash}
 
 // --- deprecated: hidden from the generated interface ---
 
+///|
+#deprecated("Use `Json(x)` instead", skip_current_package=true)
+#doc(hidden)
+pub extend VectorMap with ToJson::{to_json}
+
 ///|
 #deprecated("Use `Debug::to_repr` instead", skip_current_package=true)
 #doc(hidden)
-pub extend Decimal with @debug.Debug::{to_repr}
+pub extend VectorMap with @debug.Debug::{to_repr}
 
 ///|
-#deprecated("Use `Show::output` via the trait or `to_string` instead", skip_current_package=true)
+#deprecated("Use `!=` instead", skip_current_package=true)
 #doc(hidden)
-pub extend Decimal with Show::{output}
+pub extend VectorMap with Eq::{not_equal}
 
 ///|
-#deprecated("Use `Debug::to_repr` instead", skip_current_package=true)
+#deprecated("Use `Hash::hash_combine` instead", skip_current_package=true)
 #doc(hidden)
-pub extend StrConvError with @debug.Debug::{to_repr}
+pub extend VectorMap with Hash::{hash_combine}
 
 ///|
-#deprecated("Use `Show::output` via the trait or `to_string` instead", skip_current_package=true)
+#deprecated("Use `@json.from_json` instead", skip_current_package=true)
 #doc(hidden)
-pub extend StrConvError with Show::{output}
+pub extend VectorMap with @json.FromJson::{from_json}
diff --git a/immut/vector_map/invariants_wbtest.mbt b/immut/vector_map/invariants_wbtest.mbt
new file mode 100644
index 0000000000..234d5849c4
--- /dev/null
+++ b/immut/vector_map/invariants_wbtest.mbt
@@ -0,0 +1,261 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// The public tests pin down what the map means; these pin down what it is.
+// Nothing observable distinguishes a spine full of unreclaimed holes from a
+// dense one, so the bounds that keep the representation from degrading — the
+// index agreeing with the spine both ways, no trailing tombstone, holes never
+// outnumbering entries past the threshold — have to be asserted from inside.
+
+///|
+/// Every representation invariant from the doc comment on `VectorMap`, as a
+/// single predicate. Returns the first failure as a message so that a property
+/// counterexample says which invariant broke.
+fn[K : Eq + Hash + Show, V] check_invariants(map : VectorMap[K, V]) -> String? {
+  let length = map.spine.length()
+  // `size` counts exactly the live slots.
+  let mut live = 0
+  map.spine.each(entry => if entry is Some(_) { live += 1 })
+  guard live == map.size else {
+    return Some("size is \{map.size} but the spine holds \{live} entries")
+  }
+  // The index holds exactly the live keys…
+  guard map.index.length() == map.size else {
+    return Some(
+      "index holds \{map.index.length()} keys for \{map.size} entries",
+    )
+  }
+  // …and each one points at a slot carrying that same key.
+  for key, slot in map.index {
+    guard slot >= 0 && slot < length else {
+      return Some("key \{key} points at slot \{slot}, outside 0..<\{length}")
+    }
+    guard map.spine.get(slot) is Some(Some((slot_key, _))) else {
+      return Some("key \{key} points at slot \{slot}, which is a tombstone")
+    }
+    guard slot_key == key else {
+      return Some("key \{key} points at slot \{slot}, which holds \{slot_key}")
+    }
+  }
+  // Conversely every live slot is reachable through the index, at that slot —
+  // this is the direction that catches an index left stale by a rebuild.
+  let mut failure = None
+  map.spine.eachi((slot, entry) => {
+    if entry is Some((key, _)) {
+      if map.index.get(key) != Some(slot) && failure is None {
+        failure = Some(
+          "slot \{slot} holds \{key}, which the index sends elsewhere",
+        )
+      }
+    }
+  })
+  guard failure is None else { return failure }
+  // A non-empty spine never ends on a hole, so an empty map is empty underneath.
+  if length > 0 {
+    guard map.spine.get(length - 1) is Some(Some(_)) else {
+      return Some("the spine ends on a tombstone")
+    }
+  }
+  // Holes stay bounded once the spine is long enough to be worth rebuilding.
+  if length >= MIN_COMPACT_LENGTH && length - map.size > map.size {
+    return Some("\{length - map.size} holes against \{map.size} entries")
+  }
+  None
+}
+
+///|
+test "invariants hold through a deletion-heavy operation sequence" {
+  @quickcheck.check(
+    (ops : Array[Int]) => {
+      let mut map : VectorMap[Int, Int] = new()
+      for op in ops {
+        let n = op & 0x7fffffff
+        let key = n / 8 % 24
+        match n % 6 {
+          0 | 1 => map = map.add(key, n % 7)
+          2 | 3 | 4 => map = map.remove(key)
+          _ => map = map.filter((k, _) => k % 3 != 0)
+        }
+        guard check_invariants(map) is None else { return false }
+      }
+      true
+    },
+    count=300,
+  )
+}
+
+///|
+test "invariants hold while the spine is churned around the threshold" {
+  // Alternating growth and shrinkage across `MIN_COMPACT_LENGTH` is where
+  // trimming, the compaction trigger and the index rebuild interact.
+  @quickcheck.check(
+    (raw : Array[Int]) => {
+      let mut map : VectorMap[Int, Int] = new()
+      let mut next = 0
+      for n in raw {
+        // `abs` would overflow on `Int::min_value`; masking cannot.
+        let count = (n & 0x7fffffff) % 12 + 1
+        // grow
+        for _ in 0..= 0 && n % 3 != 0 {
+            map = map.remove(k)
+            guard check_invariants(map) is None else { return false }
+          }
+        }
+      }
+      true
+    },
+    count=200,
+  )
+}
+
+///|
+test "trailing removals shrink the spine instead of leaving holes" {
+  let map = from_iter((0).until(10).map(i => (i, i)))
+  inspect(map.spine.length(), content="10")
+  // Dropping the last entry must pop it, not tombstone it…
+  let popped = map.remove(9)
+  inspect(popped.spine.length(), content="9")
+  // …and the trim keeps going over holes it uncovers.
+  let mut chained = map.remove(7).remove(8)
+  inspect(chained.spine.length(), content="10")
+  chained = chained.remove(9)
+  inspect(chained.spine.length(), content="7")
+  assert_true(check_invariants(chained) is None)
+}
+
+///|
+test "the spine is rebuilt once the holes outnumber the entries" {
+  let map = from_iter((0).until(100).map(i => (i, i)))
+  inspect(map.spine.length(), content="100")
+  // Removing 49 of the first 99 leaves 51 entries against 49 holes: still
+  // above the ratio, so nothing has been rebuilt yet.
+  let mut punched = map
+  for i in 0..<49 {
+    punched = punched.remove(i * 2)
+  }
+  inspect(punched.length(), content="51")
+  inspect(punched.spine.length(), content="100")
+  // Level pegging does not tip it either: the trigger is a strict majority of
+  // holes, so 50 against 50 still leaves the spine alone.
+  punched = punched.remove(98)
+  inspect(punched.length(), content="50")
+  inspect(punched.spine.length(), content="100")
+  // One more hole does tip it, and the spine collapses onto the live entries.
+  punched = punched.remove(97)
+  inspect(punched.length(), content="49")
+  inspect(punched.spine.length(), content="49")
+  assert_true(check_invariants(punched) is None)
+  // The rebuilt index still resolves every survivor.
+  for k, v in punched {
+    assert_true(punched.get(k) == Some(v))
+  }
+}
+
+///|
+test "a short spine is left alone however holey it gets" {
+  // Below the threshold the ratio is not worth a rebuild, so the holes stay.
+  let map = from_iter((0).until(10).map(i => (i, i)))
+  let mut punched = map
+  for i in 0..<9 {
+    punched = punched.remove(i)
+  }
+  inspect(punched.length(), content="1")
+  inspect(punched.spine.length(), content="10")
+  assert_true(check_invariants(punched) is None)
+  // Emptying it entirely still leaves nothing behind, because the trim runs
+  // regardless of the threshold.
+  punched = punched.remove(9)
+  inspect(punched.spine.length(), content="0")
+  assert_true(punched.is_empty())
+}
+
+///|
+test "appending can be what tips the spine into a rebuild" {
+  // A spine of 31 slots holding one entry is under `MIN_COMPACT_LENGTH`, so
+  // nothing reclaims its 30 holes. Appending one more entry carries it to 32,
+  // at which point the ratio is wildly over the limit — so `add` has to test
+  // for compaction too, not just `remove`.
+  let mut map = from_iter((0).until(31).map(i => (i, i)))
+  for i in 0..<30 {
+    map = map.remove(i)
+  }
+  inspect(map.length(), content="1")
+  inspect(map.spine.length(), content="31")
+  assert_true(check_invariants(map) is None)
+  let appended = map.add(100, 100)
+  inspect(appended.length(), content="2")
+  inspect(appended.spine.length(), content="2")
+  assert_true(check_invariants(appended) is None)
+  // …and the rebuild kept the order and every lookup
+  @debug.debug_inspect(appended.to_array(), content="[(30, 30), (100, 100)]")
+}
+
+///|
+test "churn under add and remove keeps reclaiming the spine" {
+  // Drives the ratio repeatedly rather than once, so a compaction that only
+  // worked from a pristine spine would show up here.
+  let mut map = from_iter((0).until(40).map(i => (i, i)))
+  let mut compactions = 0
+  for round in 0..<12 {
+    let before = map.spine.length()
+    // remove two thirds of what is there, oldest first…
+    for k, _ in map.to_array()[:map.length() * 2 / 3] {
+      map = map.remove(k)
+    }
+    // …and append fresh keys, which land past the holes
+    for j in 0..<8 {
+      map = map.add(1000 + round * 8 + j, j)
+    }
+    if map.spine.length() < before {
+      compactions += 1
+    }
+    assert_true(check_invariants(map) is None)
+  }
+  assert_true(compactions > 0)
+  // The surviving entries are still exactly what the operations left.
+  for k, v in map {
+    assert_true(map.get(k) == Some(v))
+  }
+}
+
+///|
+test "the simple constructors produce valid representations" {
+  let empty : VectorMap[String, Int] = new()
+  assert_true(check_invariants(empty) is None)
+  assert_true(check_invariants(singleton("a", 1)) is None)
+  assert_true(
+    check_invariants(VectorMap([("a", 1), ("b", 2), ("a", 3)])) is None,
+  )
+  assert_true(
+    check_invariants(from_iter((0).until(40).map(i => (i, i)))) is None,
+  )
+}
+
+///|
+test "map shares the index rather than rebuilding it" {
+  // Values change, slots do not, so the trie is handed straight over. This is
+  // what keeps a value-only transform off the hash path entirely.
+  let map = from_iter((0).until(40).map(i => (i, i)))
+  let mapped = map.map((_, v) => v * 2)
+  assert_true(physical_equal(mapped.index, map.index))
+  assert_true(check_invariants(mapped) is None)
+}
diff --git a/immut/vector_map/iter.mbt b/immut/vector_map/iter.mbt
new file mode 100644
index 0000000000..ca2d622dda
--- /dev/null
+++ b/immut/vector_map/iter.mbt
@@ -0,0 +1,159 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Every traversal below walks the spine and skips tombstones, so all of them
+// yield entries in insertion order. Reading one never touches the hash trie;
+// only `filter` does, and only to carry the index over to the result.
+
+///|
+/// Apply `f` to every entry, in insertion order. `O(n)`.
+pub fn[K, V] VectorMap::each(
+  self : VectorMap[K, V],
+  f : (K, V) -> Unit raise?,
+) -> Unit raise? {
+  self.spine.each(entry => if entry is Some((k, v)) { f(k, v) })
+}
+
+///|
+/// Apply `f` to every entry along with its position, in insertion order.
+///
+/// The position is the entry's rank among the live entries, so it always runs
+/// from `0` to `length() - 1` with no gaps, whatever the spine looks like
+/// underneath. `O(n)`.
+pub fn[K, V] VectorMap::eachi(
+  self : VectorMap[K, V],
+  f : (Int, K, V) -> Unit raise?,
+) -> Unit raise? {
+  let mut i = 0
+  self.spine.each(entry => {
+    if entry is Some((k, v)) {
+      f(i, k, v)
+      i += 1
+    }
+  })
+}
+
+///|
+/// Iterate over the entries, in insertion order. `O(1)` to build, `O(n)` to
+/// drain.
+#alias(iterator, deprecated)
+pub fn[K, V] VectorMap::iter(self : VectorMap[K, V]) -> Iter[(K, V)] {
+  self.spine.iter().filter_map(entry => entry)
+}
+
+///|
+/// Iterate over the entries as key/value pairs, in insertion order. `O(1)` to
+/// build, `O(n)` to drain.
+#alias(iterator2, deprecated)
+pub fn[K, V] VectorMap::iter2(self : VectorMap[K, V]) -> Iter2[K, V] {
+  self.iter()
+}
+
+///|
+/// Iterate over the keys, in insertion order. `O(1)` to build, `O(n)` to drain.
+pub fn[K, V] VectorMap::keys(self : VectorMap[K, V]) -> Iter[K] {
+  self.iter().map(kv => kv.0)
+}
+
+///|
+/// Iterate over the values, in insertion order. `O(1)` to build, `O(n)` to
+/// drain.
+#alias(elems, deprecated)
+pub fn[K, V] VectorMap::values(self : VectorMap[K, V]) -> Iter[V] {
+  self.iter().map(kv => kv.1)
+}
+
+///|
+/// Fold over the entries, in insertion order. `O(n)`.
+pub fn[K, V, A] VectorMap::fold(
+  self : VectorMap[K, V],
+  init~ : A,
+  f : (A, K, V) -> A raise?,
+) -> A raise? {
+  self.spine.fold(init~, (acc, entry) => {
+    match entry {
+      Some((k, v)) => f(acc, k, v)
+      None => acc
+    }
+  })
+}
+
+///|
+/// Collect the entries into an array, in insertion order. `O(n)`.
+pub fn[K, V] VectorMap::to_array(self : VectorMap[K, V]) -> Array[(K, V)] {
+  let result : Array[(K, V)] = Array(capacity=self.size)
+  self.each((k, v) => result.push((k, v)))
+  result
+}
+
+///|
+/// Transform every value, keeping the keys and their order.
+///
+/// The index is shared with the receiver rather than rebuilt: no slot moves, so
+/// the mapping from keys to positions is unchanged. `O(n)`, and no key is
+/// hashed.
+#alias(map_with_key, deprecated)
+pub fn[K, V, A] VectorMap::map(
+  self : VectorMap[K, V],
+  f : (K, V) -> A raise?,
+) -> VectorMap[K, A] raise? {
+  {
+    index: self.index,
+    spine: self.spine.map(entry => {
+      match entry {
+        Some((k, v)) => Some((k, f(k, v)))
+        None => None
+      }
+    }),
+    size: self.size,
+  }
+}
+
+///|
+/// Keep the entries satisfying `pred`, in their existing relative order.
+///
+/// A predicate that rejects nothing returns the receiver itself, holes and all;
+/// any other result is rebuilt dense, so filtering never *introduces* a hole.
+///
+/// `O(n)`: one spine pass, then the index is filtered and renumbered in place
+/// of being rebuilt from the keys, so again nothing is hashed.
+#alias(filter_with_key, deprecated)
+pub fn[K, V] VectorMap::filter(
+  self : VectorMap[K, V],
+  pred : (K, V) -> Bool raise?,
+) -> VectorMap[K, V] raise? {
+  // `remap` sends a surviving slot to its new dense position and a dropped one
+  // to -1, which is what lets the index be filtered and renumbered below
+  // without rehashing any key.
+  let remap = FixedArray::make(self.spine.length(), -1)
+  let kept : Array[(K, V)?] = []
+  self.spine.eachi((slot, entry) => {
+    if entry is Some((k, v)) {
+      if pred(k, v) {
+        remap[slot] = kept.length()
+        kept.push(entry)
+      }
+    }
+  })
+  if kept.length() == self.size {
+    return self
+  }
+  {
+    index: self.index
+    .filter((_, slot) => remap[slot] >= 0)
+    .map((_, slot) => remap[slot]),
+    spine: @vector.from_iter(kept.iter()),
+    size: kept.length(),
+  }
+}
diff --git a/strconv/moon.pkg b/immut/vector_map/moon.pkg
similarity index 56%
rename from strconv/moon.pkg
rename to immut/vector_map/moon.pkg
index f287acdf47..e38bed0814 100644
--- a/strconv/moon.pkg
+++ b/immut/vector_map/moon.pkg
@@ -1,16 +1,16 @@
 import {
   "moonbitlang/core/builtin",
-  "moonbitlang/core/double",
-  "moonbitlang/core/uint64",
-  "moonbitlang/core/char",
   "moonbitlang/core/array",
   "moonbitlang/core/debug",
+  "moonbitlang/core/json",
+  "moonbitlang/core/immut/hashmap",
+  "moonbitlang/core/immut/vector",
 }
 
 import {
   "moonbitlang/core/quickcheck",
-  "moonbitlang/core/json",
-  "moonbitlang/core/string",
 } for "test"
 
-warnings = "-deprecated"
+import {
+  "moonbitlang/core/quickcheck",
+} for "wbtest"
diff --git a/immut/vector_map/pkg.generated.mbti b/immut/vector_map/pkg.generated.mbti
new file mode 100644
index 0000000000..1c0b4f0819
--- /dev/null
+++ b/immut/vector_map/pkg.generated.mbti
@@ -0,0 +1,60 @@
+// Generated using `moon info`, DON'T EDIT IT
+package "moonbitlang/core/immut/vector_map"
+
+import {
+  "moonbitlang/core/debug",
+  "moonbitlang/core/json",
+}
+
+// Values
+
+// Errors
+
+// Types and methods
+type VectorMap[K, V]
+pub fn[K : Eq + Hash, V] VectorMap::VectorMap(ArrayView[(K, V)]) -> Self[K, V]
+pub fn[K : Eq + Hash, V] VectorMap::add(Self[K, V], K, V) -> Self[K, V]
+#alias("_[_]")
+pub fn[K : Eq + Hash, V] VectorMap::at(Self[K, V], K) -> V
+pub fn[K : Eq + Hash, V] VectorMap::contains(Self[K, V], K) -> Bool
+pub fn[K, V] VectorMap::each(Self[K, V], (K, V) -> Unit raise?) -> Unit raise?
+pub fn[K, V] VectorMap::eachi(Self[K, V], (Int, K, V) -> Unit raise?) -> Unit raise?
+pub fn[K : Eq, V : Eq] VectorMap::equal(Self[K, V], Self[K, V]) -> Bool
+#alias(filter_with_key, deprecated)
+pub fn[K, V] VectorMap::filter(Self[K, V], (K, V) -> Bool raise?) -> Self[K, V] raise?
+pub fn[K, V, A] VectorMap::fold(Self[K, V], init~ : A, (A, K, V) -> A raise?) -> A raise?
+#alias(from_iterator, deprecated)
+#as_free_fn(from_iterator, deprecated)
+#as_free_fn
+pub fn[K : Eq + Hash, V] VectorMap::from_iter(Iter[(K, V)]) -> Self[K, V]
+#alias(find, deprecated)
+pub fn[K : Eq + Hash, V] VectorMap::get(Self[K, V], K) -> V?
+pub fn[K : Hash, V : Hash] VectorMap::hash(Self[K, V]) -> Int
+pub fn[K, V] VectorMap::is_empty(Self[K, V]) -> Bool
+#alias(iterator, deprecated)
+pub fn[K, V] VectorMap::iter(Self[K, V]) -> Iter[(K, V)]
+#alias(iterator2, deprecated)
+pub fn[K, V] VectorMap::iter2(Self[K, V]) -> Iter2[K, V]
+pub fn[K, V] VectorMap::keys(Self[K, V]) -> Iter[K]
+#alias(size, deprecated)
+pub fn[K, V] VectorMap::length(Self[K, V]) -> Int
+#alias(map_with_key, deprecated)
+pub fn[K, V, A] VectorMap::map(Self[K, V], (K, V) -> A raise?) -> Self[K, A] raise?
+#as_free_fn
+pub fn[K, V] VectorMap::new() -> Self[K, V]
+pub fn[K : Eq + Hash, V] VectorMap::remove(Self[K, V], K) -> Self[K, V]
+#as_free_fn
+pub fn[K : Hash, V] VectorMap::singleton(K, V) -> Self[K, V]
+pub fn[K, V] VectorMap::to_array(Self[K, V]) -> Array[(K, V)]
+pub fn[K : Eq + Hash, V] VectorMap::update(Self[K, V], K, (V?) -> V? raise?) -> Self[K, V] raise?
+#alias(elems, deprecated)
+pub fn[K, V] VectorMap::values(Self[K, V]) -> Iter[V]
+pub impl[K : Eq, V : Eq] Eq for VectorMap[K, V]
+pub impl[K : Hash, V : Hash] Hash for VectorMap[K, V]
+pub impl[K : ToJson, V : ToJson] ToJson for VectorMap[K, V]
+pub impl[K : @debug.Debug, V : @debug.Debug] @debug.Debug for VectorMap[K, V]
+pub impl[K : @json.FromJson + Eq + Hash, V : @json.FromJson] @json.FromJson for VectorMap[K, V]
+
+// Type aliases
+
+// Traits
diff --git a/immut/vector_map/quickcheck_test.mbt b/immut/vector_map/quickcheck_test.mbt
new file mode 100644
index 0000000000..02f84c0517
--- /dev/null
+++ b/immut/vector_map/quickcheck_test.mbt
@@ -0,0 +1,314 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Property sweep against an association array carrying the same ordering rule:
+// a fresh key is appended, a key already present is overwritten where it sits.
+// That array IS the specification — `agrees_with_model` demands the map expose
+// exactly its entries, in exactly its order, through every traversal, and that
+// a map rebuilt from that content compares and hashes equal to the map that
+// reached it by operations. The interesting pressure is deletion: holes and the
+// rebuild that reclaims them are invisible to this model, so any slot that
+// survives an operation it should not, or any index left pointing at a stale
+// position, shows up as disagreement.
+
+///|
+/// Append if new, overwrite in place if present — `VectorMap::add`.
+fn model_add(model : Array[(Int, Int)], key : Int, value : Int) -> Unit {
+  for i, kv in model {
+    if kv.0 == key {
+      model[i] = (key, value)
+      return
+    }
+  }
+  model.push((key, value))
+}
+
+///|
+fn model_remove(model : Array[(Int, Int)], key : Int) -> Unit {
+  for i, kv in model {
+    if kv.0 == key {
+      model.remove(i) |> ignore
+      return
+    }
+  }
+}
+
+///|
+/// The full specification check for one state.
+fn agrees_with_model(
+  map : @vector_map.VectorMap[Int, Int],
+  model : Array[(Int, Int)],
+) -> Bool {
+  guard map.length() == model.length() else { return false }
+  guard map.is_empty() == model.is_empty() else { return false }
+  // Order and content, as seen through the array conversion…
+  guard map.to_array() == model else { return false }
+  // …and through the traversals that reach the spine by a different route:
+  // `each` walks it directly, `iter` filters its iterator, `fold` folds it.
+  // (`to_array` is `each` underneath, and `keys`/`values`/`iter2` are all
+  // `iter`, so those are checked for agreement rather than independence.)
+  let via_each = []
+  map.each((k, v) => via_each.push((k, v)))
+  guard via_each == model else { return false }
+  let via_fold = map.fold(init=[], (acc : Array[(Int, Int)], k, v) => {
+    acc.push((k, v))
+    acc
+  })
+  guard via_fold == model else { return false }
+  guard map.keys().to_array() == model.map(kv => kv.0) else { return false }
+  guard map.values().to_array() == model.map(kv => kv.1) else { return false }
+  let via_iter2 = []
+  for k, v in map {
+    via_iter2.push((k, v))
+  }
+  guard via_iter2 == model else { return false }
+  // `eachi` must number the live entries densely, whatever the spine holds.
+  let ranks = []
+  map.eachi((i, _, _) => ranks.push(i))
+  guard ranks == Array::makei(model.length(), i => i) else { return false }
+  // Lookups agree across the whole key universe, absences included.
+  for key in 0..<32 {
+    let expected = for kv in model {
+      if kv.0 == key {
+        break Some(kv.1)
+      }
+    } nobreak {
+      None
+    }
+    guard map.get(key) == expected else { return false }
+    guard map.contains(key) == (expected is Some(_)) else { return false }
+  }
+  // Same content reached by any history is the same value: equal, and equally
+  // hashed. Both read live spine entries only — it is the lookup sweep above
+  // that catches a stale index — so what this adds is that an unreclaimed slot
+  // or a reordering never leaks into either.
+  let rebuilt = @vector_map.from_iter(model.iter())
+  guard map == rebuilt else { return false }
+  let h1 = Hasher()
+  h1.combine(map)
+  let h2 = Hasher()
+  h2.combine(rebuilt)
+  h1.finalize() == h2.finalize()
+}
+
+///|
+/// Interpret `ops` as a stream of operations over a 32-key universe, mirroring
+/// each on the model and demanding agreement after every single step.
+///
+/// `remove_bias` skews the operation mix towards deletion and starts from a map
+/// already holding the whole key universe. Neither is strictly necessary —
+/// adding and removing from empty reaches trimming immediately, and enough
+/// churn eventually reaches a rebuild — but together they make both reliable
+/// within the op counts the generator produces, rather than leaving them to
+/// chance on a stream that spends its length near empty.
+fn ops_agree(ops : Array[Int], remove_bias~ : Bool) -> Bool {
+  let model : Array[(Int, Int)] = []
+  let mut map : @vector_map.VectorMap[Int, Int] = @vector_map.new()
+  if remove_bias {
+    for key in 0..<32 {
+      map = map.add(key, key)
+      model_add(model, key, key)
+    }
+  }
+  for op in ops {
+    let n = op & 0x7fffffff
+    let key = n / 8 % 32
+    let value = n / 256 % 11
+    // Two adds against four removes, so the population drains while still
+    // being replenished — which is what keeps appending fresh slots past the
+    // holes left behind.
+    let choice = if remove_bias {
+      // Only adds and removes, the two operations that create and reclaim
+      // holes. `filter` is kept out because a rejecting predicate rebuilds the
+      // spine dense, which would keep resetting the very accumulation this
+      // variant exists to produce; `map` is kept out merely because it moves
+      // neither the spine's length nor its holes, so it would add nothing here.
+      match n % 6 {
+        0 | 1 => 0
+        _ => 3
+      }
+    } else {
+      n % 8
+    }
+    match choice {
+      0 | 1 | 2 => {
+        map = map.add(key, value)
+        model_add(model, key, value)
+      }
+      3 | 4 => {
+        map = map.remove(key)
+        model_remove(model, key)
+      }
+      5 => {
+        // `update` must equal add-or-remove depending on what it returns.
+        map = map.update(key, old => {
+          if old is Some(v) && v % 2 == 0 {
+            None
+          } else {
+            Some(value)
+          }
+        })
+        let old = for kv in model {
+          if kv.0 == key {
+            break Some(kv.1)
+          }
+        } nobreak {
+          None
+        }
+        if old is Some(v) && v % 2 == 0 {
+          model_remove(model, key)
+        } else {
+          model_add(model, key, value)
+        }
+      }
+      6 => {
+        map = map.filter((k, v) => (k + v) % 3 != 0)
+        model.retain(kv => (kv.0 + kv.1) % 3 != 0)
+      }
+      _ => {
+        // `map` rewrites values without touching keys or their order.
+        map = map.map((k, v) => (v * 2 + k) % 11)
+        for i, kv in model {
+          model[i] = (kv.0, (kv.1 * 2 + kv.0) % 11)
+        }
+      }
+    }
+    guard agrees_with_model(map, model) else { return false }
+  }
+  true
+}
+
+///|
+test "quickcheck: operation sequences agree with the ordered model" {
+  @quickcheck.check(
+    (ops : Array[Int]) => ops_agree(ops, remove_bias=false),
+    count=300,
+  )
+}
+
+///|
+test "quickcheck: deletion-heavy sequences agree too" {
+  // Same property, but starting from a full universe and draining it, so the
+  // spine spends the run accumulating holes and reclaiming them.
+  @quickcheck.check(
+    (ops : Array[Int]) => ops_agree(ops, remove_bias=true),
+    count=300,
+  )
+}
+
+///|
+/// Build a map and its model from raw pairs, applying the ordering rule.
+fn build(
+  entries : Array[(Int, Int)],
+) -> (@vector_map.VectorMap[Int, Int], Array[(Int, Int)]) {
+  let model : Array[(Int, Int)] = []
+  // Masking rather than `%` keeps keys inside the probed universe for
+  // negative inputs too, and cannot overflow on `Int::min_value`.
+  let normalised = entries.map(kv => (kv.0 & 31, kv.1 & 7))
+  for kv in normalised {
+    model_add(model, kv.0, kv.1)
+  }
+  (VectorMap(normalised), model)
+}
+
+///|
+test "quickcheck: bulk construction matches repeated add" {
+  @quickcheck.check(
+    (entries : Array[(Int, Int)]) => {
+      let (map, model) = build(entries)
+      guard agrees_with_model(map, model) else { return false }
+      // The array constructor, the iterator constructor and a fold of `add`
+      // must all land on the same value.
+      let by_add = entries
+        .map(kv => (kv.0 & 31, kv.1 & 7))
+        .fold(init=@vector_map.new(), (m, kv) => m.add(kv.0, kv.1))
+      guard map == by_add else { return false }
+      map ==
+      @vector_map.from_iter(entries.iter().map(kv => (kv.0 & 31, kv.1 & 7)))
+    },
+    count=300,
+  )
+}
+
+///|
+test "quickcheck: persistence — an operation never disturbs its receiver" {
+  @quickcheck.check(
+    (input : (Array[(Int, Int)], Int, Int)) => {
+      let (entries, raw_key, raw_value) = input
+      let (map, model) = build(entries)
+      let key = raw_key & 31
+      let value = raw_value & 7
+      // Derive several new versions, then require the original intact.
+      let _ = map.add(key, value)
+      let _ = map.remove(key)
+      let _ = map.filter((k, _) => k % 2 == 0)
+      let _ = map.map((_, v) => v + 1)
+      let _ = map.update(key, _ => Some(value))
+      agrees_with_model(map, model)
+    },
+    count=200,
+  )
+}
+
+///|
+test "quickcheck: equality is exactly same-entries-same-order" {
+  @quickcheck.check(
+    (input : (Array[(Int, Int)], Int)) => {
+      let (entries, raw_shift) = input
+      let (map, model) = build(entries)
+      guard map == @vector_map.from_iter(model.iter()) else { return false }
+      // A rotation of a map with at least two distinct positions changes the
+      // order, so it must compare unequal.
+      let n = model.length()
+      guard n >= 2 else { return true }
+      // `abs` would overflow on `Int::min_value`; masking cannot.
+      let shift = (raw_shift & 0x7fffffff) % (n - 1) + 1
+      let rotated = Array::makei(n, i => model[(i + shift) % n])
+      let rotated_map = @vector_map.from_iter(rotated.iter())
+      // Same entries either way…
+      guard rotated_map.length() == map.length() else { return false }
+      for kv in model {
+        guard rotated_map.get(kv.0) == Some(kv.1) else { return false }
+      }
+      guard map != rotated_map else { return false }
+      // Perturbing a single value, leaving keys and order alone, must also be
+      // enough to make them unequal.
+      let (k0, v0) = model[0]
+      guard map != map.add(k0, v0 + 1) else { return false }
+      // A proper prefix must compare unequal from BOTH sides — a length check
+      // that only caught the short side on the right would leave `Eq`
+      // asymmetric, and comparing entry by entry would not notice.
+      let prefix = @vector_map.from_iter(model.iter().take(n - 1))
+      map != prefix && prefix != map
+    },
+    count=300,
+  )
+}
+
+///|
+test "quickcheck: json survives a round trip, order included" {
+  @quickcheck.check(
+    (entries : Array[(Int, Int)]) => {
+      let (map, _) = build(entries)
+      let decoded : @vector_map.VectorMap[Int, Int]? = Some(
+        @json.from_json(Json(map)),
+      ) catch {
+        _ => None
+      }
+      guard decoded is Some(back) else { return false }
+      back == map && back.to_array() == map.to_array()
+    },
+    count=200,
+  )
+}
diff --git a/immut/vector_map/traits_impl.mbt b/immut/vector_map/traits_impl.mbt
new file mode 100644
index 0000000000..3db49e3775
--- /dev/null
+++ b/immut/vector_map/traits_impl.mbt
@@ -0,0 +1,113 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+/// Two maps are equal when they hold the same entries **in the same order**.
+///
+/// Order is part of the content here, so maps built from the same pairs in
+/// different orders are not equal — which is what a consumer rendering the
+/// entries needs, since a reordering is a visible change. The comparison reads
+/// the live entries only: tombstone layout is representation, and two equal
+/// maps reached by different histories may well have different spines.
+///
+/// `O(1)` when the two are physically identical or differ in length, `O(n)`
+/// otherwise — and it stops at the first entry that differs.
+pub impl[K : Eq, V : Eq] Eq for VectorMap[K, V] with fn equal(self, other) {
+  if physical_equal(self, other) {
+    return true
+  }
+  // Comparing lengths first is not just a shortcut: the walk below stops after
+  // `self.size` entries, so without it a map would compare equal to any longer
+  // map it is a prefix of — and `Eq` would not even be symmetric.
+  guard self.size == other.size else { return false }
+  let left = self.iter()
+  let right = other.iter()
+  for _ in 0.. (Repr(k), Repr(v)) ]),
+  )
+}
+
+///|
+/// Serialise as an array of `[key, value]` pairs.
+///
+/// An array rather than an object, because the point of this type is the order
+/// of its entries and JSON object member order is not something a reader is
+/// obliged to preserve. It also keeps non-string keys intact. `O(n)`.
+pub impl[K : ToJson, V : ToJson] ToJson for VectorMap[K, V] with fn to_json(
+  self,
+) {
+  [
+    for k, v in self => ([k, v] : Json)
+  ]
+}
+
+///|
+/// Decode an array of `[key, value]` pairs, applying the same duplicate-key rule
+/// as the array constructor, at the same `O(m log n)` for `m` pairs.
+pub impl[K : @json.FromJson + Eq + Hash, V : @json.FromJson] @json.FromJson for VectorMap[
+  K,
+  V,
+] with fn from_json(json, path) {
+  guard json is Array(pairs) else {
+    raise JsonDecodeError((path, "@immut/vector_map.from_json: expected array"))
+  }
+  let entries : Array[(K, V)] = Array(capacity=pairs.length())
+  for i, pair in pairs {
+    let entry_path = path.add_index(i)
+    guard pair is Array([key, value]) else {
+      raise JsonDecodeError(
+        (entry_path, "@immut/vector_map.from_json: expected [key, value] pair"),
+      )
+    }
+    entries.push(
+      (
+        @json.FromJson::from_json(key, entry_path.add_index(0)),
+        @json.FromJson::from_json(value, entry_path.add_index(1)),
+      ),
+    )
+  }
+  from_pairs(entries.iter())
+}
diff --git a/immut/vector_map/types.mbt b/immut/vector_map/types.mbt
new file mode 100644
index 0000000000..3eac655627
--- /dev/null
+++ b/immut/vector_map/types.mbt
@@ -0,0 +1,77 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+/// A persistent map that iterates in **insertion order**, the immutable
+/// counterpart of the built-in insertion-ordered `Map`.
+///
+/// Entries live in a persistent vector — the *spine* — in the order they were
+/// first inserted, and a persistent hash map — the *index* — maps each key to
+/// its slot in that spine. Iteration therefore reads the spine straight
+/// through, without descending the hash trie, which is what makes it suited to
+/// rendering a collection on every state change.
+///
+/// Removal punches a hole in the spine rather than shifting the entries after
+/// it, since shifting would invalidate every index entry to their right. Those
+/// holes go two ways as removals continue. A hole left at the end of the spine
+/// is trimmed at once, on the removal that made it, and trimming one can
+/// uncover more. A hole in the middle waits for `compact`, which runs only when
+/// the spine is both at least `MIN_COMPACT_LENGTH` slots long and holding more
+/// holes than live entries — a spine too short to be worth rebuilding is left
+/// alone however holey it gets. Beyond that, a `filter` that rejects anything
+/// rebuilds the spine dense whatever its length, so it clears every hole as a
+/// side effect.
+///
+/// # Invariants
+///
+/// - `index` holds exactly the live keys, and `spine[index[k]]` is
+///   `Some((k, _))` for every one of them.
+/// - `size` is the number of `Some` slots in `spine`.
+/// - The last slot of a non-empty `spine` is never a tombstone, so an empty
+///   map always has an empty spine.
+/// - Tombstones never outnumber live entries once the spine reaches
+///   `MIN_COMPACT_LENGTH`.
+///
+/// Note that the representation is deliberately *not* canonical: two equal maps
+/// may hold different tombstone layouts, so `Eq` compares the live entry
+/// sequence rather than the underlying structure.
+///
+/// # Complexity
+///
+/// Each operation carries its own cost below. Two conventions run through them:
+/// `n` is the number of live entries — never the number of pairs an operation
+/// consumes, which is written `m` where the two differ — and a *descent*, of
+/// the hash trie or of the spine, is `O(log n)` for keys whose hashes are
+/// reasonably distributed. Keys that collide on the whole hash share a bucket
+/// searched linearly, so a bad `Hash` degrades every keyed operation towards
+/// `O(n)`; that is inherited from `@immut/hashmap` rather than added here.
+///
+/// Traversals are linear in the *spine*, whose length the reclamation rules
+/// bound at `max(MIN_COMPACT_LENGTH - 1, 2n)`: a spine too short to be worth
+/// rebuilding is left alone however holey it gets, and above that length the
+/// trigger is a strict majority of holes, so an even split survives. Traversals
+/// are therefore `O(n)`, with a constant factor that rises as holes accumulate
+/// and, for a map that has shrunk to almost nothing, a floor set by the
+/// exemption rather than by the entry count.
+struct VectorMap[K, V] {
+  index : @hashmap.HashMap[K, Int]
+  spine : @vector.Vector[(K, V)?]
+  size : Int
+}
+
+///|
+/// A spine shorter than this is never compacted: rebuilding it costs more than
+/// the slots it wastes. Matches the threshold Immutable.js uses for the same
+/// representation.
+const MIN_COMPACT_LENGTH : Int = 32
diff --git a/immut/vector_map/vector_map.mbt b/immut/vector_map/vector_map.mbt
new file mode 100644
index 0000000000..d840be9d5a
--- /dev/null
+++ b/immut/vector_map/vector_map.mbt
@@ -0,0 +1,316 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+/// Create an empty map. `O(1)`.
+#as_free_fn
+pub fn[K, V] VectorMap::new() -> VectorMap[K, V] {
+  { index: @hashmap.new(), spine: @vector.new(), size: 0, }
+}
+
+///|
+/// Create a map holding a single entry. `O(1)`.
+#as_free_fn
+pub fn[K : Hash, V] VectorMap::singleton(key : K, value : V) -> VectorMap[K, V] {
+  {
+    index: @hashmap.singleton(key, 0),
+    spine: @vector.singleton(Some((key, value))),
+    size: 1,
+  }
+}
+
+///|
+/// Create a map from an array of key-value pairs, in the array's order.
+///
+/// A repeated key keeps the position of its **first** occurrence and the value
+/// of its **last**, matching what repeated `add`s would produce.
+///
+/// One trie descent per input pair, plus a second for each pair that introduces
+/// a key, so `O(m log n)` for `m` pairs — repeated keys cost the lookup only.
+///
+/// # Example
+///
+/// ```mbt check
+/// test {
+///   let m = @vector_map.VectorMap([(3, "c"), (1, "a"), (3, "z")])
+///   debug_inspect(
+///     m.to_array(),
+///     content=(
+///       #|[(3, "z"), (1, "a")]
+///     ),
+///   )
+/// }
+/// ```
+pub fn[K : Eq + Hash, V] VectorMap::VectorMap(
+  arr : ArrayView[(K, V)],
+) -> VectorMap[K, V] {
+  from_pairs(arr.iter())
+}
+
+///|
+/// Create a map from an iterator of key-value pairs, in the iterator's order.
+///
+/// A repeated key keeps the position of its first occurrence and the value of
+/// its last. One trie descent per pair, plus a second for each pair that
+/// introduces a key, so `O(m log n)` for `m` pairs.
+#as_free_fn
+#alias(from_iterator, deprecated)
+#as_free_fn(from_iterator, deprecated)
+pub fn[K : Eq + Hash, V] VectorMap::from_iter(
+  iter : Iter[(K, V)],
+) -> VectorMap[K, V] {
+  from_pairs(iter)
+}
+
+///|
+/// Build a dense map in a single pass: the first sighting of a key appends a
+/// slot, every later one overwrites that slot's value in place.
+fn[K : Eq + Hash, V] from_pairs(iter : Iter[(K, V)]) -> VectorMap[K, V] {
+  let entries : Array[(K, V)] = []
+  let mut index : @hashmap.HashMap[K, Int] = @hashmap.new()
+  for kv in iter {
+    let (key, value) = kv
+    match index.get(key) {
+      // Keep the key first inserted, so that the retained key and the
+      // retained position always come from the same occurrence.
+      Some(slot) => entries[slot] = (entries[slot].0, value)
+      None => {
+        index = index.add(key, entries.length())
+        entries.push((key, value))
+      }
+    }
+  }
+  {
+    index,
+    spine: @vector.from_iter(entries.iter().map(kv => Some(kv))),
+    size: entries.length(),
+  }
+}
+
+///|
+/// The number of entries in the map. `O(1)` — the count is stored, unlike
+/// `@immut/hashmap.HashMap::length` which walks the whole trie.
+#alias(size, deprecated)
+pub fn[K, V] VectorMap::length(self : VectorMap[K, V]) -> Int {
+  self.size
+}
+
+///|
+/// Whether the map holds no entries. `O(1)`.
+pub fn[K, V] VectorMap::is_empty(self : VectorMap[K, V]) -> Bool {
+  self.size == 0
+}
+
+///|
+/// Look up a key. One trie descent and one spine descent, so `O(log n)`.
+#alias(find, deprecated)
+pub fn[K : Eq + Hash, V] VectorMap::get(self : VectorMap[K, V], key : K) -> V? {
+  guard self.index.get(key) is Some(slot) else { return None }
+  guard self.spine.get(slot) is Some(Some((_, value))) else { return None }
+  Some(value)
+}
+
+///|
+/// Look up a key, aborting when it is absent. `O(log n)`, as for `get`.
+#alias("_[_]")
+pub fn[K : Eq + Hash, V] VectorMap::at(self : VectorMap[K, V], key : K) -> V {
+  guard! self.get(key) is Some(value)
+  value
+}
+
+///|
+/// Whether the map holds an entry for `key`. One trie descent and no spine
+/// access at all, so `O(log n)` and cheaper than `get`.
+pub fn[K : Eq + Hash, V] VectorMap::contains(
+  self : VectorMap[K, V],
+  key : K,
+) -> Bool {
+  self.index.contains(key)
+}
+
+///|
+/// Add an entry, returning a new map.
+///
+/// A key already present keeps its position and only its value is replaced; a
+/// new key is appended after every existing entry. Removing a key and adding it
+/// back therefore moves it to the end.
+///
+/// `O(log n)` for a key already present. For a new key `O(log n)` amortised,
+/// but `O(n)` on the call that carries the spine over the rebuild threshold.
+///
+/// # Example
+///
+/// ```mbt check
+/// test {
+///   let m = @vector_map.VectorMap([("a", 1), ("b", 2)])
+///   // updating in place keeps "a" first
+///   debug_inspect(
+///     m.add("a", 10).keys().to_array(),
+///     content=(
+///       #|["a", "b"]
+///     ),
+///   )
+///   // re-adding after a removal appends
+///   debug_inspect(
+///     m.remove("a").add("a", 10).keys().to_array(),
+///     content=(
+///       #|["b", "a"]
+///     ),
+///   )
+/// }
+/// ```
+pub fn[K : Eq + Hash, V] VectorMap::add(
+  self : VectorMap[K, V],
+  key : K,
+  value : V,
+) -> VectorMap[K, V] {
+  match self.index.get(key) {
+    Some(slot) => {
+      // The key already stored is the one kept, so that the retained key and
+      // the retained position always come from the same insertion.
+      guard! self.spine.get(slot) is Some(Some((old_key, _)))
+      {
+        index: self.index,
+        spine: self.spine.set(slot, Some((old_key, value))),
+        size: self.size,
+      }
+    }
+    None => {
+      // Appending grows the spine, which can carry it over the length at which
+      // holes left by earlier removals become worth reclaiming — a spine of 31
+      // slots holding one entry is under the threshold and so is left alone,
+      // but the entry appended after it is not. Hence the check here as well as
+      // in `remove`: the ratio is a property of the spine, not of one operation.
+      let map = {
+        index: self.index.add(key, self.spine.length()),
+        spine: self.spine.push(Some((key, value))),
+        size: self.size + 1,
+      }
+      if map.should_compact() {
+        map.compact()
+      } else {
+        map
+      }
+    }
+  }
+}
+
+///|
+/// Remove a key, returning a new map. A key that is absent returns the receiver
+/// itself.
+///
+/// `O(log n)` amortised. The worst case is a call that reclaims: trimming `k`
+/// uncovered holes costs `k` spine pops, each of which may copy a path, so
+/// `O(k log n)`; a rebuild costs `O(n)`. The amortisation holds along one line
+/// of descent only — repeatedly removing from a version taken just before a
+/// trim or a rebuild pays for it each time.
+pub fn[K : Eq + Hash, V] VectorMap::remove(
+  self : VectorMap[K, V],
+  key : K,
+) -> VectorMap[K, V] {
+  guard self.index.get(key) is Some(slot) else { return self }
+  // Punching a hole keeps every surviving slot where it is, so the rest of the
+  // index stays valid; trimming afterwards keeps last-in-first-out deletion
+  // from leaving anything behind at all.
+  let map = {
+    index: self.index.remove(key),
+    spine: trim_trailing(self.spine.set(slot, None)),
+    size: self.size - 1,
+  }
+  if map.should_compact() {
+    map.compact()
+  } else {
+    map
+  }
+}
+
+///|
+/// Replace, insert, or remove the entry for `key` in one step: `f` sees the
+/// current value if there is one, and its result becomes the new value, or
+/// removes the key when it is `None`.
+///
+/// A lookup followed by an `add` or a `remove`, so `O(log n)` amortised, with
+/// the reclamation worst cases those two carry.
+///
+/// # Example
+///
+/// ```mbt check
+/// test {
+///   let m = @vector_map.VectorMap([("a", 1)])
+///   let bumped = m.update("a", v => Some(v.unwrap_or(0) + 1))
+///   debug_inspect(bumped.get("a"), content="Some(2)")
+///   let cleared = m.update("a", _ => None)
+///   inspect(cleared.is_empty(), content="true")
+/// }
+/// ```
+pub fn[K : Eq + Hash, V] VectorMap::update(
+  self : VectorMap[K, V],
+  key : K,
+  f : (V?) -> V? raise?,
+) -> VectorMap[K, V] raise? {
+  match f(self.get(key)) {
+    Some(value) => self.add(key, value)
+    None => self.remove(key)
+  }
+}
+
+///|
+/// Drop tombstones off the end of the spine, restoring the invariant that a
+/// non-empty spine ends in a live entry.
+fn[K, V] trim_trailing(
+  spine : @vector.Vector[(K, V)?],
+) -> @vector.Vector[(K, V)?] {
+  for s = spine {
+    guard s.peek() is Some(None) else { break s }
+    guard s.pop() is Some(rest) else { break s }
+    continue rest
+  }
+}
+
+///|
+/// Whether the tombstones are worth an `O(n)` rebuild.
+///
+/// Rebuilding once the holes outnumber the live entries keeps the wasted slots
+/// at or below half — the comparison is strict, so an even split stands — and
+/// makes each rebuild pay for the `Omega(n)` removals that caused it. Note that
+/// the threshold compares `holes > size` rather than `size < length / 2`: the
+/// two disagree on odd spines carrying exactly one hole more than they carry
+/// entries, where integer division rounds the majority away — at length 33 with
+/// 16 entries and 17 holes, `size < length / 2` is `16 < 16` and never fires.
+fn[K, V] VectorMap::should_compact(self : VectorMap[K, V]) -> Bool {
+  let length = self.spine.length()
+  length >= MIN_COMPACT_LENGTH && length - self.size > self.size
+}
+
+///|
+/// Rebuild the spine dense and renumber the index onto the new slots.
+///
+/// No key is hashed again: `HashMap::map` walks the existing trie and rebuilds
+/// it around the new values, node for node, so the shape is carried over
+/// without a single hash being recomputed.
+fn[K, V] VectorMap::compact(self : VectorMap[K, V]) -> VectorMap[K, V] {
+  let remap = FixedArray::make(self.spine.length(), 0)
+  let kept : Array[(K, V)?] = Array(capacity=self.size)
+  self.spine.eachi((slot, entry) => {
+    if entry is Some(_) {
+      remap[slot] = kept.length()
+      kept.push(entry)
+    }
+  })
+  {
+    index: self.index.map((_, slot) => remap[slot]),
+    spine: @vector.from_iter(kept.iter()),
+    size: kept.length(),
+  }
+}
diff --git a/immut/vector_map/vector_map_test.mbt b/immut/vector_map/vector_map_test.mbt
new file mode 100644
index 0000000000..03007fef8d
--- /dev/null
+++ b/immut/vector_map/vector_map_test.mbt
@@ -0,0 +1,537 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+test "construction" {
+  let empty : @vector_map.VectorMap[String, Int] = @vector_map.new()
+  inspect(empty.length(), content="0")
+  inspect(empty.is_empty(), content="true")
+  let single = @vector_map.singleton("a", 1)
+  debug_inspect(
+    single.to_array(),
+    content=(
+      #|[("a", 1)]
+    ),
+  )
+  let from_arr = @vector_map.VectorMap([("b", 2), ("a", 1), ("c", 3)])
+  debug_inspect(
+    from_arr.to_array(),
+    content=(
+      #|[("b", 2), ("a", 1), ("c", 3)]
+    ),
+  )
+  let from_it = @vector_map.from_iter([|("b", 2), ("a", 1)|])
+  debug_inspect(
+    from_it.to_array(),
+    content=(
+      #|[("b", 2), ("a", 1)]
+    ),
+  )
+}
+
+///|
+test "a repeated key keeps the first position and the last value" {
+  let m = @vector_map.VectorMap([("a", 1), ("b", 2), ("a", 3), ("c", 4)])
+  debug_inspect(
+    m.to_array(),
+    content=(
+      #|[("a", 3), ("b", 2), ("c", 4)]
+    ),
+  )
+  inspect(m.length(), content="3")
+  // …exactly as repeated `add`s would leave it
+  let built = @vector_map.new().add("a", 1).add("b", 2).add("a", 3).add("c", 4)
+  assert_true(m == built)
+}
+
+///|
+test "singleton builds a whole map, not just a spine" {
+  // `to_array` reads only the spine, so a `singleton` that forgot to populate
+  // its index would look right and then fail every keyed operation.
+  let one = @vector_map.singleton("a", 1)
+  inspect(one.length(), content="1")
+  debug_inspect(one.get("a"), content="Some(1)")
+  inspect(one.contains("a"), content="true")
+  inspect(one["a"], content="1")
+  inspect(one.remove("a").is_empty(), content="true")
+  // replacing the value updates in place rather than appending a second entry
+  debug_inspect(
+    one.add("a", 2).to_array(),
+    content=(
+      #|[("a", 2)]
+    ),
+  )
+  debug_inspect(
+    one.update("a", v => Some(v.unwrap() + 10)).to_array(),
+    content=(
+      #|[("a", 11)]
+    ),
+  )
+  assert_true(one == VectorMap([("a", 1)]))
+}
+
+///|
+test "lookup" {
+  let m = @vector_map.VectorMap([("a", 1), ("b", 2)])
+  debug_inspect(m.get("a"), content="Some(1)")
+  debug_inspect(m.get("z"), content="None")
+  inspect(m["b"], content="2")
+  inspect(m.contains("a"), content="true")
+  inspect(m.contains("z"), content="false")
+}
+
+///|
+test "panic at on a missing key" {
+  let m : @vector_map.VectorMap[String, Int] = @vector_map.new()
+  ignore(m["missing"])
+}
+
+///|
+test "add keeps an existing key in place but appends a fresh one" {
+  let m = @vector_map.VectorMap([("a", 1), ("b", 2), ("c", 3)])
+  debug_inspect(
+    m.add("b", 20).to_array(),
+    content=(
+      #|[("a", 1), ("b", 20), ("c", 3)]
+    ),
+  )
+  debug_inspect(
+    m.add("d", 4).to_array(),
+    content=(
+      #|[("a", 1), ("b", 2), ("c", 3), ("d", 4)]
+    ),
+  )
+  // removing and re-adding moves the key to the end
+  debug_inspect(
+    m.remove("a").add("a", 1).to_array(),
+    content=(
+      #|[("b", 2), ("c", 3), ("a", 1)]
+    ),
+  )
+  // the receiver is untouched throughout
+  debug_inspect(
+    m.to_array(),
+    content=(
+      #|[("a", 1), ("b", 2), ("c", 3)]
+    ),
+  )
+}
+
+///|
+test "remove" {
+  let m = @vector_map.VectorMap([("a", 1), ("b", 2), ("c", 3)])
+  debug_inspect(
+    m.remove("b").to_array(),
+    content=(
+      #|[("a", 1), ("c", 3)]
+    ),
+  )
+  // removing the last entry, then the rest
+  inspect(m.remove("c").remove("b").remove("a").is_empty(), content="true")
+}
+
+///|
+test "a no-op returns the receiver itself" {
+  // Callers that skip work when the map is physically unchanged depend on
+  // these two, so they are contract rather than incidental optimisation.
+  let m = @vector_map.VectorMap([("a", "x"), ("b", "y")])
+  assert_true(physical_equal(m.remove("missing"), m))
+  assert_true(physical_equal(m.filter((_, _) => true), m))
+  // A holey receiver is handed back holes and all, rather than compacted.
+  let holey = @vector_map.VectorMap([("a", "x"), ("gone", "z"), ("b", "y")]).remove(
+    "gone",
+  )
+  assert_true(physical_equal(holey.filter((_, _) => true), holey))
+  // …and a real change does not preserve identity
+  assert_false(physical_equal(m.remove("a"), m))
+  assert_false(physical_equal(m.filter((k, _) => k != "a"), m))
+  // `add` makes no such promise, even when it stores the very value already
+  // there: skipping the write would mean deciding equality with
+  // `physical_equal`, which is documented as unsafe to hang semantics on. So it
+  // allocates, and only compares equal.
+  assert_true(m.add("a", m["a"]) == m)
+  assert_false(physical_equal(m.add("a", m["a"]), m))
+}
+
+///|
+test "update" {
+  let m = @vector_map.VectorMap([("a", 1), ("b", 2)])
+  // replace in place
+  debug_inspect(
+    m.update("a", v => Some(v.unwrap() + 10)).to_array(),
+    content=(
+      #|[("a", 11), ("b", 2)]
+    ),
+  )
+  // insert, appending
+  debug_inspect(
+    m.update("c", _ => Some(3)).to_array(),
+    content=(
+      #|[("a", 1), ("b", 2), ("c", 3)]
+    ),
+  )
+  // remove
+  debug_inspect(
+    m.update("a", _ => None).to_array(),
+    content=(
+      #|[("b", 2)]
+    ),
+  )
+  // removing something absent is a no-op, down to the identity of the result:
+  // `update` reaches `remove`, which hands the receiver back untouched
+  assert_true(m.update("z", _ => None) == m)
+  assert_true(physical_equal(m.update("z", _ => None), m))
+}
+
+///|
+test "traversals all agree on insertion order" {
+  let m = @vector_map.VectorMap([("c", 3), ("a", 1), ("b", 2)])
+  debug_inspect(
+    m.keys().to_array(),
+    content=(
+      #|["c", "a", "b"]
+    ),
+  )
+  debug_inspect(m.values().to_array(), content="[3, 1, 2]")
+  let seen = []
+  m.each((k, v) => seen.push("\{k}=\{v}"))
+  debug_inspect(
+    seen,
+    content=(
+      #|["c=3", "a=1", "b=2"]
+    ),
+  )
+  let indexed = []
+  m.eachi((i, k, _) => indexed.push("\{i}:\{k}"))
+  debug_inspect(
+    indexed,
+    content=(
+      #|["0:c", "1:a", "2:b"]
+    ),
+  )
+  inspect(m.fold(init="", (acc, k, _) => acc + k), content="cab")
+  let pairs = []
+  for k, v in m {
+    pairs.push((k, v))
+  }
+  debug_inspect(
+    pairs,
+    content=(
+      #|[("c", 3), ("a", 1), ("b", 2)]
+    ),
+  )
+}
+
+///|
+test "eachi numbers the live entries, not the underlying slots" {
+  // "b" leaves a hole behind, which must not show up as a skipped index.
+  let m = @vector_map.VectorMap([("a", 1), ("b", 2), ("c", 3)]).remove("b")
+  let indexed = []
+  m.eachi((i, k, _) => indexed.push("\{i}:\{k}"))
+  debug_inspect(
+    indexed,
+    content=(
+      #|["0:a", "1:c"]
+    ),
+  )
+}
+
+///|
+test "map and filter preserve order" {
+  let m = @vector_map.VectorMap([("c", 3), ("a", 1), ("b", 2)])
+  debug_inspect(
+    m.map((k, v) => "\{k}\{v}").to_array(),
+    content=(
+      #|[("c", "c3"), ("a", "a1"), ("b", "b2")]
+    ),
+  )
+  debug_inspect(
+    m.filter((_, v) => v != 1).to_array(),
+    content=(
+      #|[("c", 3), ("b", 2)]
+    ),
+  )
+  // filtering everything out leaves an empty map
+  inspect(m.filter((_, _) => false).is_empty(), content="true")
+}
+
+///|
+test "equality is order sensitive" {
+  let ab = @vector_map.VectorMap([("a", 1), ("b", 2)])
+  let ba = @vector_map.VectorMap([("b", 2), ("a", 1)])
+  assert_true(ab == ab)
+  assert_false(ab == ba)
+  assert_true(ab != ba)
+  // equal content in equal order compares equal however it was built
+  assert_true(ab == @vector_map.new().add("a", 1).add("b", 2))
+  // A prefix is not equal to the whole — and this has to be checked from BOTH
+  // sides. Comparing entry by entry only detects the short side running out
+  // when it is on the right, so a length check that fired one way round would
+  // leave `Eq` asymmetric.
+  let a_only = @vector_map.singleton("a", 1)
+  let empty : @vector_map.VectorMap[String, Int] = @vector_map.new()
+  assert_false(ab == a_only)
+  assert_false(a_only == ab)
+  assert_false(ab == empty)
+  assert_false(empty == ab)
+  // same keys, same order, one different value
+  assert_false(ab == VectorMap([("a", 1), ("b", 99)]))
+  assert_false(ab == VectorMap([("a", 99), ("b", 2)]))
+  // same order and values, one different key
+  assert_false(ab == VectorMap([("a", 1), ("z", 2)]))
+}
+
+///|
+test "equality ignores how the holes fell" {
+  // Same live entries in the same order, but reached by different histories:
+  // one spine carries a tombstone, the other does not.
+  let punched = @vector_map.VectorMap([("a", 1), ("gone", 0), ("b", 2)]).remove(
+    "gone",
+  )
+  let dense = @vector_map.VectorMap([("a", 1), ("b", 2)])
+  assert_true(punched == dense)
+  let h1 = Hasher()
+  h1.combine(punched)
+  let h2 = Hasher()
+  h2.combine(dense)
+  inspect(h1.finalize() == h2.finalize(), content="true")
+}
+
+///|
+fn[K : Hash, V : Hash] hash_of(map : @vector_map.VectorMap[K, V]) -> Int {
+  let hasher = Hasher()
+  hasher.combine(map)
+  hasher.finalize()
+}
+
+///|
+test "every part of an entry feeds the hash" {
+  // Only "equal maps hash equally" is a contract — unequal maps are always free
+  // to collide. Each case below pins one concrete pair, so that a hash which
+  // quietly stopped folding in the keys or the values would show up rather than
+  // passing on the strength of the component it still folds in.
+  let ab = @vector_map.VectorMap([("a", 1), ("b", 2)])
+  // keys: same values, same order
+  inspect(
+    hash_of(ab) == hash_of(VectorMap([("x", 1), ("y", 2)])),
+    content="false",
+  )
+  // values: same keys, same order
+  inspect(
+    hash_of(ab) == hash_of(VectorMap([("a", 9), ("b", 8)])),
+    content="false",
+  )
+  // Shorter maps: nothing folds the length in explicitly, so these check that
+  // feeding the entries in sequence is enough to separate a prefix anyway.
+  inspect(
+    hash_of(ab) == hash_of(@vector_map.singleton("a", 1)),
+    content="false",
+  )
+  let empty : @vector_map.VectorMap[String, Int] = @vector_map.new()
+  inspect(hash_of(ab) == hash_of(empty), content="false")
+}
+
+///|
+test "hashing distinguishes the orderings" {
+  // Guards against the hash silently becoming order-insensitive the way
+  // `@immut/hashmap`'s deliberately is.
+  let ab = @vector_map.VectorMap([("a", 1), ("b", 2)])
+  let ba = @vector_map.VectorMap([("b", 2), ("a", 1)])
+  let h1 = Hasher()
+  h1.combine(ab)
+  let h2 = Hasher()
+  h2.combine(ba)
+  inspect(h1.finalize() == h2.finalize(), content="false")
+}
+
+///|
+test "Debug for VectorMap" {
+  let m = @vector_map.VectorMap([(2, "b"), (1, "a")])
+  @debug.debug_inspect(
+    m,
+    content=(
+      #|
+    ),
+  )
+}
+
+///|
+test "json round trip keeps the order and the key type" {
+  let m = @vector_map.VectorMap([(30, "c"), (10, "a"), (20, "b")])
+  json_inspect(m, content=[[30, "c"], [10, "a"], [20, "b"]])
+  let back : @vector_map.VectorMap[Int, String] = @json.from_json(Json(m))
+  assert_true(back == m)
+}
+
+///|
+test "json decoding rejects malformed input, pointing at it" {
+  // An object cannot carry an order, so it is refused outright.
+  let bad : Json = { "a": 1 }
+  try (@json.from_json(bad) : @vector_map.VectorMap[Int, Int]) |> ignore catch {
+    JsonDecodeError((path, msg)) => {
+      inspect(
+        path,
+        content=(
+          #|
+        ),
+      )
+      inspect(msg, content="@immut/vector_map.from_json: expected array")
+    }
+  } noraise {
+    _ => fail("expected an object to be rejected")
+  }
+  // A pair of the wrong width is reported at the offending entry, not at the
+  // root — the path is what makes a decode failure diagnosable.
+  let ragged : Json = [[0, 0], [1]]
+  try
+    (@json.from_json(ragged) : @vector_map.VectorMap[Int, Int]) |> ignore
+  catch {
+    JsonDecodeError((path, msg)) => {
+      inspect(
+        path,
+        content=(
+          #|/1
+        ),
+      )
+      inspect(
+        msg,
+        content="@immut/vector_map.from_json: expected [key, value] pair",
+      )
+    }
+  } noraise {
+    _ => fail("expected a one-element pair to be rejected")
+  }
+  // A malformed KEY is reported at position 0 of its pair, a malformed value
+  // at position 1 — the two halves are decoded under different paths, so both
+  // need pinning.
+  let bad_key : Json = [[0, 0], ["not an int", 1]]
+  try
+    (@json.from_json(bad_key) : @vector_map.VectorMap[Int, Int]) |> ignore
+  catch {
+    JsonDecodeError((path, _)) =>
+      inspect(
+        path,
+        content=(
+          #|/1/0
+        ),
+      )
+  } noraise {
+    _ => fail("expected a mistyped key to be rejected")
+  }
+  // A value of the wrong type is reported inside the pair.
+  let mistyped : Json = [[0, "not an int"]]
+  try
+    (@json.from_json(mistyped) : @vector_map.VectorMap[Int, Int]) |> ignore
+  catch {
+    JsonDecodeError((path, _)) =>
+      inspect(
+        path,
+        content=(
+          #|/0/1
+        ),
+      )
+  } noraise {
+    _ => fail("expected a mistyped value to be rejected")
+  }
+}
+
+///|
+test "json decoding applies the same duplicate-key rule as construction" {
+  // Decoding goes through the same builder as `VectorMap([...])`, so a repeated
+  // key keeps its first position and its last value rather than appearing twice.
+  let dup : Json = [[1, "a"], [2, "b"], [1, "z"]]
+  let m : @vector_map.VectorMap[Int, String] = @json.from_json(dup)
+  debug_inspect(
+    m.to_array(),
+    content=(
+      #|[(1, "z"), (2, "b")]
+    ),
+  )
+  assert_true(m == VectorMap([(1, "a"), (2, "b"), (1, "z")]))
+}
+
+///|
+test "removals past the compaction threshold keep every promise" {
+  // 200 entries is well past `MIN_COMPACT_LENGTH`, so dropping most of them
+  // forces at least one rebuild of both the spine and the index.
+  let m = @vector_map.from_iter((0).until(200).map(i => (i, i * i)))
+  let sparse = (0)
+    .until(200)
+    .fold(init=m, (acc, i) => if i % 5 == 0 { acc } else { acc.remove(i) })
+  inspect(sparse.length(), content="40")
+  debug_inspect(
+    sparse.keys().to_array()[:5].to_owned(),
+    content="[0, 5, 10, 15, 20]",
+  )
+  // Every survivor still resolves THROUGH THE INDEX, and to the value it was
+  // given. Iterating would only read the spine, so it would not notice an index
+  // left pointing at the pre-rebuild slots; `get` and `contains` are the direct
+  // read-only probes of it, which is what makes them the ones to sweep with
+  // here. (`add`, `remove` and `update` consult it too, but by changing the map
+  // rather than reporting on it.)
+  for i in 0..<200 {
+    if i % 5 == 0 {
+      assert_true(sparse.contains(i))
+      assert_eq(sparse.get(i), Some(i * i))
+    } else {
+      assert_false(sparse.contains(i))
+      assert_eq(sparse.get(i), None)
+    }
+  }
+  // and the surviving order matches a map built dense from the start
+  assert_true(sparse == @vector_map.from_iter(sparse.iter()))
+  // the original is untouched by all of it
+  inspect(m.length(), content="200")
+}
+
+///|
+/// A key whose identity is its `name` alone: two `TaggedKey`s with the same
+/// name are `Eq` and hash alike, but carry a `tag` that says which of them a
+/// map actually kept. Without a key like this, "the first key is retained" is
+/// unobservable — repeating an identical `String` proves nothing.
+priv struct TaggedKey {
+  name : String
+  tag : Int
+}
+
+///|
+impl Eq for TaggedKey with fn equal(self, other) {
+  self.name == other.name
+}
+
+///|
+impl Hash for TaggedKey with fn hash_combine(self, hasher) {
+  hasher.combine_string(self.name)
+}
+
+///|
+test "a replaced entry keeps the key it was first inserted with" {
+  let first = { name: "k", tag: 1, }
+  let second = { name: "k", tag: 2, }
+  let third = { name: "k", tag: 3, }
+  // Bulk construction: last value wins, but the key stays the first one, so
+  // that the retained key and the retained position come from one insertion.
+  let built = @vector_map.VectorMap([(first, "x"), (second, "y")])
+  inspect(built.length(), content="1")
+  inspect(built.to_array()[0].0.tag, content="1")
+  inspect(built.to_array()[0].1, content="y")
+  // `add` over an existing key behaves the same way.
+  let updated = built.add(third, "z")
+  inspect(updated.to_array()[0].0.tag, content="1")
+  inspect(updated.to_array()[0].1, content="z")
+  // …and after the key has been removed, the next one inserted is kept,
+  // because there is no earlier key left to retain.
+  let reinserted = built.remove(first).add(third, "z")
+  inspect(reinserted.to_array()[0].0.tag, content="3")
+}
diff --git a/int/README.mbt.md b/int/README.mbt.md
index a83dfe2ccc..3bfe7ee11b 100644
--- a/int/README.mbt.md
+++ b/int/README.mbt.md
@@ -21,7 +21,7 @@ test "basic int operations" {
 
 ## Byte Conversion
 
-The package provides methods to convert integers to their byte representation in both big-endian and little-endian formats:
+An integer's byte representation, in both big-endian and little-endian formats, is produced with a `Buffer`:
 
 ```mbt check
 ///|
diff --git a/int16/int16.mbt b/int16/int16.mbt
index ff46f6a382..c997aaef76 100644
--- a/int16/int16.mbt
+++ b/int16/int16.mbt
@@ -231,7 +231,7 @@ pub fn Int16::from_int(self : Int) -> Int16 = "%i32_to_i16"
 /// ```mbt check
 /// test {
 ///   let b = b'\xFF'
-///   inspect(Int16::from_byte(b), content="255") // Sign is preserved
+///   inspect(Int16::from_byte(b), content="255") // Zero-extended, not sign-extended
 ///   let p = b'\x7F'
 ///   inspect(Int16::from_byte(p), content="127")
 /// }
@@ -261,9 +261,7 @@ pub fn Int16::from_byte(self : Byte) -> Int16 = "%byte_to_i16"
 ///   inspect(Int16::from_int64(small), content="42") // 42 fits in Int16, remains unchanged
 /// }
 /// ```
-/// Create from `int64`.
 #cfg(not(target="js"))
-/// Convert `Int64` to `Int16`.
 pub fn Int16::from_int64(self : Int64) -> Int16 = "%i64_to_i16"
 
 ///|
diff --git a/int64/README.mbt.md b/int64/README.mbt.md
index e5fb61f100..0062fa0c4f 100644
--- a/int64/README.mbt.md
+++ b/int64/README.mbt.md
@@ -24,14 +24,18 @@ test "basic operations" {
 
 ## Binary Representation
 
-The package provides functions to convert `Int64` values to their binary representation in both big-endian and little-endian byte order:
+`Int64` values can be written out in their binary representation, in either big-endian or little-endian byte order, using a `Buffer`:
 
 ```mbt check
 ///|
 test "binary conversion" {
   let x = 258L // Int64 value of 258
-  let be_bytes = x.to_be_bytes_local()
-  let le_bytes = x.to_le_bytes_local()
+  let be_buf = Buffer(size_hint=8)
+  be_buf.write_int64_be(x)
+  let be_bytes = be_buf.to_bytes()
+  let le_buf = Buffer(size_hint=8)
+  le_buf.write_int64_le(x)
+  let le_bytes = le_buf.to_bytes()
 
   // Convert to String for inspection
   inspect(
@@ -65,9 +69,11 @@ test "method style" {
   // Using method syntax for absolute value
   inspect(x.abs(), content="42")
 
-  // Binary conversions as methods
+  // Binary conversion via `Buffer`
+  let buf = Buffer(size_hint=8)
+  buf.write_int64_be(x)
   inspect(
-    x.to_be_bytes_local(),
+    buf.to_bytes(),
     content=(
       #|b"\xff\xff\xff\xff\xff\xff\xff\xd6"
     ),
diff --git a/internal/edit_distance/edit_distance.mbt b/internal/edit_distance/edit_distance.mbt
index def89734c5..81d4e6def9 100644
--- a/internal/edit_distance/edit_distance.mbt
+++ b/internal/edit_distance/edit_distance.mbt
@@ -235,7 +235,7 @@ fn[S : LevInput] lev_within(s : S, max_distance : Int) -> Int? {
 /// ```
 pub fn[T : Eq] edit_distance(a : ArrayView[T], b : ArrayView[T]) -> Int {
   let (a, b) = if a.length() >= b.length() { (a, b) } else { (b, a) }
-  let s : ViewPair[T] = { a, b }
+  let s : ViewPair[T] = { a, b, }
   lev_distance(s)
 }
 
@@ -278,7 +278,7 @@ pub fn[T : Eq] edit_distance_within(
     return None
   }
   let (a, b) = if a.length() >= b.length() { (a, b) } else { (b, a) }
-  let s : ViewPair[T] = { a, b }
+  let s : ViewPair[T] = { a, b, }
   lev_within(s, max_distance)
 }
 
@@ -309,7 +309,7 @@ pub fn[T : Eq] edit_distance_within(
 /// ```
 pub fn edit_distance_str(a : StringView, b : StringView) -> Int {
   let (a, b) = if a.length() >= b.length() { (a, b) } else { (b, a) }
-  let s : StrPair = { a, b }
+  let s : StrPair = { a, b, }
   lev_distance(s)
 }
 
@@ -341,6 +341,6 @@ pub fn edit_distance_str_within(
     return None
   }
   let (a, b) = if a.length() >= b.length() { (a, b) } else { (b, a) }
-  let s : StrPair = { a, b }
+  let s : StrPair = { a, b, }
   lev_within(s, max_distance)
 }
diff --git a/internal/edit_distance/moon.pkg b/internal/edit_distance/moon.pkg
index 9abd69fcb5..d80fe87fe0 100644
--- a/internal/edit_distance/moon.pkg
+++ b/internal/edit_distance/moon.pkg
@@ -3,3 +3,8 @@ import {
   "moonbitlang/core/int",
   "moonbitlang/core/string",
 }
+
+import {
+  "moonbitlang/core/cmp",
+  "moonbitlang/core/quickcheck",
+} for "test"
diff --git a/internal/edit_distance/quickcheck_test.mbt b/internal/edit_distance/quickcheck_test.mbt
new file mode 100644
index 0000000000..767aee044d
--- /dev/null
+++ b/internal/edit_distance/quickcheck_test.mbt
@@ -0,0 +1,396 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Specification tests for the Levenshtein distance.
+//
+// The definition is a three-line dynamic program, and `oracle` below is
+// exactly that: the full (m + 1) x (n + 1) table, no trimming, no
+// banding, no saturation. The implementation is none of those things —
+// it trims the common prefix and suffix, rolls two rows instead of the
+// table, swaps the arguments so the longer side is first, and for
+// `*_within` restricts the search to a diagonal band with Ukkonen
+// bail-out and saturating arithmetic. Each of those is a chance to be
+// wrong in a way that only shows up on particular shapes of input, so
+// the suite checks the optimized code against the definition rather
+// than against itself.
+//
+// Two things drive that comparison. Exhaustive sweeps over small
+// alphabets settle whole input spaces outright — every pair of binary
+// words up to length 6, at every distance bound — which is the only way
+// to be sure a band-edge case has not been missed. Property tests then
+// carry the same checks to longer inputs, together with the axioms that
+// hold independently of the algorithm: Levenshtein is a metric, so it
+// is zero exactly on equal inputs, symmetric, and obeys the triangle
+// inequality; it is bounded below by the length difference and above by
+// the longer length; and it is invariant under a shared prefix and
+// suffix, which is precisely what the trimming step assumes.
+
+// =====================================================================
+// The definition.
+// =====================================================================
+
+///|
+/// The textbook Levenshtein recurrence, written as the full table.
+///
+/// This is the specification: `d[i][j]` is the distance between the
+/// first `i` elements of `a` and the first `j` of `b`, and the answer
+/// is `d[m][n]`. Deliberately unoptimized — no trimming, no banding, no
+/// rolling rows — so it shares no structure with the code under test.
+fn oracle(a : ArrayView[Int], b : ArrayView[Int]) -> Int {
+  let m = a.length()
+  let n = b.length()
+  let d = Array::makei(m + 1, _ => Array::make(n + 1, 0))
+  for i in 0..<(m + 1) {
+    d[i][0] = i
+  }
+  for j in 0..<(n + 1) {
+    d[0][j] = j
+  }
+  for i in 1..<(m + 1) {
+    for j in 1..<(n + 1) {
+      let substitute = d[i - 1][j - 1] +
+        (if a[i - 1] == b[j - 1] { 0 } else { 1 })
+      let delete = d[i - 1][j] + 1
+      let insert = d[i][j - 1] + 1
+      let mut best = substitute
+      if delete < best {
+        best = delete
+      }
+      if insert < best {
+        best = insert
+      }
+      d[i][j] = best
+    }
+  }
+  d[m][n]
+}
+
+// =====================================================================
+// Test data.
+// =====================================================================
+
+///|
+/// Every word of length `0 ..= max_length` over `0 ..< alphabet`.
+fn all_words(alphabet : Int, max_length : Int) -> Array[Array[Int]] {
+  let out = [[]]
+  let mut frontier : Array[Array[Int]] = [[]]
+  for _ in 0.. Int {
+  let r = value % modulus
+  if r < 0 {
+    r + modulus
+  } else {
+    r
+  }
+}
+
+///|
+/// Projects arbitrary values onto a small alphabet.
+///
+/// This matters more than it looks: over unrestricted `Int`s two random
+/// sequences almost never share an element, so the distance collapses
+/// to `max(m, n)` and the interesting part of the DP — the substitution
+/// versus insert/delete choice, and the prefix/suffix trimming — is
+/// never reached.
+fn narrow(values : ArrayView[Int], alphabet : Int) -> Array[Int] {
+  values.map(v => wrap_index(v, alphabet))
+}
+
+// =====================================================================
+// Exhaustive agreement with the definition.
+// =====================================================================
+
+///|
+/// Checks both entry points against the oracle for one pair, at every
+/// distance bound from `-1` (which must always be rejected) to one past
+/// the largest possible distance.
+fn check_pair(a : Array[Int], b : Array[Int]) -> Bool {
+  let expected = oracle(a, b)
+  // the exact distance, and its symmetry
+  guard @edit_distance.edit_distance(a, b) == expected &&
+    @edit_distance.edit_distance(b, a) == expected else {
+    return false
+  }
+  // the banded search must agree with it at every bound, from both
+  // argument orders
+  let ceiling = @cmp.maximum(a.length(), b.length()) + 1
+  for bound in -1..<(ceiling + 1) {
+    let want = if bound >= 0 && expected <= bound {
+      Some(expected)
+    } else {
+      None
+    }
+    guard @edit_distance.edit_distance_within(a, b, max_distance=bound) == want &&
+      @edit_distance.edit_distance_within(b, a, max_distance=bound) == want else {
+      return false
+    }
+  }
+  true
+}
+
+///|
+test "exhaustive: every pair of binary words up to length 6" {
+  // 127 words, 16129 ordered pairs, each checked at every bound. This
+  // settles the band-edge behaviour outright for short inputs, which is
+  // where the `lo`/`hi` clamping and the row sealing are most likely to
+  // be off by one.
+  let words = all_words(2, 6)
+  for a in words {
+    for b in words {
+      assert_true(check_pair(a, b))
+    }
+  }
+}
+
+///|
+test "exhaustive: every pair of ternary words up to length 4" {
+  // A larger alphabet makes partial matches, and so substitutions,
+  // much more common than in the binary sweep.
+  let words = all_words(3, 4)
+  for a in words {
+    for b in words {
+      assert_true(check_pair(a, b))
+    }
+  }
+}
+
+///|
+test "quickcheck: longer inputs agree with the definition" {
+  @quickcheck.check(
+    (input : (Array[Int], Array[Int], Int)) => {
+      let alphabet = 1 + wrap_index(input.2, 5)
+      let a = narrow(input.0, alphabet)
+      let b = narrow(input.1, alphabet)
+      let expected = oracle(a, b)
+      @edit_distance.edit_distance(a, b) == expected &&
+      @edit_distance.edit_distance(b, a) == expected
+    },
+    count=2000,
+  )
+}
+
+///|
+test "quickcheck: the banded search agrees at every bound" {
+  @quickcheck.check(
+    (input : (Array[Int], Array[Int], Int)) => {
+      let alphabet = 1 + wrap_index(input.2, 5)
+      let a = narrow(input.0, alphabet)
+      let b = narrow(input.1, alphabet)
+      let expected = oracle(a, b)
+      let ceiling = @cmp.maximum(a.length(), b.length()) + 1
+      for bound in -1..<(ceiling + 1) {
+        let want = if bound >= 0 && expected <= bound {
+          Some(expected)
+        } else {
+          None
+        }
+        guard @edit_distance.edit_distance_within(a, b, max_distance=bound) ==
+          want else {
+          return false
+        }
+      }
+      // a bound far above the distance must still return it exactly
+      @edit_distance.edit_distance_within(a, b, max_distance=@int.MAX_VALUE) ==
+      Some(expected)
+    },
+    count=1000,
+  )
+}
+
+// =====================================================================
+// The metric axioms.
+// =====================================================================
+
+///|
+test "quickcheck: identity of indiscernibles, and symmetry" {
+  @quickcheck.check(
+    (input : (Array[Int], Array[Int], Int)) => {
+      let alphabet = 1 + wrap_index(input.2, 4)
+      let a = narrow(input.0, alphabet)
+      let b = narrow(input.1, alphabet)
+      let ab = @edit_distance.edit_distance(a, b)
+      // d(x, x) == 0, and d(a, b) == 0 only when they are equal
+      @edit_distance.edit_distance(a, a) == 0 &&
+      (ab == 0) == (a == b) &&
+      // d(a, b) == d(b, a)
+      ab == @edit_distance.edit_distance(b, a)
+    },
+    count=2000,
+  )
+}
+
+///|
+test "quickcheck: the triangle inequality" {
+  @quickcheck.check(
+    (input : (Array[Int], Array[Int], Array[Int], Int)) => {
+      let alphabet = 1 + wrap_index(input.3, 4)
+      let a = narrow(input.0, alphabet)
+      let b = narrow(input.1, alphabet)
+      let c = narrow(input.2, alphabet)
+      @edit_distance.edit_distance(a, c) <=
+      @edit_distance.edit_distance(a, b) + @edit_distance.edit_distance(b, c)
+    },
+    count=2000,
+  )
+}
+
+///|
+test "quickcheck: bounded below by the length difference, above by the longer length" {
+  @quickcheck.check(
+    (input : (Array[Int], Array[Int], Int)) => {
+      let alphabet = 1 + wrap_index(input.2, 4)
+      let a = narrow(input.0, alphabet)
+      let b = narrow(input.1, alphabet)
+      let d = @edit_distance.edit_distance(a, b)
+      let difference = @cmp.maximum(a.length(), b.length()) -
+        @cmp.minimum(a.length(), b.length())
+      d >= difference && d <= @cmp.maximum(a.length(), b.length())
+    },
+    count=2000,
+  )
+}
+
+// =====================================================================
+// The assumptions the optimizations rest on.
+// =====================================================================
+
+///|
+test "quickcheck: a shared prefix and suffix do not change the distance" {
+  // This is exactly what the trimming step assumes. Wrapping both
+  // inputs in the same context must leave the answer alone -- and the
+  // context is drawn from the same small alphabet, so the trimming loop
+  // may well run past the intended boundary if its bound is wrong.
+  @quickcheck.check(
+    (input : (Array[Int], Array[Int], Array[Int], Array[Int])) => {
+      let a = narrow(input.0, 3)
+      let b = narrow(input.1, 3)
+      let prefix = narrow(input.2, 3)
+      let suffix = narrow(input.3, 3)
+      let bare = @edit_distance.edit_distance(a, b)
+      let wrapped = @edit_distance.edit_distance(
+        prefix + a + suffix,
+        prefix + b + suffix,
+      )
+      bare == wrapped
+    },
+    count=2000,
+  )
+}
+
+///|
+test "quickcheck: a single edit costs exactly one" {
+  @quickcheck.check(
+    (input : (Array[Int], Int)) => {
+      let a = narrow(input.0, 3)
+      if a.is_empty() {
+        return true
+      }
+      let at = wrap_index(input.1, a.length())
+      // deleting one element
+      let deleted = a.copy()
+      deleted.remove(at) |> ignore
+      guard @edit_distance.edit_distance(a, deleted) == 1 else { return false }
+      // inserting one element
+      let inserted = a.copy()
+      inserted.insert(at, 99)
+      guard @edit_distance.edit_distance(a, inserted) == 1 else { return false }
+      // substituting one element
+      let substituted = a.copy()
+      substituted[at] = a[at] + 1
+      @edit_distance.edit_distance(a, substituted) == 1
+    },
+    count=2000,
+  )
+}
+
+///|
+test "quickcheck: the empty input is at distance length" {
+  @quickcheck.check(
+    (values : Array[Int]) => {
+      let a = narrow(values, 3)
+      let empty : Array[Int] = []
+      @edit_distance.edit_distance(a, empty) == a.length() &&
+      @edit_distance.edit_distance(empty, a) == a.length() &&
+      @edit_distance.edit_distance(empty, empty) == 0
+    },
+    count=1000,
+  )
+}
+
+// =====================================================================
+// The string entry points.
+// =====================================================================
+
+///|
+test "quickcheck: the string API is the array API over code units" {
+  // `edit_distance_str` is documented as UTF-16 code-unit distance,
+  // which is exactly what the array API computes over `code_units()`.
+  @quickcheck.check(
+    (input : (String, String, Int)) => {
+      let a = input.0
+      let b = input.1
+      let ua = a.code_units().map(u => u.to_int())
+      let ub = b.code_units().map(u => u.to_int())
+      let expected = oracle(ua, ub)
+      guard @edit_distance.edit_distance_str(a, b) == expected &&
+        @edit_distance.edit_distance_str(b, a) == expected &&
+        @edit_distance.edit_distance(ua, ub) == expected else {
+        return false
+      }
+      let bound = wrap_index(input.2, 8) - 1
+      let want = if bound >= 0 && expected <= bound {
+        Some(expected)
+      } else {
+        None
+      }
+      @edit_distance.edit_distance_str_within(a, b, max_distance=bound) == want
+    },
+    count=2000,
+  )
+}
+
+///|
+test "the documented astral-character behaviour" {
+  // Code-unit distance means a non-BMP character counts as two units,
+  // and two that share a high surrogate are one unit apart.
+  inspect(@edit_distance.edit_distance_str("🍎", "🍏"), content="1")
+  inspect(@edit_distance.edit_distance_str("🙂", "🍎"), content="2")
+  // ...while scalar-level distance counts either as a single character
+  inspect(
+    @edit_distance.edit_distance("🍎".to_array(), "🍏".to_array()),
+    content="1",
+  )
+  inspect(
+    @edit_distance.edit_distance("🙂".to_array(), "🍎".to_array()),
+    content="1",
+  )
+}
diff --git a/internal/os_string/moon.pkg b/internal/os_string/moon.pkg
new file mode 100644
index 0000000000..ca358d71af
--- /dev/null
+++ b/internal/os_string/moon.pkg
@@ -0,0 +1,4 @@
+import {
+  "moonbitlang/core/builtin",
+  "moonbitlang/core/encoding/utf8",
+}
diff --git a/internal/os_string/os_string.mbt b/internal/os_string/os_string.mbt
new file mode 100644
index 0000000000..303002ab6a
--- /dev/null
+++ b/internal/os_string/os_string.mbt
@@ -0,0 +1,71 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+/// A string type using native encoding of current operating system.
+///
+/// On Windows, we use native `W` series unicode API,
+/// which return string in UTF-16 directly,
+/// so no need for extra encoding phase here.
+///
+/// Note: technically path etc. may be invalid UTF-16 on Windows,
+///   but this should be extremely rare in practice.
+///   If we need to fix this case, a validation or encoding phase can be added.
+#cfg(any(not(target="native"), platform="windows"))
+struct OsString(String)
+
+///|
+/// A string type using native encoding of current operating system.
+///
+/// On non-Windows platforms, path etc. are essentially binary data,
+/// and we assume they contain UTF-8 encoded text here.
+#cfg(all(target="native", not(platform="windows")))
+struct OsString(Bytes)
+
+///|
+#cfg(any(not(target="native"), platform="windows"))
+pub impl Show for OsString with fn to_string(self) {
+  self.0
+}
+
+///|
+#cfg(all(target="native", not(platform="windows")))
+pub impl Show for OsString with fn to_string(self) {
+  @utf8.decode_lossy(self.0)
+}
+
+///|
+#deprecated
+pub extend OsString with @builtin.Show::{output}
+
+///|
+pub extend OsString with @builtin.Show::{to_string}
+
+///|
+/// Create a `OsString` from a MoonBit `StringView`
+#cfg(any(not(target="native"), platform="windows"))
+pub fn OsString::from_string(str : StringView) -> OsString {
+  OsString(str.to_owned())
+}
+
+///|
+/// Create a `OsString` from a MoonBit `StringView`
+#cfg(all(target="native", not(platform="windows")))
+pub fn OsString::from_string(str : StringView) -> OsString {
+  OsString(@utf8.encode(str))
+}
+
+///|
+#cfg(any(not(target="native"), platform="windows"))
+let _unused_import : Unit = ignore(@utf8.encode(""))
diff --git a/internal/os_string/pkg.generated.mbti b/internal/os_string/pkg.generated.mbti
new file mode 100644
index 0000000000..519fbcb178
--- /dev/null
+++ b/internal/os_string/pkg.generated.mbti
@@ -0,0 +1,18 @@
+// Generated using `moon info`, DON'T EDIT IT
+package "moonbitlang/core/internal/os_string"
+
+// Values
+
+// Errors
+
+// Types and methods
+type OsString
+pub fn OsString::from_string(StringView) -> Self
+#deprecated
+pub fn OsString::output(Self, &Logger) -> Unit
+pub fn OsString::to_string(Self) -> String
+pub impl Show for OsString
+
+// Type aliases
+
+// Traits
diff --git a/internal/regex_engine/automata/delta.mbt b/internal/regex_engine/automata/delta.mbt
index a347b51896..f4cbb41c83 100644
--- a/internal/regex_engine/automata/delta.mbt
+++ b/internal/regex_engine/automata/delta.mbt
@@ -44,8 +44,6 @@
 /// - `delta_expr`: Computes derivative of expression, handling all expression types
 /// - `delta_threads`: Applies derivative to a collection of threads
 /// - `find_slot`: Allocates a slot for the new state to track positions
-#warnings("-3")
-priv struct Delta {}
 
 ///|
 #valtype
@@ -218,7 +216,7 @@ pub fn delta(
   let prev_cat = state.cat
   let desc = delta_threads(
     state.desc,
-    { prev_cat, next_cat, c },
+    { prev_cat, next_cat, c, },
     MarkSlotMap::empty(),
   )
   let desc = desc.remove_duplicates(e_eps)
diff --git a/internal/regex_engine/automata/expr.mbt b/internal/regex_engine/automata/expr.mbt
index 4123024786..654a50c5af 100644
--- a/internal/regex_engine/automata/expr.mbt
+++ b/internal/regex_engine/automata/expr.mbt
@@ -109,11 +109,11 @@ pub fn e_copy(ctx~ : Context, e : Expr) -> Expr {
 }
 
 ///|
-let e_empty : Expr = { id: EXPR_ID_EMPTY, def: Alt([]) }
+let e_empty : Expr = { id: EXPR_ID_EMPTY, def: Alt([]), }
 
 ///|
 /// Canonical epsilon expression.
-pub let e_eps : Expr = { id: EXPR_ID_EPS, def: Eps }
+pub let e_eps : Expr = { id: EXPR_ID_EPS, def: Eps, }
 
 ///|
 /// Return whether the expression is epsilon.
@@ -129,7 +129,7 @@ pub fn e_cset(ctx~ : Context, c : @shared_types.RecharSet) -> Expr {
   if c.is_empty() {
     e_empty
   } else {
-    { id: ctx.new_expr_id(), def: Chr(c) }
+    { id: ctx.new_expr_id(), def: Chr(c), }
   }
 }
 
@@ -141,25 +141,25 @@ pub fn e_rep(
   pref : @shared_types.Preference,
   x : Expr,
 ) -> Expr {
-  { id: ctx.new_expr_id(), def: Rep(mode, pref, x) }
+  { id: ctx.new_expr_id(), def: Rep(mode, pref, x), }
 }
 
 ///|
 /// Create a capture-mark expression node.
 pub fn e_mark(ctx~ : Context, m : Mark) -> Expr {
-  { id: ctx.new_expr_id(), def: Mark(m) }
+  { id: ctx.new_expr_id(), def: Mark(m), }
 }
 
 ///|
 /// Create a zero-width "before-category" assertion node.
 pub fn e_before(ctx~ : Context, cat : @shared_types.Category) -> Expr {
-  { id: ctx.new_expr_id(), def: Before(cat) }
+  { id: ctx.new_expr_id(), def: Before(cat), }
 }
 
 ///|
 /// Create a zero-width "after-category" assertion node.
 pub fn e_after(ctx~ : Context, cat : @shared_types.Category) -> Expr {
-  { id: ctx.new_expr_id(), def: After(cat) }
+  { id: ctx.new_expr_id(), def: After(cat), }
 }
 
 ///|
@@ -171,7 +171,7 @@ pub fn e_after(ctx~ : Context, cat : @shared_types.Category) -> Expr {
 pub fn e_alt(ctx~ : Context, xs : Array[Expr]) -> Expr {
   match xs {
     [x] | ([] with x = e_empty) => x
-    xs => { id: ctx.new_expr_id(), def: Alt(xs) }
+    xs => { id: ctx.new_expr_id(), def: Alt(xs), }
   }
 }
 
@@ -195,6 +195,6 @@ pub fn e_seq(
     (_, Alt([])) => y
     (Eps, _) => y
     (_, Eps) if pref is First => x
-    _ => { id: ctx.new_expr_id(), def: Seq(pref, x, y) }
+    _ => { id: ctx.new_expr_id(), def: Seq(pref, x, y), }
   }
 }
diff --git a/internal/regex_engine/automata/slot.mbt b/internal/regex_engine/automata/slot.mbt
index be5a5c9f17..6871a2f079 100644
--- a/internal/regex_engine/automata/slot.mbt
+++ b/internal/regex_engine/automata/slot.mbt
@@ -49,7 +49,7 @@ pub fn Slot::from_index(index : Int) -> Slot {
 ///
 /// Note: This is the only slot value that is considered "unassigned".
 pub fn Slot::unassigned() -> Slot {
-  { index: 0 }
+  { index: 0, }
 }
 
 ///|
diff --git a/internal/regex_engine/automata/state.mbt b/internal/regex_engine/automata/state.mbt
index d017c4f8a4..c7809485fa 100644
--- a/internal/regex_engine/automata/state.mbt
+++ b/internal/regex_engine/automata/state.mbt
@@ -87,7 +87,7 @@ fn State::new(
   cat : @shared_types.Category,
   desc : ThreadSet,
 ) -> State {
-  { slot, cat, desc, hash: Hash::hash((slot, cat, desc)) }
+  { slot, cat, desc, hash: Hash::hash((slot, cat, desc)), }
 }
 
 ///|
diff --git a/internal/regex_engine/automata/thread_set.mbt b/internal/regex_engine/automata/thread_set.mbt
index ea9c9e1b20..5d108c9db6 100644
--- a/internal/regex_engine/automata/thread_set.mbt
+++ b/internal/regex_engine/automata/thread_set.mbt
@@ -161,8 +161,7 @@ fn ThreadSet::flat_map(
 ///|
 fn ThreadSet::find_first_match(self : ThreadSet) -> MarkSlotMap? {
   match self {
-    Empty => None
-    Node(i={ no_match: true }, ..) => None
+    Empty | Node(i={ no_match: true, }, ..) => None
     Node(l=Empty, t=End(marks), ..) => Some(marks)
     Node(l~, t~, r~, ..) =>
       match l.find_first_match() {
@@ -180,7 +179,7 @@ fn ThreadSet::find_first_match(self : ThreadSet) -> MarkSlotMap? {
 fn ThreadSet::remove_matches(self : ThreadSet) -> ThreadSet {
   match self {
     Empty => Empty
-    Node(i={ no_match: true }, ..) => self
+    Node(i={ no_match: true, }, ..) => self
     Node(l~, t~, r~, p~, ..) =>
       match t {
         End(_) => l.remove_matches() + r.remove_matches()
@@ -194,12 +193,12 @@ fn ThreadSet::remove_matches(self : ThreadSet) -> ThreadSet {
 fn ThreadSet::split_at_first_match(self : ThreadSet) -> (ThreadSet, ThreadSet) {
   match self {
     Empty => (Empty, Empty)
-    Node(i={ no_match: true }, ..) => (self, Empty)
-    Node(l=Empty | Node(i={ no_match: true }, ..) as l, t=End(_), r~, ..) =>
+    Node(i={ no_match: true, }, ..) => (self, Empty)
+    Node(l=Empty | Node(i={ no_match: true, }, ..) as l, t=End(_), r~, ..) =>
       (l, r)
     Node(
       l=Empty
-      | Node(i={ no_match: true }, ..) as l,
+      | Node(i={ no_match: true, }, ..) as l,
       t=Exp(_)
       | Seq(_) as t,
       r~,
@@ -209,7 +208,7 @@ fn ThreadSet::split_at_first_match(self : ThreadSet) -> (ThreadSet, ThreadSet) {
       let (r1, r2) = r.split_at_first_match()
       (ThreadSet::make_node(l, t, r1, p~), r2)
     }
-    Node(l=Node(i={ no_match: false }, ..) as l, t~, r~, p~, ..) => {
+    Node(l=Node(i={ no_match: false, }, ..) as l, t~, r~, p~, ..) => {
       let (l1, l2) = l.split_at_first_match()
       (l1, ThreadSet::make_node(l2, t, r, p~))
     }
@@ -217,15 +216,47 @@ fn ThreadSet::split_at_first_match(self : ThreadSet) -> (ThreadSet, ThreadSet) {
 }
 
 ///|
+/// Drops threads that cannot contribute anything an earlier thread does not
+/// already cover: two threads with the same future differ only in priority,
+/// so the later one can go.
+///
+/// Threads in `self` all continue with `next` once they finish, which is
+/// what makes a thread that has *already* finished — one sitting at `Eps` —
+/// equivalent to a thread sitting at `next` itself. Both are keyed by
+/// `next.id` so that the pair collapses to one; without that, a sequence
+/// whose first part can both finish and continue doubles its thread set on
+/// every character.
+///
+/// The equivalence is used as a dedup key only. Rewriting the finished
+/// thread to `Exp(marks, next)` would *look* like the same statement, but
+/// the caller wraps the result in `ts_seq(.., next)`, which appends `next`
+/// again — so the continuation would be matched twice over. Keeping the
+/// thread at `Eps` also leaves `ts_seq` able to collapse a sequence whose
+/// first part has come down to a single finished thread.
+///
+/// One `seen` set is shared across the whole nested thread tree. Equivalent
+/// futures can occur under different `Seq` wrappers when a repeated body
+/// consumes a variable number of characters; limiting deduplication to each
+/// wrapper separately retains one thread per partition of the input and
+/// makes the set grow exponentially.
 fn ThreadSet::remove_duplicates(self : ThreadSet, next : Expr) -> ThreadSet {
   let seen = @hashset.HashSet([])
+  self.remove_duplicates_with_seen(next, seen)
+}
+
+///|
+fn ThreadSet::remove_duplicates_with_seen(
+  self : ThreadSet,
+  next : Expr,
+  seen : @hashset.HashSet[ExprId],
+) -> ThreadSet {
   for thread in self; result = ts_empty {
     match thread {
       End(_) => break result + ts_one(thread)
-      Exp(marks, { def: Eps, .. }) =>
+      Exp(_, { def: Eps, .. }) =>
         if !seen.contains(next.id) {
           seen.add(next.id)
-          continue result + ts_exp(marks, next)
+          continue result + ts_one(thread)
         } else {
           continue result
         }
@@ -237,7 +268,7 @@ fn ThreadSet::remove_duplicates(self : ThreadSet, next : Expr) -> ThreadSet {
           continue result
         }
       Seq(pref, first, next) => {
-        let first_dedup = first.remove_duplicates(next)
+        let first_dedup = first.remove_duplicates_with_seen(next, seen)
         continue result + ts_seq(pref, first_dedup, next)
       }
     }
@@ -289,7 +320,7 @@ fn ThreadSet::iter_marks(self : ThreadSet) -> Iter[MarkSlotMap] {
   .iter()
   .flat_map(thread => {
     match thread {
-      End(marks) | Exp(marks, _) => Iter::singleton(marks)
+      End(marks) | Exp(marks, _) => [|marks|]
       Seq(_pref, first, _next) => first.iter_marks()
     }
   })
diff --git a/internal/regex_engine/shared_types/profile.mbt b/internal/regex_engine/shared_types/profile.mbt
index 6b5c5f098f..6413782443 100644
--- a/internal/regex_engine/shared_types/profile.mbt
+++ b/internal/regex_engine/shared_types/profile.mbt
@@ -34,5 +34,5 @@ pub fn Profile::Profile(
 ) -> Profile {
   guard valid.first_interval() is Some((lb, _)) else { panic() }
   guard valid.last_interval() is Some((_, ub)) else { panic() }
-  { lb, ub, valid, word, word_symbolize_splits, category }
+  { lb, ub, valid, word, word_symbolize_splits, category, }
 }
diff --git a/internal/strconv/double_exact_test.mbt b/internal/strconv/double_exact_test.mbt
new file mode 100644
index 0000000000..b47995d61c
--- /dev/null
+++ b/internal/strconv/double_exact_test.mbt
@@ -0,0 +1,182 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// `parse_double` against exact arithmetic.
+//
+// The properties in `double_quickcheck_test.mbt` pin down internal
+// consistency: every spelling of a decimal agrees with every other, the
+// result is monotone, and it round-trips. All of that would still hold if
+// the parser were uniformly off by an ulp. What settles the remaining
+// question is the definition itself — the nearest double to the exact
+// rational `mantissa x 10^exponent`, ties to even — so the oracle below
+// computes exactly that in `BigInt` and compares bit patterns.
+//
+// The exact value is held as `num / den` with both sides integral. Scaling
+// it by a power of two puts the quotient in `[2^52, 2^53)`, and the
+// remainder decides the rounding. Nothing here shares code, tables, or
+// approximations with the implementation.
+
+///|
+
+///|
+let big_one : @bigint.BigInt = @bigint.BigInt::from_int(1)
+
+///|
+let big_two : @bigint.BigInt = @bigint.BigInt::from_int(2)
+
+///|
+let big_ten : @bigint.BigInt = @bigint.BigInt::from_int(10)
+
+///|
+/// `round(a / b)` to nearest, ties to even. `b` must be positive.
+fn div_round_even(a : @bigint.BigInt, b : @bigint.BigInt) -> @bigint.BigInt {
+  let q = a / b
+  let r = a % b
+  let twice = r * big_two
+  match twice.compare(b) {
+    c if c < 0 => q
+    c if c > 0 => q + big_one
+    // Exactly halfway: take the even neighbour.
+    _ => if (q % big_two).is_zero() { q } else { q + big_one }
+  }
+}
+
+///|
+/// Builds the double `magnitude * 2^(exponent)` from an integral
+/// significand, by assembling the IEEE-754 bit pattern directly rather than
+/// going through arithmetic that could round a second time.
+///
+/// `significand` is in `[2^52, 2^53)` for a normal number, or below `2^52`
+/// with `unbiased == -1074` for a subnormal.
+fn assemble(neg : Bool, significand : UInt64, unbiased : Int) -> Double {
+  let sign = if neg { 0x8000000000000000UL } else { 0UL }
+  let bits = if significand < 0x10000000000000UL {
+    // Subnormal (or zero): the exponent field is 0 and the significand is
+    // the fraction outright.
+    sign | significand
+  } else {
+    let biased = (unbiased + 1023).to_uint64()
+    sign | (biased << 52) | (significand - 0x10000000000000UL)
+  }
+  bits.reinterpret_as_double()
+}
+
+///|
+/// The correctly rounded double nearest to `sign * digits * 10^exponent`,
+/// or `None` when the magnitude overflows the format — matching the
+/// implementation's choice to report overflow as an error rather than as an
+/// infinity. Underflow is a signed zero, not an error.
+fn oracle_double(neg : Bool, digits : String, exponent : Int) -> Double? {
+  let mantissa = @bigint.BigInt::from_string(digits)
+  if mantissa.is_zero() {
+    return Some(assemble(neg, 0UL, 0))
+  }
+  // The exact value as num / den.
+  let mut num = mantissa
+  let mut den = big_one
+  if exponent >= 0 {
+    num = num * big_ten.pow(@bigint.BigInt::from_int(exponent))
+  } else {
+    den = big_ten.pow(@bigint.BigInt::from_int(-exponent))
+  }
+  // `p` is the position of the leading bit: the largest p with
+  // num/den >= 2^p. Start from the bit lengths and correct by one.
+  let mut p = num.bit_length() - den.bit_length()
+  if scaled_compare(num, den, p) < 0 {
+    p = p - 1
+  }
+  // Round the significand at 52 bits below the leading bit, except in the
+  // subnormal range where the exponent is pinned at -1074.
+  let shift = if p < -1022 { 1074 } else { 52 - p }
+  let (a, b) = if shift >= 0 {
+    (num.shl(shift), den)
+  } else {
+    (num, den.shl(-shift))
+  }
+  let mut q = div_round_even(a, b)
+  let mut unbiased = if p < -1022 { -1074 } else { p }
+  // Rounding up can carry into the next binade.
+  if q.compare(big_one.shl(53)) >= 0 {
+    q = q.shr(1)
+    unbiased = unbiased + 1
+  }
+  if unbiased > 1023 {
+    return None
+  }
+  Some(assemble(neg, q.to_uint64_exact(), unbiased))
+}
+
+///|
+/// Compares `num / den` with `2^p` without dividing.
+fn scaled_compare(num : @bigint.BigInt, den : @bigint.BigInt, p : Int) -> Int {
+  if p >= 0 {
+    num.compare(den.shl(p))
+  } else {
+    num.shl(-p).compare(den)
+  }
+}
+
+///|
+/// `BigInt` has no `to_uint64`; the significand always fits in 53 bits, so
+/// go through the low 64 bits of the two's complement representation.
+fn @bigint.BigInt::to_uint64_exact(self : @bigint.BigInt) -> UInt64 {
+  self.to_int64().reinterpret_as_uint64()
+}
+
+///|
+test "quickcheck: parse_double is the correctly rounded value" {
+  @quickcheck.check(
+    (input : (Bool, Array[Int], Int, Int)) => {
+      let (neg, raw, len_code, exp_code) = input
+      let exponent = wrap_index(exp_code, 760) - 380
+      let lit = Literal::make(neg, raw, len_code, exponent)
+      match (parsed(lit.plain()), oracle_double(neg, lit.digits, exponent)) {
+        (Some(a), Some(b)) => same_double(a, b)
+        (None, None) => true
+        _ => false
+      }
+    },
+    count=20000,
+  )
+}
+
+///|
+/// The same comparison aimed at the hard cases: mantissas just either side
+/// of a rounding boundary, where a parser that is even slightly imprecise
+/// picks the wrong neighbour.
+test "quickcheck: parse_double rounds correctly at the boundaries" {
+  @quickcheck.check(
+    (input : (UInt64, Int, Int)) => {
+      let (raw, exp_code, nudge) = input
+      // Start from an actual double, take its exact decimal expansion, and
+      // step a few units in the last decimal place. Halfway cases between
+      // two doubles land in this neighbourhood.
+      let significand = raw % 9007199254740992UL + 4503599627370496UL
+      let exponent = wrap_index(exp_code, 60) - 30
+      let stepped = significand.reinterpret_as_int64() +
+        (wrap_index(nudge, 11) - 5).to_int64()
+      let digits = stepped.to_string()
+      match
+        (
+          parsed("\{digits}e\{exponent}"),
+          oracle_double(false, digits, exponent),
+        ) {
+        (Some(a), Some(b)) => same_double(a, b)
+        (None, None) => true
+        _ => false
+      }
+    },
+    count=20000,
+  )
+}
diff --git a/internal/strconv/double_quickcheck_test.mbt b/internal/strconv/double_quickcheck_test.mbt
new file mode 100644
index 0000000000..b301181086
--- /dev/null
+++ b/internal/strconv/double_quickcheck_test.mbt
@@ -0,0 +1,222 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Specification tests for `parse_double`.
+//
+// `parse_double` has three routes to an answer — Clinger's fast path, its
+// "disguised" extension, and the decimal slow path — chosen from the shape
+// of the input rather than from its value. That makes the interesting
+// specification a *representation-independence* one: two spellings of the
+// same exact decimal must produce the same double, even when they take
+// different routes. Padding a mantissa with zeros is enough to force the
+// slow path, so the properties below compare spellings that are equal by
+// construction and let the implementation pick different routes for them.
+
+///|
+/// Compares doubles by bit pattern, so `0.0` and `-0.0` are distinguished
+/// and two NaNs with the same payload compare equal.
+fn same_double(a : Double, b : Double) -> Bool {
+  a.reinterpret_as_uint64() == b.reinterpret_as_uint64()
+}
+
+///|
+fn parsed(text : String) -> Double? {
+  Some(@strconv.parse_double(text)) catch {
+    _ => None
+  }
+}
+
+///|
+fn repeat_char(c : Char, n : Int) -> String {
+  String::from_array(Array::make(n, c))
+}
+
+///|
+/// A decimal literal: `digits` scaled by `10^exponent`, optionally negative.
+/// Every spelling produced from one of these denotes the same exact number.
+struct Literal {
+  neg : Bool
+  digits : String
+  exponent : Int
+}
+
+///|
+/// `length` is taken up to 45 digits so that mantissas routinely exceed the
+/// 19 the fast path can hold — that is what makes the two routes diverge if
+/// they are going to.
+fn Literal::make(
+  neg : Bool,
+  raw : ArrayView[Int],
+  length_code : Int,
+  exponent : Int,
+) -> Literal {
+  let length = wrap_index(length_code, 45) + 1
+  let digits = String::from_array(
+    Array::makei(length, i => {
+      let seed = if raw.length() == 0 { i } else { raw[i % raw.length()] + i }
+      (wrap_index(seed, 10) + '0').unsafe_to_char()
+    }),
+  )
+  { neg, digits, exponent, }
+}
+
+///|
+fn Literal::sign(self : Literal) -> String {
+  if self.neg {
+    "-"
+  } else {
+    ""
+  }
+}
+
+///|
+/// `digits e exponent` — the plain spelling.
+fn Literal::plain(self : Literal) -> String {
+  "\{self.sign()}\{self.digits}e\{self.exponent}"
+}
+
+///|
+/// The decimal point moved `p` digits in from the left, with the exponent
+/// compensating. `p == len` is the plain spelling with a trailing point.
+fn Literal::pointed(self : Literal, p : Int) -> String {
+  let len = self.digits.length()
+  let p = wrap_index(p, len + 1)
+  let head = self.digits[0:p].to_owned()
+  let tail = self.digits[p:len].to_owned()
+  let head = if head == "" { "0" } else { head }
+  "\{self.sign()}\{head}.\{tail}e\{self.exponent + len - p}"
+}
+
+///|
+/// `k` zeros appended to the mantissa, with the exponent compensating. This
+/// is the spelling that forces the slow path once the digit count passes
+/// the fast path's limit.
+fn Literal::padded(self : Literal, k : Int) -> String {
+  let k = wrap_index(k, 30)
+  "\{self.sign()}\{self.digits}\{repeat_char('0', k)}e\{self.exponent - k}"
+}
+
+///|
+/// `k` insignificant zeros in front of the mantissa.
+fn Literal::leading(self : Literal, k : Int) -> String {
+  let k = wrap_index(k, 30)
+  "\{self.sign()}\{repeat_char('0', k)}\{self.digits}e\{self.exponent}"
+}
+
+///|
+/// `0.000…digits`, with the exponent compensating — the same value written
+/// as a pure fraction.
+fn Literal::fractional(self : Literal, k : Int) -> String {
+  let k = wrap_index(k, 30)
+  let len = self.digits.length()
+  "\{self.sign()}0.\{repeat_char('0', k)}\{self.digits}e\{self.exponent + len + k}"
+}
+
+///|
+/// Every spelling of the same exact decimal must parse to the same double.
+test "quickcheck: parse_double is independent of the spelling" {
+  @quickcheck.check(
+    (input : (Bool, Array[Int], Int, Int, Int, Int)) => {
+      let (neg, raw, len_code, exp_code, p, k) = input
+      // Exponents span the whole interesting range: subnormal, normal, and
+      // both saturating ends.
+      let exponent = wrap_index(exp_code, 700) - 350
+      let lit = Literal::make(neg, raw, len_code, exponent)
+      let reference = parsed(lit.plain())
+      [
+        lit.pointed(p),
+        lit.padded(k),
+        lit.leading(k),
+        lit.fractional(k),
+        lit.padded(k + 17),
+        lit.pointed(p + 1),
+      ].all(spelling => {
+        match (parsed(spelling), reference) {
+          (Some(a), Some(b)) => same_double(a, b)
+          (None, None) => true
+          _ => false
+        }
+      })
+    },
+    count=20000,
+  )
+}
+
+///|
+/// Clinger's theorem: when the mantissa is exactly representable and the
+/// power of ten is too, one rounding gives the correctly rounded result. So
+/// on that subset the parser must agree with plain double arithmetic.
+test "quickcheck: parse_double is exact on the Clinger subset" {
+  @quickcheck.check(
+    (input : (Int64, Int)) => {
+      let (raw, exp_code) = input
+      // |mantissa| <= 2^53 and |exponent| <= 22 keep both operands exact.
+      let mantissa = raw % 9007199254740993L
+      let exponent = wrap_index(exp_code, 45) - 22
+      let text = "\{mantissa}e\{exponent}"
+      // Built by repeated multiplication rather than `pow`: every power of
+      // ten up to 10^22 is exactly representable, so this is exact.
+      let mut power = 1.0
+      for _ in 0..= 0 {
+        mantissa.to_double() * power
+      } else {
+        mantissa.to_double() / power
+      }
+      parsed(text) is Some(v) && same_double(v, expected)
+    },
+    count=20000,
+  )
+}
+
+///|
+/// Parsing is monotone: at a fixed exponent, a larger mantissa cannot parse
+/// to a smaller double.
+test "quickcheck: parse_double is monotone in the mantissa" {
+  @quickcheck.check(
+    (input : (UInt64, UInt64, Int)) => {
+      let (a, b, exp_code) = input
+      let lo = if a <= b { a } else { b }
+      let hi = if a <= b { b } else { a }
+      let exponent = wrap_index(exp_code, 700) - 350
+      match (parsed("\{lo}e\{exponent}"), parsed("\{hi}e\{exponent}")) {
+        (Some(x), Some(y)) => x <= y
+        // Overflow is reported as an error rather than an infinity, so the
+        // larger mantissa may drop out where the smaller one does not.
+        // The reverse would mean the order was inverted.
+        (Some(_), None) => true
+        (None, None) => true
+        (None, Some(_)) => false
+      }
+    },
+    count=20000,
+  )
+}
+
+///|
+/// `Double::to_string` produces a decimal that reads back as the same
+/// double — the round-trip the shortest-representation printer promises.
+test "quickcheck: to_string round-trips through parse_double" {
+  @quickcheck.check(
+    (d : Double) => {
+      if d.is_nan() || d.is_inf() {
+        return true
+      }
+      parsed(d.to_string()) is Some(back) && same_double(back, d)
+    },
+    count=20000,
+  )
+}
diff --git a/internal/strconv/eisel_lemire_quickcheck_test.mbt b/internal/strconv/eisel_lemire_quickcheck_test.mbt
new file mode 100644
index 0000000000..a62d521faf
--- /dev/null
+++ b/internal/strconv/eisel_lemire_quickcheck_test.mbt
@@ -0,0 +1,79 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Property tests for the Eisel-Lemire fast path.
+//
+// `try_eisel_lemire64` is free to reject any input, but every value it
+// accepts must be the correctly rounded double — bit for bit what the
+// arbitrary-precision Decimal conversion produces. The oracle reaches that
+// exact path through the public parser: appending 21 mantissa zeros (with
+// the exponent compensating) pushes the digit count past the 19 the fast
+// paths tolerate, so `parse_double` is forced onto the Decimal slow path.
+
+///|
+fn eisel_lemire_is_exact(
+  mantissa : UInt64,
+  exponent : Int,
+  negative : Bool,
+) -> Bool {
+  let fast = @strconv.try_eisel_lemire64(
+    mantissa,
+    exponent.to_int64(),
+    negative,
+  )
+  if fast.is_nan() {
+    // Rejection is always allowed: those inputs stay on the exact fallback.
+    return true
+  }
+  let sign = if negative { "-" } else { "" }
+  let padded = "\{sign}\{mantissa}\{repeat_char('0', 21)}e\{exponent - 21}"
+  parsed(padded) is Some(exact) && same_double(fast, exact)
+}
+
+///|
+/// Whatever the fast path accepts must match the exact conversion.
+test "quickcheck: accepted Eisel-Lemire values are correctly rounded" {
+  @quickcheck.check(
+    (input : (UInt64, Int, Int, Bool)) => {
+      let (raw, shift_code, exp_code, negative) = input
+      // The shift spreads mantissas across every magnitude, including the
+      // small values a uniform UInt64 almost never produces.
+      let mantissa = raw >> wrap_index(shift_code, 64)
+      // Exponents overshoot the table range (-348..=347) on both sides so
+      // the bounds check is exercised alongside the conversion.
+      let exponent = wrap_index(exp_code, 723) - 361
+      eisel_lemire_is_exact(mantissa, exponent, negative)
+    },
+    count=20000,
+  )
+}
+
+///|
+/// Mantissas next to powers of two sit on binade boundaries, where rounding
+/// carries into the next exponent and halfway cases cluster; the ambiguity
+/// rejection has to fire exactly there.
+test "quickcheck: Eisel-Lemire stays exact near binade boundaries" {
+  @quickcheck.check(
+    (input : (Int, Int, Int, Bool)) => {
+      let (bit_code, delta_code, exp_code, negative) = input
+      let base = 1UL << wrap_index(bit_code, 64)
+      let delta = (wrap_index(delta_code, 9) - 4).to_int64()
+      // Wrapping addition is fine: any UInt64 is a valid mantissa.
+      let mantissa = base + delta.reinterpret_as_uint64()
+      let exponent = wrap_index(exp_code, 723) - 361
+      eisel_lemire_is_exact(mantissa, exponent, negative)
+    },
+    count=20000,
+  )
+}
diff --git a/internal/strconv/moon.pkg b/internal/strconv/moon.pkg
index b7b17e4972..845796e3c7 100644
--- a/internal/strconv/moon.pkg
+++ b/internal/strconv/moon.pkg
@@ -10,4 +10,6 @@ import {
 
 import {
   "moonbitlang/core/bench",
+  "moonbitlang/core/quickcheck",
+  "moonbitlang/core/bigint",
 } for "test"
diff --git a/internal/strconv/parse_double_bench_test.mbt b/internal/strconv/parse_double_bench_test.mbt
index d545377df8..fde64d9e5c 100644
--- a/internal/strconv/parse_double_bench_test.mbt
+++ b/internal/strconv/parse_double_bench_test.mbt
@@ -27,6 +27,12 @@ let parse_double_underscore_bench_inputs : FixedArray[String] = [
   "123_456_789_012_345e-2", "876_543_210_987_654e-3", "1_234_567_890_123e+2", "7_654_321_098_765e-1",
 ]
 
+///|
+let parse_double_long_mantissa_bench_inputs : FixedArray[String] = [
+  "-65.613616999999977", "43.420273000000009", "-65.619720000000029", "43.418052999999986",
+  "-65.625000000000000", "43.412101000000000", "-65.630279999999994", "43.406101000000010",
+]
+
 ///|
 fn parse_double_bench_sum(inputs : FixedArray[String]) -> Double {
   let mut sum = 0.0
@@ -51,3 +57,10 @@ test "bench parse_double underscores n=4096" (it : @bench.T) {
     it.keep(parse_double_bench_sum(parse_double_underscore_bench_inputs))
   })
 }
+
+///|
+test "bench parse_double long mantissa n=4096" (it : @bench.T) {
+  it.bench(fn() {
+    it.keep(parse_double_bench_sum(parse_double_long_mantissa_bench_inputs))
+  })
+}
diff --git a/internal/strconv/quickcheck_test.mbt b/internal/strconv/quickcheck_test.mbt
new file mode 100644
index 0000000000..c37c995c34
--- /dev/null
+++ b/internal/strconv/quickcheck_test.mbt
@@ -0,0 +1,392 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Specification tests for the integer parsers.
+//
+// The oracle below is written from the documented grammar, not from the
+// implementation: sign, optional base prefix, digits separated by single
+// underscores, every digit below the base, and a magnitude that fits the
+// target type. Accumulation is done in `UInt64` with an exact
+// `acc > (limit - d) / base` overflow test, so the oracle's notion of "out
+// of range" does not depend on the same threshold arithmetic the
+// implementation uses.
+//
+// The inputs are short strings over an alphabet chosen to sit on the
+// grammar's boundaries — digits either side of every base, letters either
+// side of 'f' and 'z', both cases, the prefix letters, underscore, both
+// signs, and two characters that are never valid.
+
+///|
+let alphabet : Array[Char] = [
+  '0', '1', '7', '8', '9', 'a', 'f', 'g', 'z', 'A', 'F', 'Z', '_', '+', '-', 'x',
+  'X', 'o', 'O', 'b', 'B', ' ', '.',
+]
+
+///|
+/// Bases worth trying: 0 (infer), every base with a prefix, the two
+/// endpoints of the valid range, a couple of ordinary ones, and three
+/// invalid ones.
+let bases : Array[Int] = [0, 2, 8, 10, 16, 36, 3, 7, 35, 1, 37, -1]
+
+///|
+fn wrap_index(value : Int, modulus : Int) -> Int {
+  let r = value % modulus
+  if r < 0 {
+    r + modulus
+  } else {
+    r
+  }
+}
+
+///|
+fn make_text(codes : ArrayView[Int]) -> String {
+  String::from_array(
+    Array::makei(codes.length(), i => {
+      alphabet[wrap_index(codes[i], alphabet.length())]
+    }),
+  )
+}
+
+///|
+/// The digit value of `c` in base 36, or `None` if it is not a digit.
+fn digit_value(c : Char) -> Int? {
+  let n = c.to_int()
+  if n >= '0' && n <= '9' {
+    Some(n - '0')
+  } else if n >= 'a' && n <= 'z' {
+    Some(n - 'a' + 10)
+  } else if n >= 'A' && n <= 'Z' {
+    Some(n - 'A' + 10)
+  } else {
+    None
+  }
+}
+
+///|
+/// The base a prefix introduces, if `chars[i..]` starts with one.
+fn prefix_base(chars : ArrayView[Char], i : Int) -> Int? {
+  if i + 1 >= chars.length() || chars[i] != '0' {
+    return None
+  }
+  match chars[i + 1] {
+    'x' | 'X' => Some(16)
+    'o' | 'O' => Some(8)
+    'b' | 'B' => Some(2)
+    _ => None
+  }
+}
+
+///|
+/// Shared front end: validates the base, consumes a sign if `signed`, and
+/// consumes a base prefix when it agrees with `base`. Returns the sign, the
+/// effective base, the index of the first digit, and whether an underscore
+/// may lead the digits (only just after a prefix).
+fn scan_prefix(
+  chars : ArrayView[Char],
+  base : Int,
+  signed : Bool,
+) -> (Bool, Int, Int, Bool)? {
+  guard base == 0 || (base >= 2 && base <= 36) else { return None }
+  guard chars.length() > 0 else { return None }
+  let mut i = 0
+  let mut neg = false
+  if chars[0] is ('+' | '-') {
+    guard signed else { return None }
+    neg = chars[0] == '-'
+    i = 1
+  }
+  match prefix_base(chars, i) {
+    Some(p) if base == 0 || base == p => Some((neg, p, i + 2, true))
+    _ => Some((neg, if base == 0 { 10 } else { base }, i, false))
+  }
+}
+
+///|
+/// The digits of `chars[start..]` in `base`, or `None` if the digit/underscore
+/// grammar is violated. `lead_underscore` says whether a single underscore may
+/// come first (it may, exactly when a prefix was just consumed).
+fn scan_digits(
+  chars : ArrayView[Char],
+  start : Int,
+  base : Int,
+  lead_underscore : Bool,
+) -> Array[Int]? {
+  let digits = []
+  let mut underscore_ok = lead_underscore
+  let mut after_underscore = false
+  for i in start.. 0 else { return None }
+  Some(digits)
+}
+
+///|
+/// Folds `digits` in `base`, or `None` if the magnitude exceeds `limit`.
+/// Exact: `acc * base + d <= limit` iff `acc <= (limit - d) / base`.
+fn fold_digits(digits : ArrayView[Int], base : Int, limit : UInt64) -> UInt64? {
+  let b = base.to_uint64()
+  let mut acc = 0UL
+  for d in digits {
+    let dv = d.to_uint64()
+    guard acc <= (limit - dv) / b else { return None }
+    acc = acc * b + dv
+  }
+  Some(acc)
+}
+
+///|
+/// Reference `parse_int64`. `None` means "must be rejected".
+fn oracle_int64(text : String, base : Int) -> Int64? {
+  let chars = text.to_array()[:]
+  guard scan_prefix(chars, base, true) is Some((neg, eff, start, lead)) else {
+    return None
+  }
+  guard scan_digits(chars, start, eff, lead) is Some(digits) else {
+    return None
+  }
+  // Two's complement: the negative range reaches one further than the
+  // positive one.
+  let limit = if neg { 0x8000000000000000UL } else { 0x7fffffffffffffffUL }
+  guard fold_digits(digits, eff, limit) is Some(magnitude) else { return None }
+  let v = magnitude.reinterpret_as_int64()
+  Some(if neg { 0L - v } else { v })
+}
+
+///|
+/// Reference `parse_uint64`. Signs are not part of the grammar here.
+fn oracle_uint64(text : String, base : Int) -> UInt64? {
+  let chars = text.to_array()[:]
+  guard scan_prefix(chars, base, false) is Some((_, eff, start, lead)) else {
+    return None
+  }
+  guard scan_digits(chars, start, eff, lead) is Some(digits) else {
+    return None
+  }
+  fold_digits(digits, eff, 0xffffffffffffffffUL)
+}
+
+///|
+fn actual_int64(text : String, base : Int) -> Int64? {
+  Some(@strconv.parse_int64(text, base~)) catch {
+    _ => None
+  }
+}
+
+///|
+fn actual_int(text : String, base : Int) -> Int? {
+  Some(@strconv.parse_int(text, base~)) catch {
+    _ => None
+  }
+}
+
+///|
+fn actual_uint64(text : String, base : Int) -> UInt64? {
+  Some(@strconv.parse_uint64(text, base~)) catch {
+    _ => None
+  }
+}
+
+///|
+fn actual_uint(text : String, base : Int) -> UInt? {
+  Some(@strconv.parse_uint(text, base~)) catch {
+    _ => None
+  }
+}
+
+// =====================================================================
+// Well-formed numerals, biased at the type boundaries.
+//
+// Random text is good at the grammar but almost never long enough to
+// overflow: `Int64` needs 19 decimal digits. These properties render a
+// magnitude in the target base instead — with every combination of sign,
+// prefix, and interior underscores the grammar allows — and bias the
+// magnitude towards the values where a threshold is off by one.
+// =====================================================================
+
+///|
+/// Magnitudes where an overflow check can be wrong by one.
+let boundaries : Array[UInt64] = [
+  0, 1, 0x7ffffffe, 0x7fffffff, 0x80000000, 0x80000001, 0xfffffffe, 0xffffffff, 0x100000000,
+  0x7ffffffffffffffe, 0x7fffffffffffffff, 0x8000000000000000, 0x8000000000000001,
+  0xfffffffffffffffe, 0xffffffffffffffff,
+]
+
+///|
+/// `choice` either keeps the generated magnitude or swaps in a boundary,
+/// so a run covers both the bulk of the space and its edges.
+fn pick_magnitude(raw : UInt64, choice : Int) -> UInt64 {
+  if choice % 2 == 0 {
+    boundaries[wrap_index(choice / 2, boundaries.length())]
+  } else {
+    raw
+  }
+}
+
+///|
+/// Renders `magnitude` in `base`. `flags` selects the sign, whether a base
+/// prefix is emitted (only where one exists and agrees), the digit case,
+/// and which gaps between digits get an underscore.
+fn render(magnitude : UInt64, base : Int, flags : Int) -> String {
+  let digits = []
+  let b = base.to_uint64()
+  let mut n = magnitude
+  if n == 0 {
+    digits.push(0)
+  }
+  while n > 0 {
+    digits.push((n % b).to_int())
+    n = n / b
+  }
+  digits.rev_in_place()
+  let upper = flags / 2 % 2 == 0
+  let out = StringBuilder()
+  match flags % 4 {
+    1 => out.write_char('+')
+    2 => out.write_char('-')
+    _ => ()
+  }
+  if flags / 4 % 2 == 0 {
+    match base {
+      16 => out.write_string("0x")
+      8 => out.write_string("0o")
+      2 => out.write_string("0b")
+      _ => ()
+    }
+  }
+  for i, d in digits {
+    // An underscore may sit between any two digits; the bit pattern of
+    // `flags` decides which gaps take one.
+    if i > 0 && flags / 8 / (1 << (i % 20)) % 2 == 1 {
+      out.write_char('_')
+    }
+    let c = if d < 10 {
+      (d + '0').unsafe_to_char()
+    } else if upper {
+      (d - 10 + 'A').unsafe_to_char()
+    } else {
+      (d - 10 + 'a').unsafe_to_char()
+    }
+    out.write_char(c)
+  }
+  out.to_string()
+}
+
+///|
+test "quickcheck: signed numerals at the type boundaries" {
+  @quickcheck.check(
+    (input : (UInt64, Int, Int, Int)) => {
+      let (raw, choice, base_code, flags) = input
+      let base = bases[wrap_index(base_code, bases.length())]
+      let render_base = if base is (2..=36) { base } else { 10 }
+      let text = render(pick_magnitude(raw, choice), render_base, flags)
+      actual_int64(text, base) == oracle_int64(text, base) &&
+      actual_int(text, base) ==
+      (match oracle_int64(text, base) {
+        Some(v) if v >= -2147483648L && v <= 2147483647L => Some(v.to_int())
+        _ => None
+      })
+    },
+    count=20000,
+  )
+}
+
+///|
+test "quickcheck: unsigned numerals at the type boundaries" {
+  @quickcheck.check(
+    (input : (UInt64, Int, Int, Int)) => {
+      let (raw, choice, base_code, flags) = input
+      let base = bases[wrap_index(base_code, bases.length())]
+      let render_base = if base is (2..=36) { base } else { 10 }
+      let text = render(pick_magnitude(raw, choice), render_base, flags)
+      actual_uint64(text, base) == oracle_uint64(text, base) &&
+      actual_uint(text, base) ==
+      (match oracle_uint64(text, base) {
+        Some(v) if v <= 0xffffffffUL => Some(v.to_uint())
+        _ => None
+      })
+    },
+    count=20000,
+  )
+}
+
+///|
+test "quickcheck: parse_int64 agrees with the grammar" {
+  @quickcheck.check(
+    (input : (Int, Array[Int])) => {
+      let (base_code, codes) = input
+      let base = bases[wrap_index(base_code, bases.length())]
+      let text = make_text(codes)
+      actual_int64(text, base) == oracle_int64(text, base)
+    },
+    count=20000,
+  )
+}
+
+///|
+test "quickcheck: parse_uint64 agrees with the grammar" {
+  @quickcheck.check(
+    (input : (Int, Array[Int])) => {
+      let (base_code, codes) = input
+      let base = bases[wrap_index(base_code, bases.length())]
+      let text = make_text(codes)
+      actual_uint64(text, base) == oracle_uint64(text, base)
+    },
+    count=20000,
+  )
+}
+
+///|
+test "quickcheck: parse_int is parse_int64 narrowed to 32 bits" {
+  @quickcheck.check(
+    (input : (Int, Array[Int])) => {
+      let (base_code, codes) = input
+      let base = bases[wrap_index(base_code, bases.length())]
+      let text = make_text(codes)
+      let expected = match oracle_int64(text, base) {
+        Some(v) if v >= -2147483648L && v <= 2147483647L => Some(v.to_int())
+        _ => None
+      }
+      actual_int(text, base) == expected
+    },
+    count=20000,
+  )
+}
+
+///|
+test "quickcheck: parse_uint is parse_uint64 narrowed to 32 bits" {
+  @quickcheck.check(
+    (input : (Int, Array[Int])) => {
+      let (base_code, codes) = input
+      let base = bases[wrap_index(base_code, bases.length())]
+      let text = make_text(codes)
+      let expected = match oracle_uint64(text, base) {
+        Some(v) if v <= 0xffffffffUL => Some(v.to_uint())
+        _ => None
+      }
+      actual_uint(text, base) == expected
+    },
+    count=20000,
+  )
+}
diff --git a/internal/strconv/strconv_coverage_test.mbt b/internal/strconv/strconv_coverage_test.mbt
index 74ac0c5ce1..fa1aaec425 100644
--- a/internal/strconv/strconv_coverage_test.mbt
+++ b/internal/strconv/strconv_coverage_test.mbt
@@ -157,7 +157,7 @@ test "double slow path: signs, subnormals and buffer-filling fractions" {
   inspect(@strconv.parse_double("4.9e-324") > 0.0, content="true")
   // a fraction with more than the 800-digit buffer overflows it and forces
   // left-shift truncation/trimming during the binary conversion
-  let frac = StringBuilder::new()
+  let frac = StringBuilder()
   frac.write_string("0.")
   for _ in 0..<90 {
     frac.write_string("1234567890")
@@ -165,14 +165,14 @@ test "double slow path: signs, subnormals and buffer-filling fractions" {
   frac.write_string("5")
   inspect(@strconv.parse_double(frac.to_string()) > 0.0, content="true")
   // the same digit count at magnitude ~1.5 drives right-shift truncation
-  let mid = StringBuilder::new()
+  let mid = StringBuilder()
   mid.write_string("1.5")
   for _ in 0..<90 {
     mid.write_string("1234567890")
   }
   inspect(@strconv.parse_double(mid.to_string()) > 1.0, content="true")
   // a huge integer part runs through the slow path and overflows the range
-  let big = StringBuilder::new()
+  let big = StringBuilder()
   for _ in 0..<40 {
     big.write_string("1234567890")
   }
@@ -183,7 +183,7 @@ test "double slow path: signs, subnormals and buffer-filling fractions" {
 test "double slow path: exponents past the digit-position limits" {
   // a large positive exponent with a long integer part pushes the decimal
   // point past the upper accumulation limit
-  let hi = StringBuilder::new()
+  let hi = StringBuilder()
   for _ in 0..<320 {
     hi.write_string("9")
   }
@@ -191,7 +191,7 @@ test "double slow path: exponents past the digit-position limits" {
   inspect(double_raises(hi.to_string()), content="true")
   // a long leading-zero fraction with a negative exponent pushes the decimal
   // point past the lower limit, underflowing to zero
-  let lo = StringBuilder::new()
+  let lo = StringBuilder()
   lo.write_string("0.")
   for _ in 0..<340 {
     lo.write_string("0")
diff --git a/internal/strconv/strconv_double.mbt b/internal/strconv/strconv_double.mbt
index 156a47328a..78cedd8888 100644
--- a/internal/strconv/strconv_double.mbt
+++ b/internal/strconv/strconv_double.mbt
@@ -46,15 +46,22 @@ let max_exponent_disguised_fast_path : Int64 = 37L
 let max_mantissa_fast_path : UInt64 = 2UL << mantissa_explicit_bits
 
 ///|
-/// Parse a string into a double precision floating point number. The string
-/// must contain at least one of:
-/// - An integer part (decimal digits)
-/// - A decimal point followed by a fractional part (decimal digits)
-/// - An exponent part ('e' or 'E' followed by an optional sign and decimal digits)
+/// Parse a string into a double precision floating point number. Except for
+/// the infinity and NaN spellings listed below, the string must contain at
+/// least one decimal digit, in:
+/// - An integer part (decimal digits), and/or
+/// - A fractional part (decimal digits) after a decimal point
+///
+/// An optional exponent part ('e' or 'E' followed by an optional sign and
+/// decimal digits) may follow those digits, but is not sufficient on its own.
 ///
 /// The string may optionally start with a sign ('+' or '-').
 /// For readability, underscores may appear between digits.
 ///
+/// The digit-free forms "inf", "infinity" and "nan" are also accepted, in any
+/// letter case and with an optional leading sign, yielding the corresponding
+/// infinity or NaN value.
+///
 /// Examples:
 /// ```mbt check
 /// test {
@@ -79,7 +86,18 @@ pub fn parse_double(str : StringView) -> Double raise {
       // Clinger's fast path (How to read floating point numbers accurately)[https://doi.org/10.1145/989393.989430]
       match num.try_fast_path() {
         Some(value) => value
-        None => parse_decimal_priv(str).to_double_priv() // fallback to slow path
+        None => {
+          let fast = if num.many_digits {
+            @double.not_a_number
+          } else {
+            try_eisel_lemire64(num.mantissa, num.exponent, num.negative)
+          }
+          if fast.is_nan() {
+            parse_decimal_priv(str).to_double_priv() // fallback to slow path
+          } else {
+            fast
+          }
+        }
       }
   }
 }
@@ -127,10 +145,9 @@ fn Number::try_fast_path(self : Number) -> Double? {
     } else {
       // disguised fast path
       let shift = self.exponent - max_exponent_fast_path
-      let mantissa = match
-        checked_mul(self.mantissa, int_pow10[shift.to_int()]) {
-        Some(m) => m
-        None => return None
+      guard checked_mul(self.mantissa, int_pow10[shift.to_int()])
+        is Some(mantissa) else {
+        return None
       }
       if mantissa > max_mantissa_fast_path {
         return None
diff --git a/internal/strconv/strconv_eisel_lemire.mbt b/internal/strconv/strconv_eisel_lemire.mbt
new file mode 100644
index 0000000000..12854394a0
--- /dev/null
+++ b/internal/strconv/strconv_eisel_lemire.mbt
@@ -0,0 +1,128 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+#valtype
+priv struct EiselProduct {
+  lo : UInt64
+  hi : UInt64
+}
+
+///|
+fn eisel_umul128(a : UInt64, b : UInt64) -> EiselProduct {
+  let a_lo = a & 0xffffffffUL
+  let a_hi = a >> 32
+  let b_lo = b & 0xffffffffUL
+  let b_hi = b >> 32
+  let x = a_lo * b_lo
+  let y = a_hi * b_lo + (x >> 32)
+  let z = a_lo * b_hi + (y & 0xffffffffUL)
+  let hi = a_hi * b_hi + (y >> 32) + (z >> 32)
+  { lo: a * b, hi, }
+}
+
+///|
+fn eisel_mul_log2_10(exponent : Int) -> Int {
+  // floor(exponent * log2(10)) for -500 <= exponent <= 500.
+  (exponent * 108853) >> 15
+}
+
+///|
+/// Attempts Eisel-Lemire conversion of `mantissa * 10^exponent`.
+///
+/// A NaN result is a private failure sentinel. The algorithm deliberately
+/// rejects values whose correct rounding cannot be certified; callers must
+/// retain an exact Decimal fallback for those inputs.
+#doc(hidden)
+pub fn try_eisel_lemire64(
+  mantissa : UInt64,
+  exponent : Int64,
+  negative : Bool,
+) -> Double {
+  if mantissa == 0UL {
+    return if negative {
+      0x8000000000000000UL.reinterpret_as_double()
+    } else {
+      0.0
+    }
+  }
+  if exponent < EISEL_LEMIRE_POW10_MIN.to_int64() ||
+    exponent > EISEL_LEMIRE_POW10_MAX.to_int64() {
+    return @double.not_a_number
+  }
+  let exponent = exponent.to_int()
+  let table_index = (exponent - EISEL_LEMIRE_POW10_MIN) * 2
+  let pow_hi = eisel_lemire_pow10_table[table_index]
+  let pow_lo = eisel_lemire_pow10_table[table_index + 1]
+  let pow_exp2 = 1 + eisel_mul_log2_10(exponent)
+
+  // Normalize the decimal mantissa so its most significant bit is set.
+  let leading_zeros = mantissa.clz()
+  let normalized = mantissa << leading_zeros
+  let mut result_exp2 = pow_exp2 + 63 + 1023 - leading_zeros
+
+  let product = eisel_umul128(normalized, pow_hi)
+  let mut product_hi = product.hi
+  let mut product_lo = product.lo
+
+  // Use the low limb of the cached power when the first product does not
+  // contain enough information to determine the rounded result.
+  if (product_hi & 0x1ffUL) == 0x1ffUL && product_lo + normalized < normalized {
+    let wider = eisel_umul128(normalized, pow_lo)
+    let mut merged_hi = product_hi
+    let merged_lo = product_lo + wider.hi
+    if merged_lo < product_lo {
+      merged_hi += 1UL
+    }
+    if (merged_hi & 0x1ffUL) == 0x1ffUL &&
+      merged_lo + 1UL == 0UL &&
+      wider.lo + normalized < normalized {
+      return @double.not_a_number
+    }
+    product_hi = merged_hi
+    product_lo = merged_lo
+  }
+
+  // Keep 54 significant bits, then round down to the binary64 precision.
+  let top_bit = (product_hi >> 63).to_int()
+  let mut result_mantissa = product_hi >> (top_bit + 9)
+  result_exp2 -= 1 - top_bit
+
+  // An exact halfway case needs the Decimal fallback to resolve ties safely.
+  if product_lo == 0UL &&
+    (product_hi & 0x1ffUL) == 0UL &&
+    (result_mantissa & 3UL) == 1UL {
+    return @double.not_a_number
+  }
+
+  result_mantissa += result_mantissa & 1UL
+  result_mantissa = result_mantissa >> 1
+  if result_mantissa >> 53 > 0UL {
+    result_mantissa = result_mantissa >> 1
+    result_exp2 += 1
+  }
+
+  // Subnormal, overflow, and special-value boundaries remain on the exact
+  // fallback path.
+  if result_exp2 <= 0 || result_exp2 >= 0x7ff {
+    return @double.not_a_number
+  }
+  let exponent_bits = UInt64::extend_uint(result_exp2.reinterpret_as_uint()) <<
+    52
+  let mut result_bits = exponent_bits | (result_mantissa & 0x000fffffffffffffUL)
+  if negative {
+    result_bits = result_bits | 0x8000000000000000UL
+  }
+  result_bits.reinterpret_as_double()
+}
diff --git a/internal/strconv/strconv_eisel_lemire_table.mbt b/internal/strconv/strconv_eisel_lemire_table.mbt
new file mode 100644
index 0000000000..82c6cc10d7
--- /dev/null
+++ b/internal/strconv/strconv_eisel_lemire_table.mbt
@@ -0,0 +1,723 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated from Go's internal/strconv/pow10tab.go. DO NOT EDIT.
+
+///|
+const EISEL_LEMIRE_POW10_MIN : Int = -348
+
+///|
+const EISEL_LEMIRE_POW10_MAX : Int = 347
+
+///|
+// Flat pairs of high and low UInt64 limbs. Each pair is a 128-bit mantissa
+// of 10^e, scaled so that its high bit is set.
+let eisel_lemire_pow10_table : ReadOnlyArray[UInt64] = [
+  0xfa8fd5a0081c0288UL, 0x1732c869cd60e453UL, // 1e-348 * 2**1284
+   0x9c99e58405118195UL, 0x0e7fbd42205c8eb4UL, // 1e-347 * 2**1280
+   0xc3c05ee50655e1faUL, 0x521fac92a873b261UL, // 1e-346 * 2**1277
+   0xf4b0769e47eb5a78UL, 0xe6a797b752909ef9UL, // 1e-345 * 2**1274
+   0x98ee4a22ecf3188bUL, 0x9028bed2939a635cUL, // 1e-344 * 2**1270
+   0xbf29dcaba82fdeaeUL, 0x7432ee873880fc33UL, // 1e-343 * 2**1267
+   0xeef453d6923bd65aUL, 0x113faa2906a13b3fUL, // 1e-342 * 2**1264
+   0x9558b4661b6565f8UL, 0x4ac7ca59a424c507UL, // 1e-341 * 2**1260
+   0xbaaee17fa23ebf76UL, 0x5d79bcf00d2df649UL, // 1e-340 * 2**1257
+   0xe95a99df8ace6f53UL, 0xf4d82c2c107973dcUL, // 1e-339 * 2**1254
+   0x91d8a02bb6c10594UL, 0x79071b9b8a4be869UL, // 1e-338 * 2**1250
+   0xb64ec836a47146f9UL, 0x9748e2826cdee284UL, // 1e-337 * 2**1247
+   0xe3e27a444d8d98b7UL, 0xfd1b1b2308169b25UL, // 1e-336 * 2**1244
+   0x8e6d8c6ab0787f72UL, 0xfe30f0f5e50e20f7UL, // 1e-335 * 2**1240
+   0xb208ef855c969f4fUL, 0xbdbd2d335e51a935UL, // 1e-334 * 2**1237
+   0xde8b2b66b3bc4723UL, 0xad2c788035e61382UL, // 1e-333 * 2**1234
+   0x8b16fb203055ac76UL, 0x4c3bcb5021afcc31UL, // 1e-332 * 2**1230
+   0xaddcb9e83c6b1793UL, 0xdf4abe242a1bbf3dUL, // 1e-331 * 2**1227
+   0xd953e8624b85dd78UL, 0xd71d6dad34a2af0dUL, // 1e-330 * 2**1224
+   0x87d4713d6f33aa6bUL, 0x8672648c40e5ad68UL, // 1e-329 * 2**1220
+   0xa9c98d8ccb009506UL, 0x680efdaf511f18c2UL, // 1e-328 * 2**1217
+   0xd43bf0effdc0ba48UL, 0x0212bd1b2566def2UL, // 1e-327 * 2**1214
+   0x84a57695fe98746dUL, 0x014bb630f7604b57UL, // 1e-326 * 2**1210
+   0xa5ced43b7e3e9188UL, 0x419ea3bd35385e2dUL, // 1e-325 * 2**1207
+   0xcf42894a5dce35eaUL, 0x52064cac828675b9UL, // 1e-324 * 2**1204
+   0x818995ce7aa0e1b2UL, 0x7343efebd1940993UL, // 1e-323 * 2**1200
+   0xa1ebfb4219491a1fUL, 0x1014ebe6c5f90bf8UL, // 1e-322 * 2**1197
+   0xca66fa129f9b60a6UL, 0xd41a26e077774ef6UL, // 1e-321 * 2**1194
+   0xfd00b897478238d0UL, 0x8920b098955522b4UL, // 1e-320 * 2**1191
+   0x9e20735e8cb16382UL, 0x55b46e5f5d5535b0UL, // 1e-319 * 2**1187
+   0xc5a890362fddbc62UL, 0xeb2189f734aa831dUL, // 1e-318 * 2**1184
+   0xf712b443bbd52b7bUL, 0xa5e9ec7501d523e4UL, // 1e-317 * 2**1181
+   0x9a6bb0aa55653b2dUL, 0x47b233c92125366eUL, // 1e-316 * 2**1177
+   0xc1069cd4eabe89f8UL, 0x999ec0bb696e840aUL, // 1e-315 * 2**1174
+   0xf148440a256e2c76UL, 0xc00670ea43ca250dUL, // 1e-314 * 2**1171
+   0x96cd2a865764dbcaUL, 0x380406926a5e5728UL, // 1e-313 * 2**1167
+   0xbc807527ed3e12bcUL, 0xc605083704f5ecf2UL, // 1e-312 * 2**1164
+   0xeba09271e88d976bUL, 0xf7864a44c633682eUL, // 1e-311 * 2**1161
+   0x93445b8731587ea3UL, 0x7ab3ee6afbe0211dUL, // 1e-310 * 2**1157
+   0xb8157268fdae9e4cUL, 0x5960ea05bad82964UL, // 1e-309 * 2**1154
+   0xe61acf033d1a45dfUL, 0x6fb92487298e33bdUL, // 1e-308 * 2**1151
+   0x8fd0c16206306babUL, 0xa5d3b6d479f8e056UL, // 1e-307 * 2**1147
+   0xb3c4f1ba87bc8696UL, 0x8f48a4899877186cUL, // 1e-306 * 2**1144
+   0xe0b62e2929aba83cUL, 0x331acdabfe94de87UL, // 1e-305 * 2**1141
+   0x8c71dcd9ba0b4925UL, 0x9ff0c08b7f1d0b14UL, // 1e-304 * 2**1137
+   0xaf8e5410288e1b6fUL, 0x07ecf0ae5ee44dd9UL, // 1e-303 * 2**1134
+   0xdb71e91432b1a24aUL, 0xc9e82cd9f69d6150UL, // 1e-302 * 2**1131
+   0x892731ac9faf056eUL, 0xbe311c083a225cd2UL, // 1e-301 * 2**1127
+   0xab70fe17c79ac6caUL, 0x6dbd630a48aaf406UL, // 1e-300 * 2**1124
+   0xd64d3d9db981787dUL, 0x092cbbccdad5b108UL, // 1e-299 * 2**1121
+   0x85f0468293f0eb4eUL, 0x25bbf56008c58ea5UL, // 1e-298 * 2**1117
+   0xa76c582338ed2621UL, 0xaf2af2b80af6f24eUL, // 1e-297 * 2**1114
+   0xd1476e2c07286faaUL, 0x1af5af660db4aee1UL, // 1e-296 * 2**1111
+   0x82cca4db847945caUL, 0x50d98d9fc890ed4dUL, // 1e-295 * 2**1107
+   0xa37fce126597973cUL, 0xe50ff107bab528a0UL, // 1e-294 * 2**1104
+   0xcc5fc196fefd7d0cUL, 0x1e53ed49a96272c8UL, // 1e-293 * 2**1101
+   0xff77b1fcbebcdc4fUL, 0x25e8e89c13bb0f7aUL, // 1e-292 * 2**1098
+   0x9faacf3df73609b1UL, 0x77b191618c54e9acUL, // 1e-291 * 2**1094
+   0xc795830d75038c1dUL, 0xd59df5b9ef6a2417UL, // 1e-290 * 2**1091
+   0xf97ae3d0d2446f25UL, 0x4b0573286b44ad1dUL, // 1e-289 * 2**1088
+   0x9becce62836ac577UL, 0x4ee367f9430aec32UL, // 1e-288 * 2**1084
+   0xc2e801fb244576d5UL, 0x229c41f793cda73fUL, // 1e-287 * 2**1081
+   0xf3a20279ed56d48aUL, 0x6b43527578c1110fUL, // 1e-286 * 2**1078
+   0x9845418c345644d6UL, 0x830a13896b78aaa9UL, // 1e-285 * 2**1074
+   0xbe5691ef416bd60cUL, 0x23cc986bc656d553UL, // 1e-284 * 2**1071
+   0xedec366b11c6cb8fUL, 0x2cbfbe86b7ec8aa8UL, // 1e-283 * 2**1068
+   0x94b3a202eb1c3f39UL, 0x7bf7d71432f3d6a9UL, // 1e-282 * 2**1064
+   0xb9e08a83a5e34f07UL, 0xdaf5ccd93fb0cc53UL, // 1e-281 * 2**1061
+   0xe858ad248f5c22c9UL, 0xd1b3400f8f9cff68UL, // 1e-280 * 2**1058
+   0x91376c36d99995beUL, 0x23100809b9c21fa1UL, // 1e-279 * 2**1054
+   0xb58547448ffffb2dUL, 0xabd40a0c2832a78aUL, // 1e-278 * 2**1051
+   0xe2e69915b3fff9f9UL, 0x16c90c8f323f516cUL, // 1e-277 * 2**1048
+   0x8dd01fad907ffc3bUL, 0xae3da7d97f6792e3UL, // 1e-276 * 2**1044
+   0xb1442798f49ffb4aUL, 0x99cd11cfdf41779cUL, // 1e-275 * 2**1041
+   0xdd95317f31c7fa1dUL, 0x40405643d711d583UL, // 1e-274 * 2**1038
+   0x8a7d3eef7f1cfc52UL, 0x482835ea666b2572UL, // 1e-273 * 2**1034
+   0xad1c8eab5ee43b66UL, 0xda3243650005eecfUL, // 1e-272 * 2**1031
+   0xd863b256369d4a40UL, 0x90bed43e40076a82UL, // 1e-271 * 2**1028
+   0x873e4f75e2224e68UL, 0x5a7744a6e804a291UL, // 1e-270 * 2**1024
+   0xa90de3535aaae202UL, 0x711515d0a205cb36UL, // 1e-269 * 2**1021
+   0xd3515c2831559a83UL, 0x0d5a5b44ca873e03UL, // 1e-268 * 2**1018
+   0x8412d9991ed58091UL, 0xe858790afe9486c2UL, // 1e-267 * 2**1014
+   0xa5178fff668ae0b6UL, 0x626e974dbe39a872UL, // 1e-266 * 2**1011
+   0xce5d73ff402d98e3UL, 0xfb0a3d212dc8128fUL, // 1e-265 * 2**1008
+   0x80fa687f881c7f8eUL, 0x7ce66634bc9d0b99UL, // 1e-264 * 2**1004
+   0xa139029f6a239f72UL, 0x1c1fffc1ebc44e80UL, // 1e-263 * 2**1001
+   0xc987434744ac874eUL, 0xa327ffb266b56220UL, // 1e-262 * 2**998
+   0xfbe9141915d7a922UL, 0x4bf1ff9f0062baa8UL, // 1e-261 * 2**995
+   0x9d71ac8fada6c9b5UL, 0x6f773fc3603db4a9UL, // 1e-260 * 2**991
+   0xc4ce17b399107c22UL, 0xcb550fb4384d21d3UL, // 1e-259 * 2**988
+   0xf6019da07f549b2bUL, 0x7e2a53a146606a48UL, // 1e-258 * 2**985
+   0x99c102844f94e0fbUL, 0x2eda7444cbfc426dUL, // 1e-257 * 2**981
+   0xc0314325637a1939UL, 0xfa911155fefb5308UL, // 1e-256 * 2**978
+   0xf03d93eebc589f88UL, 0x793555ab7eba27caUL, // 1e-255 * 2**975
+   0x96267c7535b763b5UL, 0x4bc1558b2f3458deUL, // 1e-254 * 2**971
+   0xbbb01b9283253ca2UL, 0x9eb1aaedfb016f16UL, // 1e-253 * 2**968
+   0xea9c227723ee8bcbUL, 0x465e15a979c1cadcUL, // 1e-252 * 2**965
+   0x92a1958a7675175fUL, 0x0bfacd89ec191ec9UL, // 1e-251 * 2**961
+   0xb749faed14125d36UL, 0xcef980ec671f667bUL, // 1e-250 * 2**958
+   0xe51c79a85916f484UL, 0x82b7e12780e7401aUL, // 1e-249 * 2**955
+   0x8f31cc0937ae58d2UL, 0xd1b2ecb8b0908810UL, // 1e-248 * 2**951
+   0xb2fe3f0b8599ef07UL, 0x861fa7e6dcb4aa15UL, // 1e-247 * 2**948
+   0xdfbdcece67006ac9UL, 0x67a791e093e1d49aUL, // 1e-246 * 2**945
+   0x8bd6a141006042bdUL, 0xe0c8bb2c5c6d24e0UL, // 1e-245 * 2**941
+   0xaecc49914078536dUL, 0x58fae9f773886e18UL, // 1e-244 * 2**938
+   0xda7f5bf590966848UL, 0xaf39a475506a899eUL, // 1e-243 * 2**935
+   0x888f99797a5e012dUL, 0x6d8406c952429603UL, // 1e-242 * 2**931
+   0xaab37fd7d8f58178UL, 0xc8e5087ba6d33b83UL, // 1e-241 * 2**928
+   0xd5605fcdcf32e1d6UL, 0xfb1e4a9a90880a64UL, // 1e-240 * 2**925
+   0x855c3be0a17fcd26UL, 0x5cf2eea09a55067fUL, // 1e-239 * 2**921
+   0xa6b34ad8c9dfc06fUL, 0xf42faa48c0ea481eUL, // 1e-238 * 2**918
+   0xd0601d8efc57b08bUL, 0xf13b94daf124da26UL, // 1e-237 * 2**915
+   0x823c12795db6ce57UL, 0x76c53d08d6b70858UL, // 1e-236 * 2**911
+   0xa2cb1717b52481edUL, 0x54768c4b0c64ca6eUL, // 1e-235 * 2**908
+   0xcb7ddcdda26da268UL, 0xa9942f5dcf7dfd09UL, // 1e-234 * 2**905
+   0xfe5d54150b090b02UL, 0xd3f93b35435d7c4cUL, // 1e-233 * 2**902
+   0x9efa548d26e5a6e1UL, 0xc47bc5014a1a6dafUL, // 1e-232 * 2**898
+   0xc6b8e9b0709f109aUL, 0x359ab6419ca1091bUL, // 1e-231 * 2**895
+   0xf867241c8cc6d4c0UL, 0xc30163d203c94b62UL, // 1e-230 * 2**892
+   0x9b407691d7fc44f8UL, 0x79e0de63425dcf1dUL, // 1e-229 * 2**888
+   0xc21094364dfb5636UL, 0x985915fc12f542e4UL, // 1e-228 * 2**885
+   0xf294b943e17a2bc4UL, 0x3e6f5b7b17b2939dUL, // 1e-227 * 2**882
+   0x979cf3ca6cec5b5aUL, 0xa705992ceecf9c42UL, // 1e-226 * 2**878
+   0xbd8430bd08277231UL, 0x50c6ff782a838353UL, // 1e-225 * 2**875
+   0xece53cec4a314ebdUL, 0xa4f8bf5635246428UL, // 1e-224 * 2**872
+   0x940f4613ae5ed136UL, 0x871b7795e136be99UL, // 1e-223 * 2**868
+   0xb913179899f68584UL, 0x28e2557b59846e3fUL, // 1e-222 * 2**865
+   0xe757dd7ec07426e5UL, 0x331aeada2fe589cfUL, // 1e-221 * 2**862
+   0x9096ea6f3848984fUL, 0x3ff0d2c85def7621UL, // 1e-220 * 2**858
+   0xb4bca50b065abe63UL, 0x0fed077a756b53a9UL, // 1e-219 * 2**855
+   0xe1ebce4dc7f16dfbUL, 0xd3e8495912c62894UL, // 1e-218 * 2**852
+   0x8d3360f09cf6e4bdUL, 0x64712dd7abbbd95cUL, // 1e-217 * 2**848
+   0xb080392cc4349decUL, 0xbd8d794d96aacfb3UL, // 1e-216 * 2**845
+   0xdca04777f541c567UL, 0xecf0d7a0fc5583a0UL, // 1e-215 * 2**842
+   0x89e42caaf9491b60UL, 0xf41686c49db57244UL, // 1e-214 * 2**838
+   0xac5d37d5b79b6239UL, 0x311c2875c522ced5UL, // 1e-213 * 2**835
+   0xd77485cb25823ac7UL, 0x7d633293366b828bUL, // 1e-212 * 2**832
+   0x86a8d39ef77164bcUL, 0xae5dff9c02033197UL, // 1e-211 * 2**828
+   0xa8530886b54dbdebUL, 0xd9f57f830283fdfcUL, // 1e-210 * 2**825
+   0xd267caa862a12d66UL, 0xd072df63c324fd7bUL, // 1e-209 * 2**822
+   0x8380dea93da4bc60UL, 0x4247cb9e59f71e6dUL, // 1e-208 * 2**818
+   0xa46116538d0deb78UL, 0x52d9be85f074e608UL, // 1e-207 * 2**815
+   0xcd795be870516656UL, 0x67902e276c921f8bUL, // 1e-206 * 2**812
+   0x806bd9714632dff6UL, 0x00ba1cd8a3db53b6UL, // 1e-205 * 2**808
+   0xa086cfcd97bf97f3UL, 0x80e8a40eccd228a4UL, // 1e-204 * 2**805
+   0xc8a883c0fdaf7df0UL, 0x6122cd128006b2cdUL, // 1e-203 * 2**802
+   0xfad2a4b13d1b5d6cUL, 0x796b805720085f81UL, // 1e-202 * 2**799
+   0x9cc3a6eec6311a63UL, 0xcbe3303674053bb0UL, // 1e-201 * 2**795
+   0xc3f490aa77bd60fcUL, 0xbedbfc4411068a9cUL, // 1e-200 * 2**792
+   0xf4f1b4d515acb93bUL, 0xee92fb5515482d44UL, // 1e-199 * 2**789
+   0x991711052d8bf3c5UL, 0x751bdd152d4d1c4aUL, // 1e-198 * 2**785
+   0xbf5cd54678eef0b6UL, 0xd262d45a78a0635dUL, // 1e-197 * 2**782
+   0xef340a98172aace4UL, 0x86fb897116c87c34UL, // 1e-196 * 2**779
+   0x9580869f0e7aac0eUL, 0xd45d35e6ae3d4da0UL, // 1e-195 * 2**775
+   0xbae0a846d2195712UL, 0x8974836059cca109UL, // 1e-194 * 2**772
+   0xe998d258869facd7UL, 0x2bd1a438703fc94bUL, // 1e-193 * 2**769
+   0x91ff83775423cc06UL, 0x7b6306a34627ddcfUL, // 1e-192 * 2**765
+   0xb67f6455292cbf08UL, 0x1a3bc84c17b1d542UL, // 1e-191 * 2**762
+   0xe41f3d6a7377eecaUL, 0x20caba5f1d9e4a93UL, // 1e-190 * 2**759
+   0x8e938662882af53eUL, 0x547eb47b7282ee9cUL, // 1e-189 * 2**755
+   0xb23867fb2a35b28dUL, 0xe99e619a4f23aa43UL, // 1e-188 * 2**752
+   0xdec681f9f4c31f31UL, 0x6405fa00e2ec94d4UL, // 1e-187 * 2**749
+   0x8b3c113c38f9f37eUL, 0xde83bc408dd3dd04UL, // 1e-186 * 2**745
+   0xae0b158b4738705eUL, 0x9624ab50b148d445UL, // 1e-185 * 2**742
+   0xd98ddaee19068c76UL, 0x3badd624dd9b0957UL, // 1e-184 * 2**739
+   0x87f8a8d4cfa417c9UL, 0xe54ca5d70a80e5d6UL, // 1e-183 * 2**735
+   0xa9f6d30a038d1dbcUL, 0x5e9fcf4ccd211f4cUL, // 1e-182 * 2**732
+   0xd47487cc8470652bUL, 0x7647c3200069671fUL, // 1e-181 * 2**729
+   0x84c8d4dfd2c63f3bUL, 0x29ecd9f40041e073UL, // 1e-180 * 2**725
+   0xa5fb0a17c777cf09UL, 0xf468107100525890UL, // 1e-179 * 2**722
+   0xcf79cc9db955c2ccUL, 0x7182148d4066eeb4UL, // 1e-178 * 2**719
+   0x81ac1fe293d599bfUL, 0xc6f14cd848405530UL, // 1e-177 * 2**715
+   0xa21727db38cb002fUL, 0xb8ada00e5a506a7cUL, // 1e-176 * 2**712
+   0xca9cf1d206fdc03bUL, 0xa6d90811f0e4851cUL, // 1e-175 * 2**709
+   0xfd442e4688bd304aUL, 0x908f4a166d1da663UL, // 1e-174 * 2**706
+   0x9e4a9cec15763e2eUL, 0x9a598e4e043287feUL, // 1e-173 * 2**702
+   0xc5dd44271ad3cdbaUL, 0x40eff1e1853f29fdUL, // 1e-172 * 2**699
+   0xf7549530e188c128UL, 0xd12bee59e68ef47cUL, // 1e-171 * 2**696
+   0x9a94dd3e8cf578b9UL, 0x82bb74f8301958ceUL, // 1e-170 * 2**692
+   0xc13a148e3032d6e7UL, 0xe36a52363c1faf01UL, // 1e-169 * 2**689
+   0xf18899b1bc3f8ca1UL, 0xdc44e6c3cb279ac1UL, // 1e-168 * 2**686
+   0x96f5600f15a7b7e5UL, 0x29ab103a5ef8c0b9UL, // 1e-167 * 2**682
+   0xbcb2b812db11a5deUL, 0x7415d448f6b6f0e7UL, // 1e-166 * 2**679
+   0xebdf661791d60f56UL, 0x111b495b3464ad21UL, // 1e-165 * 2**676
+   0x936b9fcebb25c995UL, 0xcab10dd900beec34UL, // 1e-164 * 2**672
+   0xb84687c269ef3bfbUL, 0x3d5d514f40eea742UL, // 1e-163 * 2**669
+   0xe65829b3046b0afaUL, 0x0cb4a5a3112a5112UL, // 1e-162 * 2**666
+   0x8ff71a0fe2c2e6dcUL, 0x47f0e785eaba72abUL, // 1e-161 * 2**662
+   0xb3f4e093db73a093UL, 0x59ed216765690f56UL, // 1e-160 * 2**659
+   0xe0f218b8d25088b8UL, 0x306869c13ec3532cUL, // 1e-159 * 2**656
+   0x8c974f7383725573UL, 0x1e414218c73a13fbUL, // 1e-158 * 2**652
+   0xafbd2350644eeacfUL, 0xe5d1929ef90898faUL, // 1e-157 * 2**649
+   0xdbac6c247d62a583UL, 0xdf45f746b74abf39UL, // 1e-156 * 2**646
+   0x894bc396ce5da772UL, 0x6b8bba8c328eb783UL, // 1e-155 * 2**642
+   0xab9eb47c81f5114fUL, 0x066ea92f3f326564UL, // 1e-154 * 2**639
+   0xd686619ba27255a2UL, 0xc80a537b0efefebdUL, // 1e-153 * 2**636
+   0x8613fd0145877585UL, 0xbd06742ce95f5f36UL, // 1e-152 * 2**632
+   0xa798fc4196e952e7UL, 0x2c48113823b73704UL, // 1e-151 * 2**629
+   0xd17f3b51fca3a7a0UL, 0xf75a15862ca504c5UL, // 1e-150 * 2**626
+   0x82ef85133de648c4UL, 0x9a984d73dbe722fbUL, // 1e-149 * 2**622
+   0xa3ab66580d5fdaf5UL, 0xc13e60d0d2e0ebbaUL, // 1e-148 * 2**619
+   0xcc963fee10b7d1b3UL, 0x318df905079926a8UL, // 1e-147 * 2**616
+   0xffbbcfe994e5c61fUL, 0xfdf17746497f7052UL, // 1e-146 * 2**613
+   0x9fd561f1fd0f9bd3UL, 0xfeb6ea8bedefa633UL, // 1e-145 * 2**609
+   0xc7caba6e7c5382c8UL, 0xfe64a52ee96b8fc0UL, // 1e-144 * 2**606
+   0xf9bd690a1b68637bUL, 0x3dfdce7aa3c673b0UL, // 1e-143 * 2**603
+   0x9c1661a651213e2dUL, 0x06bea10ca65c084eUL, // 1e-142 * 2**599
+   0xc31bfa0fe5698db8UL, 0x486e494fcff30a62UL, // 1e-141 * 2**596
+   0xf3e2f893dec3f126UL, 0x5a89dba3c3efccfaUL, // 1e-140 * 2**593
+   0x986ddb5c6b3a76b7UL, 0xf89629465a75e01cUL, // 1e-139 * 2**589
+   0xbe89523386091465UL, 0xf6bbb397f1135823UL, // 1e-138 * 2**586
+   0xee2ba6c0678b597fUL, 0x746aa07ded582e2cUL, // 1e-137 * 2**583
+   0x94db483840b717efUL, 0xa8c2a44eb4571cdcUL, // 1e-136 * 2**579
+   0xba121a4650e4ddebUL, 0x92f34d62616ce413UL, // 1e-135 * 2**576
+   0xe896a0d7e51e1566UL, 0x77b020baf9c81d17UL, // 1e-134 * 2**573
+   0x915e2486ef32cd60UL, 0x0ace1474dc1d122eUL, // 1e-133 * 2**569
+   0xb5b5ada8aaff80b8UL, 0x0d819992132456baUL, // 1e-132 * 2**566
+   0xe3231912d5bf60e6UL, 0x10e1fff697ed6c69UL, // 1e-131 * 2**563
+   0x8df5efabc5979c8fUL, 0xca8d3ffa1ef463c1UL, // 1e-130 * 2**559
+   0xb1736b96b6fd83b3UL, 0xbd308ff8a6b17cb2UL, // 1e-129 * 2**556
+   0xddd0467c64bce4a0UL, 0xac7cb3f6d05ddbdeUL, // 1e-128 * 2**553
+   0x8aa22c0dbef60ee4UL, 0x6bcdf07a423aa96bUL, // 1e-127 * 2**549
+   0xad4ab7112eb3929dUL, 0x86c16c98d2c953c6UL, // 1e-126 * 2**546
+   0xd89d64d57a607744UL, 0xe871c7bf077ba8b7UL, // 1e-125 * 2**543
+   0x87625f056c7c4a8bUL, 0x11471cd764ad4972UL, // 1e-124 * 2**539
+   0xa93af6c6c79b5d2dUL, 0xd598e40d3dd89bcfUL, // 1e-123 * 2**536
+   0xd389b47879823479UL, 0x4aff1d108d4ec2c3UL, // 1e-122 * 2**533
+   0x843610cb4bf160cbUL, 0xcedf722a585139baUL, // 1e-121 * 2**529
+   0xa54394fe1eedb8feUL, 0xc2974eb4ee658828UL, // 1e-120 * 2**526
+   0xce947a3da6a9273eUL, 0x733d226229feea32UL, // 1e-119 * 2**523
+   0x811ccc668829b887UL, 0x0806357d5a3f525fUL, // 1e-118 * 2**519
+   0xa163ff802a3426a8UL, 0xca07c2dcb0cf26f7UL, // 1e-117 * 2**516
+   0xc9bcff6034c13052UL, 0xfc89b393dd02f0b5UL, // 1e-116 * 2**513
+   0xfc2c3f3841f17c67UL, 0xbbac2078d443ace2UL, // 1e-115 * 2**510
+   0x9d9ba7832936edc0UL, 0xd54b944b84aa4c0dUL, // 1e-114 * 2**506
+   0xc5029163f384a931UL, 0x0a9e795e65d4df11UL, // 1e-113 * 2**503
+   0xf64335bcf065d37dUL, 0x4d4617b5ff4a16d5UL, // 1e-112 * 2**500
+   0x99ea0196163fa42eUL, 0x504bced1bf8e4e45UL, // 1e-111 * 2**496
+   0xc06481fb9bcf8d39UL, 0xe45ec2862f71e1d6UL, // 1e-110 * 2**493
+   0xf07da27a82c37088UL, 0x5d767327bb4e5a4cUL, // 1e-109 * 2**490
+   0x964e858c91ba2655UL, 0x3a6a07f8d510f86fUL, // 1e-108 * 2**486
+   0xbbe226efb628afeaUL, 0x890489f70a55368bUL, // 1e-107 * 2**483
+   0xeadab0aba3b2dbe5UL, 0x2b45ac74ccea842eUL, // 1e-106 * 2**480
+   0x92c8ae6b464fc96fUL, 0x3b0b8bc90012929dUL, // 1e-105 * 2**476
+   0xb77ada0617e3bbcbUL, 0x09ce6ebb40173744UL, // 1e-104 * 2**473
+   0xe55990879ddcaabdUL, 0xcc420a6a101d0515UL, // 1e-103 * 2**470
+   0x8f57fa54c2a9eab6UL, 0x9fa946824a12232dUL, // 1e-102 * 2**466
+   0xb32df8e9f3546564UL, 0x47939822dc96abf9UL, // 1e-101 * 2**463
+   0xdff9772470297ebdUL, 0x59787e2b93bc56f7UL, // 1e-100 * 2**460
+   0x8bfbea76c619ef36UL, 0x57eb4edb3c55b65aUL, // 1e-99 * 2**456
+   0xaefae51477a06b03UL, 0xede622920b6b23f1UL, // 1e-98 * 2**453
+   0xdab99e59958885c4UL, 0xe95fab368e45ecedUL, // 1e-97 * 2**450
+   0x88b402f7fd75539bUL, 0x11dbcb0218ebb414UL, // 1e-96 * 2**446
+   0xaae103b5fcd2a881UL, 0xd652bdc29f26a119UL, // 1e-95 * 2**443
+   0xd59944a37c0752a2UL, 0x4be76d3346f0495fUL, // 1e-94 * 2**440
+   0x857fcae62d8493a5UL, 0x6f70a4400c562ddbUL, // 1e-93 * 2**436
+   0xa6dfbd9fb8e5b88eUL, 0xcb4ccd500f6bb952UL, // 1e-92 * 2**433
+   0xd097ad07a71f26b2UL, 0x7e2000a41346a7a7UL, // 1e-91 * 2**430
+   0x825ecc24c873782fUL, 0x8ed400668c0c28c8UL, // 1e-90 * 2**426
+   0xa2f67f2dfa90563bUL, 0x728900802f0f32faUL, // 1e-89 * 2**423
+   0xcbb41ef979346bcaUL, 0x4f2b40a03ad2ffb9UL, // 1e-88 * 2**420
+   0xfea126b7d78186bcUL, 0xe2f610c84987bfa8UL, // 1e-87 * 2**417
+   0x9f24b832e6b0f436UL, 0x0dd9ca7d2df4d7c9UL, // 1e-86 * 2**413
+   0xc6ede63fa05d3143UL, 0x91503d1c79720dbbUL, // 1e-85 * 2**410
+   0xf8a95fcf88747d94UL, 0x75a44c6397ce912aUL, // 1e-84 * 2**407
+   0x9b69dbe1b548ce7cUL, 0xc986afbe3ee11abaUL, // 1e-83 * 2**403
+   0xc24452da229b021bUL, 0xfbe85badce996168UL, // 1e-82 * 2**400
+   0xf2d56790ab41c2a2UL, 0xfae27299423fb9c3UL, // 1e-81 * 2**397
+   0x97c560ba6b0919a5UL, 0xdccd879fc967d41aUL, // 1e-80 * 2**393
+   0xbdb6b8e905cb600fUL, 0x5400e987bbc1c920UL, // 1e-79 * 2**390
+   0xed246723473e3813UL, 0x290123e9aab23b68UL, // 1e-78 * 2**387
+   0x9436c0760c86e30bUL, 0xf9a0b6720aaf6521UL, // 1e-77 * 2**383
+   0xb94470938fa89bceUL, 0xf808e40e8d5b3e69UL, // 1e-76 * 2**380
+   0xe7958cb87392c2c2UL, 0xb60b1d1230b20e04UL, // 1e-75 * 2**377
+   0x90bd77f3483bb9b9UL, 0xb1c6f22b5e6f48c2UL, // 1e-74 * 2**373
+   0xb4ecd5f01a4aa828UL, 0x1e38aeb6360b1af3UL, // 1e-73 * 2**370
+   0xe2280b6c20dd5232UL, 0x25c6da63c38de1b0UL, // 1e-72 * 2**367
+   0x8d590723948a535fUL, 0x579c487e5a38ad0eUL, // 1e-71 * 2**363
+   0xb0af48ec79ace837UL, 0x2d835a9df0c6d851UL, // 1e-70 * 2**360
+   0xdcdb1b2798182244UL, 0xf8e431456cf88e65UL, // 1e-69 * 2**357
+   0x8a08f0f8bf0f156bUL, 0x1b8e9ecb641b58ffUL, // 1e-68 * 2**353
+   0xac8b2d36eed2dac5UL, 0xe272467e3d222f3fUL, // 1e-67 * 2**350
+   0xd7adf884aa879177UL, 0x5b0ed81dcc6abb0fUL, // 1e-66 * 2**347
+   0x86ccbb52ea94baeaUL, 0x98e947129fc2b4e9UL, // 1e-65 * 2**343
+   0xa87fea27a539e9a5UL, 0x3f2398d747b36224UL, // 1e-64 * 2**340
+   0xd29fe4b18e88640eUL, 0x8eec7f0d19a03aadUL, // 1e-63 * 2**337
+   0x83a3eeeef9153e89UL, 0x1953cf68300424acUL, // 1e-62 * 2**333
+   0xa48ceaaab75a8e2bUL, 0x5fa8c3423c052dd7UL, // 1e-61 * 2**330
+   0xcdb02555653131b6UL, 0x3792f412cb06794dUL, // 1e-60 * 2**327
+   0x808e17555f3ebf11UL, 0xe2bbd88bbee40bd0UL, // 1e-59 * 2**323
+   0xa0b19d2ab70e6ed6UL, 0x5b6aceaeae9d0ec4UL, // 1e-58 * 2**320
+   0xc8de047564d20a8bUL, 0xf245825a5a445275UL, // 1e-57 * 2**317
+   0xfb158592be068d2eUL, 0xeed6e2f0f0d56712UL, // 1e-56 * 2**314
+   0x9ced737bb6c4183dUL, 0x55464dd69685606bUL, // 1e-55 * 2**310
+   0xc428d05aa4751e4cUL, 0xaa97e14c3c26b886UL, // 1e-54 * 2**307
+   0xf53304714d9265dfUL, 0xd53dd99f4b3066a8UL, // 1e-53 * 2**304
+   0x993fe2c6d07b7fabUL, 0xe546a8038efe4029UL, // 1e-52 * 2**300
+   0xbf8fdb78849a5f96UL, 0xde98520472bdd033UL, // 1e-51 * 2**297
+   0xef73d256a5c0f77cUL, 0x963e66858f6d4440UL, // 1e-50 * 2**294
+   0x95a8637627989aadUL, 0xdde7001379a44aa8UL, // 1e-49 * 2**290
+   0xbb127c53b17ec159UL, 0x5560c018580d5d52UL, // 1e-48 * 2**287
+   0xe9d71b689dde71afUL, 0xaab8f01e6e10b4a6UL, // 1e-47 * 2**284
+   0x9226712162ab070dUL, 0xcab3961304ca70e8UL, // 1e-46 * 2**280
+   0xb6b00d69bb55c8d1UL, 0x3d607b97c5fd0d22UL, // 1e-45 * 2**277
+   0xe45c10c42a2b3b05UL, 0x8cb89a7db77c506aUL, // 1e-44 * 2**274
+   0x8eb98a7a9a5b04e3UL, 0x77f3608e92adb242UL, // 1e-43 * 2**270
+   0xb267ed1940f1c61cUL, 0x55f038b237591ed3UL, // 1e-42 * 2**267
+   0xdf01e85f912e37a3UL, 0x6b6c46dec52f6688UL, // 1e-41 * 2**264
+   0x8b61313bbabce2c6UL, 0x2323ac4b3b3da015UL, // 1e-40 * 2**260
+   0xae397d8aa96c1b77UL, 0xabec975e0a0d081aUL, // 1e-39 * 2**257
+   0xd9c7dced53c72255UL, 0x96e7bd358c904a21UL, // 1e-38 * 2**254
+   0x881cea14545c7575UL, 0x7e50d64177da2e54UL, // 1e-37 * 2**250
+   0xaa242499697392d2UL, 0xdde50bd1d5d0b9e9UL, // 1e-36 * 2**247
+   0xd4ad2dbfc3d07787UL, 0x955e4ec64b44e864UL, // 1e-35 * 2**244
+   0x84ec3c97da624ab4UL, 0xbd5af13bef0b113eUL, // 1e-34 * 2**240
+   0xa6274bbdd0fadd61UL, 0xecb1ad8aeacdd58eUL, // 1e-33 * 2**237
+   0xcfb11ead453994baUL, 0x67de18eda5814af2UL, // 1e-32 * 2**234
+   0x81ceb32c4b43fcf4UL, 0x80eacf948770ced7UL, // 1e-31 * 2**230
+   0xa2425ff75e14fc31UL, 0xa1258379a94d028dUL, // 1e-30 * 2**227
+   0xcad2f7f5359a3b3eUL, 0x096ee45813a04330UL, // 1e-29 * 2**224
+   0xfd87b5f28300ca0dUL, 0x8bca9d6e188853fcUL, // 1e-28 * 2**221
+   0x9e74d1b791e07e48UL, 0x775ea264cf55347dUL, // 1e-27 * 2**217
+   0xc612062576589ddaUL, 0x95364afe032a819dUL, // 1e-26 * 2**214
+   0xf79687aed3eec551UL, 0x3a83ddbd83f52204UL, // 1e-25 * 2**211
+   0x9abe14cd44753b52UL, 0xc4926a9672793542UL, // 1e-24 * 2**207
+   0xc16d9a0095928a27UL, 0x75b7053c0f178293UL, // 1e-23 * 2**204
+   0xf1c90080baf72cb1UL, 0x5324c68b12dd6338UL, // 1e-22 * 2**201
+   0x971da05074da7beeUL, 0xd3f6fc16ebca5e03UL, // 1e-21 * 2**197
+   0xbce5086492111aeaUL, 0x88f4bb1ca6bcf584UL, // 1e-20 * 2**194
+   0xec1e4a7db69561a5UL, 0x2b31e9e3d06c32e5UL, // 1e-19 * 2**191
+   0x9392ee8e921d5d07UL, 0x3aff322e62439fcfUL, // 1e-18 * 2**187
+   0xb877aa3236a4b449UL, 0x09befeb9fad487c2UL, // 1e-17 * 2**184
+   0xe69594bec44de15bUL, 0x4c2ebe687989a9b3UL, // 1e-16 * 2**181
+   0x901d7cf73ab0acd9UL, 0x0f9d37014bf60a10UL, // 1e-15 * 2**177
+   0xb424dc35095cd80fUL, 0x538484c19ef38c94UL, // 1e-14 * 2**174
+   0xe12e13424bb40e13UL, 0x2865a5f206b06fb9UL, // 1e-13 * 2**171
+   0x8cbccc096f5088cbUL, 0xf93f87b7442e45d3UL, // 1e-12 * 2**167
+   0xafebff0bcb24aafeUL, 0xf78f69a51539d748UL, // 1e-11 * 2**164
+   0xdbe6fecebdedd5beUL, 0xb573440e5a884d1bUL, // 1e-10 * 2**161
+   0x89705f4136b4a597UL, 0x31680a88f8953030UL, // 1e-9 * 2**157
+   0xabcc77118461cefcUL, 0xfdc20d2b36ba7c3dUL, // 1e-8 * 2**154
+   0xd6bf94d5e57a42bcUL, 0x3d32907604691b4cUL, // 1e-7 * 2**151
+   0x8637bd05af6c69b5UL, 0xa63f9a49c2c1b10fUL, // 1e-6 * 2**147
+   0xa7c5ac471b478423UL, 0x0fcf80dc33721d53UL, // 1e-5 * 2**144
+   0xd1b71758e219652bUL, 0xd3c36113404ea4a8UL, // 1e-4 * 2**141
+   0x83126e978d4fdf3bUL, 0x645a1cac083126e9UL, // 1e-3 * 2**137
+   0xa3d70a3d70a3d70aUL, 0x3d70a3d70a3d70a3UL, // 1e-2 * 2**134
+   0xccccccccccccccccUL, 0xccccccccccccccccUL, // 1e-1 * 2**131
+   0x8000000000000000UL, 0x0000000000000000UL, // 1e0 * 2**127
+   0xa000000000000000UL, 0x0000000000000000UL, // 1e1 * 2**124
+   0xc800000000000000UL, 0x0000000000000000UL, // 1e2 * 2**121
+   0xfa00000000000000UL, 0x0000000000000000UL, // 1e3 * 2**118
+   0x9c40000000000000UL, 0x0000000000000000UL, // 1e4 * 2**114
+   0xc350000000000000UL, 0x0000000000000000UL, // 1e5 * 2**111
+   0xf424000000000000UL, 0x0000000000000000UL, // 1e6 * 2**108
+   0x9896800000000000UL, 0x0000000000000000UL, // 1e7 * 2**104
+   0xbebc200000000000UL, 0x0000000000000000UL, // 1e8 * 2**101
+   0xee6b280000000000UL, 0x0000000000000000UL, // 1e9 * 2**98
+   0x9502f90000000000UL, 0x0000000000000000UL, // 1e10 * 2**94
+   0xba43b74000000000UL, 0x0000000000000000UL, // 1e11 * 2**91
+   0xe8d4a51000000000UL, 0x0000000000000000UL, // 1e12 * 2**88
+   0x9184e72a00000000UL, 0x0000000000000000UL, // 1e13 * 2**84
+   0xb5e620f480000000UL, 0x0000000000000000UL, // 1e14 * 2**81
+   0xe35fa931a0000000UL, 0x0000000000000000UL, // 1e15 * 2**78
+   0x8e1bc9bf04000000UL, 0x0000000000000000UL, // 1e16 * 2**74
+   0xb1a2bc2ec5000000UL, 0x0000000000000000UL, // 1e17 * 2**71
+   0xde0b6b3a76400000UL, 0x0000000000000000UL, // 1e18 * 2**68
+   0x8ac7230489e80000UL, 0x0000000000000000UL, // 1e19 * 2**64
+   0xad78ebc5ac620000UL, 0x0000000000000000UL, // 1e20 * 2**61
+   0xd8d726b7177a8000UL, 0x0000000000000000UL, // 1e21 * 2**58
+   0x878678326eac9000UL, 0x0000000000000000UL, // 1e22 * 2**54
+   0xa968163f0a57b400UL, 0x0000000000000000UL, // 1e23 * 2**51
+   0xd3c21bcecceda100UL, 0x0000000000000000UL, // 1e24 * 2**48
+   0x84595161401484a0UL, 0x0000000000000000UL, // 1e25 * 2**44
+   0xa56fa5b99019a5c8UL, 0x0000000000000000UL, // 1e26 * 2**41
+   0xcecb8f27f4200f3aUL, 0x0000000000000000UL, // 1e27 * 2**38
+   0x813f3978f8940984UL, 0x4000000000000000UL, // 1e28 * 2**34
+   0xa18f07d736b90be5UL, 0x5000000000000000UL, // 1e29 * 2**31
+   0xc9f2c9cd04674edeUL, 0xa400000000000000UL, // 1e30 * 2**28
+   0xfc6f7c4045812296UL, 0x4d00000000000000UL, // 1e31 * 2**25
+   0x9dc5ada82b70b59dUL, 0xf020000000000000UL, // 1e32 * 2**21
+   0xc5371912364ce305UL, 0x6c28000000000000UL, // 1e33 * 2**18
+   0xf684df56c3e01bc6UL, 0xc732000000000000UL, // 1e34 * 2**15
+   0x9a130b963a6c115cUL, 0x3c7f400000000000UL, // 1e35 * 2**11
+   0xc097ce7bc90715b3UL, 0x4b9f100000000000UL, // 1e36 * 2**8
+   0xf0bdc21abb48db20UL, 0x1e86d40000000000UL, // 1e37 * 2**5
+   0x96769950b50d88f4UL, 0x1314448000000000UL, // 1e38 * 2**1
+   0xbc143fa4e250eb31UL, 0x17d955a000000000UL, // 1e39 * 2**-2
+   0xeb194f8e1ae525fdUL, 0x5dcfab0800000000UL, // 1e40 * 2**-5
+   0x92efd1b8d0cf37beUL, 0x5aa1cae500000000UL, // 1e41 * 2**-9
+   0xb7abc627050305adUL, 0xf14a3d9e40000000UL, // 1e42 * 2**-12
+   0xe596b7b0c643c719UL, 0x6d9ccd05d0000000UL, // 1e43 * 2**-15
+   0x8f7e32ce7bea5c6fUL, 0xe4820023a2000000UL, // 1e44 * 2**-19
+   0xb35dbf821ae4f38bUL, 0xdda2802c8a800000UL, // 1e45 * 2**-22
+   0xe0352f62a19e306eUL, 0xd50b2037ad200000UL, // 1e46 * 2**-25
+   0x8c213d9da502de45UL, 0x4526f422cc340000UL, // 1e47 * 2**-29
+   0xaf298d050e4395d6UL, 0x9670b12b7f410000UL, // 1e48 * 2**-32
+   0xdaf3f04651d47b4cUL, 0x3c0cdd765f114000UL, // 1e49 * 2**-35
+   0x88d8762bf324cd0fUL, 0xa5880a69fb6ac800UL, // 1e50 * 2**-39
+   0xab0e93b6efee0053UL, 0x8eea0d047a457a00UL, // 1e51 * 2**-42
+   0xd5d238a4abe98068UL, 0x72a4904598d6d880UL, // 1e52 * 2**-45
+   0x85a36366eb71f041UL, 0x47a6da2b7f864750UL, // 1e53 * 2**-49
+   0xa70c3c40a64e6c51UL, 0x999090b65f67d924UL, // 1e54 * 2**-52
+   0xd0cf4b50cfe20765UL, 0xfff4b4e3f741cf6dUL, // 1e55 * 2**-55
+   0x82818f1281ed449fUL, 0xbff8f10e7a8921a4UL, // 1e56 * 2**-59
+   0xa321f2d7226895c7UL, 0xaff72d52192b6a0dUL, // 1e57 * 2**-62
+   0xcbea6f8ceb02bb39UL, 0x9bf4f8a69f764490UL, // 1e58 * 2**-65
+   0xfee50b7025c36a08UL, 0x02f236d04753d5b4UL, // 1e59 * 2**-68
+   0x9f4f2726179a2245UL, 0x01d762422c946590UL, // 1e60 * 2**-72
+   0xc722f0ef9d80aad6UL, 0x424d3ad2b7b97ef5UL, // 1e61 * 2**-75
+   0xf8ebad2b84e0d58bUL, 0xd2e0898765a7deb2UL, // 1e62 * 2**-78
+   0x9b934c3b330c8577UL, 0x63cc55f49f88eb2fUL, // 1e63 * 2**-82
+   0xc2781f49ffcfa6d5UL, 0x3cbf6b71c76b25fbUL, // 1e64 * 2**-85
+   0xf316271c7fc3908aUL, 0x8bef464e3945ef7aUL, // 1e65 * 2**-88
+   0x97edd871cfda3a56UL, 0x97758bf0e3cbb5acUL, // 1e66 * 2**-92
+   0xbde94e8e43d0c8ecUL, 0x3d52eeed1cbea317UL, // 1e67 * 2**-95
+   0xed63a231d4c4fb27UL, 0x4ca7aaa863ee4bddUL, // 1e68 * 2**-98
+   0x945e455f24fb1cf8UL, 0x8fe8caa93e74ef6aUL, // 1e69 * 2**-102
+   0xb975d6b6ee39e436UL, 0xb3e2fd538e122b44UL, // 1e70 * 2**-105
+   0xe7d34c64a9c85d44UL, 0x60dbbca87196b616UL, // 1e71 * 2**-108
+   0x90e40fbeea1d3a4aUL, 0xbc8955e946fe31cdUL, // 1e72 * 2**-112
+   0xb51d13aea4a488ddUL, 0x6babab6398bdbe41UL, // 1e73 * 2**-115
+   0xe264589a4dcdab14UL, 0xc696963c7eed2dd1UL, // 1e74 * 2**-118
+   0x8d7eb76070a08aecUL, 0xfc1e1de5cf543ca2UL, // 1e75 * 2**-122
+   0xb0de65388cc8ada8UL, 0x3b25a55f43294bcbUL, // 1e76 * 2**-125
+   0xdd15fe86affad912UL, 0x49ef0eb713f39ebeUL, // 1e77 * 2**-128
+   0x8a2dbf142dfcc7abUL, 0x6e3569326c784337UL, // 1e78 * 2**-132
+   0xacb92ed9397bf996UL, 0x49c2c37f07965404UL, // 1e79 * 2**-135
+   0xd7e77a8f87daf7fbUL, 0xdc33745ec97be906UL, // 1e80 * 2**-138
+   0x86f0ac99b4e8dafdUL, 0x69a028bb3ded71a3UL, // 1e81 * 2**-142
+   0xa8acd7c0222311bcUL, 0xc40832ea0d68ce0cUL, // 1e82 * 2**-145
+   0xd2d80db02aabd62bUL, 0xf50a3fa490c30190UL, // 1e83 * 2**-148
+   0x83c7088e1aab65dbUL, 0x792667c6da79e0faUL, // 1e84 * 2**-152
+   0xa4b8cab1a1563f52UL, 0x577001b891185938UL, // 1e85 * 2**-155
+   0xcde6fd5e09abcf26UL, 0xed4c0226b55e6f86UL, // 1e86 * 2**-158
+   0x80b05e5ac60b6178UL, 0x544f8158315b05b4UL, // 1e87 * 2**-162
+   0xa0dc75f1778e39d6UL, 0x696361ae3db1c721UL, // 1e88 * 2**-165
+   0xc913936dd571c84cUL, 0x03bc3a19cd1e38e9UL, // 1e89 * 2**-168
+   0xfb5878494ace3a5fUL, 0x04ab48a04065c723UL, // 1e90 * 2**-171
+   0x9d174b2dcec0e47bUL, 0x62eb0d64283f9c76UL, // 1e91 * 2**-175
+   0xc45d1df942711d9aUL, 0x3ba5d0bd324f8394UL, // 1e92 * 2**-178
+   0xf5746577930d6500UL, 0xca8f44ec7ee36479UL, // 1e93 * 2**-181
+   0x9968bf6abbe85f20UL, 0x7e998b13cf4e1ecbUL, // 1e94 * 2**-185
+   0xbfc2ef456ae276e8UL, 0x9e3fedd8c321a67eUL, // 1e95 * 2**-188
+   0xefb3ab16c59b14a2UL, 0xc5cfe94ef3ea101eUL, // 1e96 * 2**-191
+   0x95d04aee3b80ece5UL, 0xbba1f1d158724a12UL, // 1e97 * 2**-195
+   0xbb445da9ca61281fUL, 0x2a8a6e45ae8edc97UL, // 1e98 * 2**-198
+   0xea1575143cf97226UL, 0xf52d09d71a3293bdUL, // 1e99 * 2**-201
+   0x924d692ca61be758UL, 0x593c2626705f9c56UL, // 1e100 * 2**-205
+   0xb6e0c377cfa2e12eUL, 0x6f8b2fb00c77836cUL, // 1e101 * 2**-208
+   0xe498f455c38b997aUL, 0x0b6dfb9c0f956447UL, // 1e102 * 2**-211
+   0x8edf98b59a373fecUL, 0x4724bd4189bd5eacUL, // 1e103 * 2**-215
+   0xb2977ee300c50fe7UL, 0x58edec91ec2cb657UL, // 1e104 * 2**-218
+   0xdf3d5e9bc0f653e1UL, 0x2f2967b66737e3edUL, // 1e105 * 2**-221
+   0x8b865b215899f46cUL, 0xbd79e0d20082ee74UL, // 1e106 * 2**-225
+   0xae67f1e9aec07187UL, 0xecd8590680a3aa11UL, // 1e107 * 2**-228
+   0xda01ee641a708de9UL, 0xe80e6f4820cc9495UL, // 1e108 * 2**-231
+   0x884134fe908658b2UL, 0x3109058d147fdcddUL, // 1e109 * 2**-235
+   0xaa51823e34a7eedeUL, 0xbd4b46f0599fd415UL, // 1e110 * 2**-238
+   0xd4e5e2cdc1d1ea96UL, 0x6c9e18ac7007c91aUL, // 1e111 * 2**-241
+   0x850fadc09923329eUL, 0x03e2cf6bc604ddb0UL, // 1e112 * 2**-245
+   0xa6539930bf6bff45UL, 0x84db8346b786151cUL, // 1e113 * 2**-248
+   0xcfe87f7cef46ff16UL, 0xe612641865679a63UL, // 1e114 * 2**-251
+   0x81f14fae158c5f6eUL, 0x4fcb7e8f3f60c07eUL, // 1e115 * 2**-255
+   0xa26da3999aef7749UL, 0xe3be5e330f38f09dUL, // 1e116 * 2**-258
+   0xcb090c8001ab551cUL, 0x5cadf5bfd3072cc5UL, // 1e117 * 2**-261
+   0xfdcb4fa002162a63UL, 0x73d9732fc7c8f7f6UL, // 1e118 * 2**-264
+   0x9e9f11c4014dda7eUL, 0x2867e7fddcdd9afaUL, // 1e119 * 2**-268
+   0xc646d63501a1511dUL, 0xb281e1fd541501b8UL, // 1e120 * 2**-271
+   0xf7d88bc24209a565UL, 0x1f225a7ca91a4226UL, // 1e121 * 2**-274
+   0x9ae757596946075fUL, 0x3375788de9b06958UL, // 1e122 * 2**-278
+   0xc1a12d2fc3978937UL, 0x0052d6b1641c83aeUL, // 1e123 * 2**-281
+   0xf209787bb47d6b84UL, 0xc0678c5dbd23a49aUL, // 1e124 * 2**-284
+   0x9745eb4d50ce6332UL, 0xf840b7ba963646e0UL, // 1e125 * 2**-288
+   0xbd176620a501fbffUL, 0xb650e5a93bc3d898UL, // 1e126 * 2**-291
+   0xec5d3fa8ce427affUL, 0xa3e51f138ab4cebeUL, // 1e127 * 2**-294
+   0x93ba47c980e98cdfUL, 0xc66f336c36b10137UL, // 1e128 * 2**-298
+   0xb8a8d9bbe123f017UL, 0xb80b0047445d4184UL, // 1e129 * 2**-301
+   0xe6d3102ad96cec1dUL, 0xa60dc059157491e5UL, // 1e130 * 2**-304
+   0x9043ea1ac7e41392UL, 0x87c89837ad68db2fUL, // 1e131 * 2**-308
+   0xb454e4a179dd1877UL, 0x29babe4598c311fbUL, // 1e132 * 2**-311
+   0xe16a1dc9d8545e94UL, 0xf4296dd6fef3d67aUL, // 1e133 * 2**-314
+   0x8ce2529e2734bb1dUL, 0x1899e4a65f58660cUL, // 1e134 * 2**-318
+   0xb01ae745b101e9e4UL, 0x5ec05dcff72e7f8fUL, // 1e135 * 2**-321
+   0xdc21a1171d42645dUL, 0x76707543f4fa1f73UL, // 1e136 * 2**-324
+   0x899504ae72497ebaUL, 0x6a06494a791c53a8UL, // 1e137 * 2**-328
+   0xabfa45da0edbde69UL, 0x0487db9d17636892UL, // 1e138 * 2**-331
+   0xd6f8d7509292d603UL, 0x45a9d2845d3c42b6UL, // 1e139 * 2**-334
+   0x865b86925b9bc5c2UL, 0x0b8a2392ba45a9b2UL, // 1e140 * 2**-338
+   0xa7f26836f282b732UL, 0x8e6cac7768d7141eUL, // 1e141 * 2**-341
+   0xd1ef0244af2364ffUL, 0x3207d795430cd926UL, // 1e142 * 2**-344
+   0x8335616aed761f1fUL, 0x7f44e6bd49e807b8UL, // 1e143 * 2**-348
+   0xa402b9c5a8d3a6e7UL, 0x5f16206c9c6209a6UL, // 1e144 * 2**-351
+   0xcd036837130890a1UL, 0x36dba887c37a8c0fUL, // 1e145 * 2**-354
+   0x802221226be55a64UL, 0xc2494954da2c9789UL, // 1e146 * 2**-358
+   0xa02aa96b06deb0fdUL, 0xf2db9baa10b7bd6cUL, // 1e147 * 2**-361
+   0xc83553c5c8965d3dUL, 0x6f92829494e5acc7UL, // 1e148 * 2**-364
+   0xfa42a8b73abbf48cUL, 0xcb772339ba1f17f9UL, // 1e149 * 2**-367
+   0x9c69a97284b578d7UL, 0xff2a760414536efbUL, // 1e150 * 2**-371
+   0xc38413cf25e2d70dUL, 0xfef5138519684abaUL, // 1e151 * 2**-374
+   0xf46518c2ef5b8cd1UL, 0x7eb258665fc25d69UL, // 1e152 * 2**-377
+   0x98bf2f79d5993802UL, 0xef2f773ffbd97a61UL, // 1e153 * 2**-381
+   0xbeeefb584aff8603UL, 0xaafb550ffacfd8faUL, // 1e154 * 2**-384
+   0xeeaaba2e5dbf6784UL, 0x95ba2a53f983cf38UL, // 1e155 * 2**-387
+   0x952ab45cfa97a0b2UL, 0xdd945a747bf26183UL, // 1e156 * 2**-391
+   0xba756174393d88dfUL, 0x94f971119aeef9e4UL, // 1e157 * 2**-394
+   0xe912b9d1478ceb17UL, 0x7a37cd5601aab85dUL, // 1e158 * 2**-397
+   0x91abb422ccb812eeUL, 0xac62e055c10ab33aUL, // 1e159 * 2**-401
+   0xb616a12b7fe617aaUL, 0x577b986b314d6009UL, // 1e160 * 2**-404
+   0xe39c49765fdf9d94UL, 0xed5a7e85fda0b80bUL, // 1e161 * 2**-407
+   0x8e41ade9fbebc27dUL, 0x14588f13be847307UL, // 1e162 * 2**-411
+   0xb1d219647ae6b31cUL, 0x596eb2d8ae258fc8UL, // 1e163 * 2**-414
+   0xde469fbd99a05fe3UL, 0x6fca5f8ed9aef3bbUL, // 1e164 * 2**-417
+   0x8aec23d680043beeUL, 0x25de7bb9480d5854UL, // 1e165 * 2**-421
+   0xada72ccc20054ae9UL, 0xaf561aa79a10ae6aUL, // 1e166 * 2**-424
+   0xd910f7ff28069da4UL, 0x1b2ba1518094da04UL, // 1e167 * 2**-427
+   0x87aa9aff79042286UL, 0x90fb44d2f05d0842UL, // 1e168 * 2**-431
+   0xa99541bf57452b28UL, 0x353a1607ac744a53UL, // 1e169 * 2**-434
+   0xd3fa922f2d1675f2UL, 0x42889b8997915ce8UL, // 1e170 * 2**-437
+   0x847c9b5d7c2e09b7UL, 0x69956135febada11UL, // 1e171 * 2**-441
+   0xa59bc234db398c25UL, 0x43fab9837e699095UL, // 1e172 * 2**-444
+   0xcf02b2c21207ef2eUL, 0x94f967e45e03f4bbUL, // 1e173 * 2**-447
+   0x8161afb94b44f57dUL, 0x1d1be0eebac278f5UL, // 1e174 * 2**-451
+   0xa1ba1ba79e1632dcUL, 0x6462d92a69731732UL, // 1e175 * 2**-454
+   0xca28a291859bbf93UL, 0x7d7b8f7503cfdcfeUL, // 1e176 * 2**-457
+   0xfcb2cb35e702af78UL, 0x5cda735244c3d43eUL, // 1e177 * 2**-460
+   0x9defbf01b061adabUL, 0x3a0888136afa64a7UL, // 1e178 * 2**-464
+   0xc56baec21c7a1916UL, 0x088aaa1845b8fdd0UL, // 1e179 * 2**-467
+   0xf6c69a72a3989f5bUL, 0x8aad549e57273d45UL, // 1e180 * 2**-470
+   0x9a3c2087a63f6399UL, 0x36ac54e2f678864bUL, // 1e181 * 2**-474
+   0xc0cb28a98fcf3c7fUL, 0x84576a1bb416a7ddUL, // 1e182 * 2**-477
+   0xf0fdf2d3f3c30b9fUL, 0x656d44a2a11c51d5UL, // 1e183 * 2**-480
+   0x969eb7c47859e743UL, 0x9f644ae5a4b1b325UL, // 1e184 * 2**-484
+   0xbc4665b596706114UL, 0x873d5d9f0dde1feeUL, // 1e185 * 2**-487
+   0xeb57ff22fc0c7959UL, 0xa90cb506d155a7eaUL, // 1e186 * 2**-490
+   0x9316ff75dd87cbd8UL, 0x09a7f12442d588f2UL, // 1e187 * 2**-494
+   0xb7dcbf5354e9beceUL, 0x0c11ed6d538aeb2fUL, // 1e188 * 2**-497
+   0xe5d3ef282a242e81UL, 0x8f1668c8a86da5faUL, // 1e189 * 2**-500
+   0x8fa475791a569d10UL, 0xf96e017d694487bcUL, // 1e190 * 2**-504
+   0xb38d92d760ec4455UL, 0x37c981dcc395a9acUL, // 1e191 * 2**-507
+   0xe070f78d3927556aUL, 0x85bbe253f47b1417UL, // 1e192 * 2**-510
+   0x8c469ab843b89562UL, 0x93956d7478ccec8eUL, // 1e193 * 2**-514
+   0xaf58416654a6babbUL, 0x387ac8d1970027b2UL, // 1e194 * 2**-517
+   0xdb2e51bfe9d0696aUL, 0x06997b05fcc0319eUL, // 1e195 * 2**-520
+   0x88fcf317f22241e2UL, 0x441fece3bdf81f03UL, // 1e196 * 2**-524
+   0xab3c2fddeeaad25aUL, 0xd527e81cad7626c3UL, // 1e197 * 2**-527
+   0xd60b3bd56a5586f1UL, 0x8a71e223d8d3b074UL, // 1e198 * 2**-530
+   0x85c7056562757456UL, 0xf6872d5667844e49UL, // 1e199 * 2**-534
+   0xa738c6bebb12d16cUL, 0xb428f8ac016561dbUL, // 1e200 * 2**-537
+   0xd106f86e69d785c7UL, 0xe13336d701beba52UL, // 1e201 * 2**-540
+   0x82a45b450226b39cUL, 0xecc0024661173473UL, // 1e202 * 2**-544
+   0xa34d721642b06084UL, 0x27f002d7f95d0190UL, // 1e203 * 2**-547
+   0xcc20ce9bd35c78a5UL, 0x31ec038df7b441f4UL, // 1e204 * 2**-550
+   0xff290242c83396ceUL, 0x7e67047175a15271UL, // 1e205 * 2**-553
+   0x9f79a169bd203e41UL, 0x0f0062c6e984d386UL, // 1e206 * 2**-557
+   0xc75809c42c684dd1UL, 0x52c07b78a3e60868UL, // 1e207 * 2**-560
+   0xf92e0c3537826145UL, 0xa7709a56ccdf8a82UL, // 1e208 * 2**-563
+   0x9bbcc7a142b17ccbUL, 0x88a66076400bb691UL, // 1e209 * 2**-567
+   0xc2abf989935ddbfeUL, 0x6acff893d00ea435UL, // 1e210 * 2**-570
+   0xf356f7ebf83552feUL, 0x0583f6b8c4124d43UL, // 1e211 * 2**-573
+   0x98165af37b2153deUL, 0xc3727a337a8b704aUL, // 1e212 * 2**-577
+   0xbe1bf1b059e9a8d6UL, 0x744f18c0592e4c5cUL, // 1e213 * 2**-580
+   0xeda2ee1c7064130cUL, 0x1162def06f79df73UL, // 1e214 * 2**-583
+   0x9485d4d1c63e8be7UL, 0x8addcb5645ac2ba8UL, // 1e215 * 2**-587
+   0xb9a74a0637ce2ee1UL, 0x6d953e2bd7173692UL, // 1e216 * 2**-590
+   0xe8111c87c5c1ba99UL, 0xc8fa8db6ccdd0437UL, // 1e217 * 2**-593
+   0x910ab1d4db9914a0UL, 0x1d9c9892400a22a2UL, // 1e218 * 2**-597
+   0xb54d5e4a127f59c8UL, 0x2503beb6d00cab4bUL, // 1e219 * 2**-600
+   0xe2a0b5dc971f303aUL, 0x2e44ae64840fd61dUL, // 1e220 * 2**-603
+   0x8da471a9de737e24UL, 0x5ceaecfed289e5d2UL, // 1e221 * 2**-607
+   0xb10d8e1456105dadUL, 0x7425a83e872c5f47UL, // 1e222 * 2**-610
+   0xdd50f1996b947518UL, 0xd12f124e28f77719UL, // 1e223 * 2**-613
+   0x8a5296ffe33cc92fUL, 0x82bd6b70d99aaa6fUL, // 1e224 * 2**-617
+   0xace73cbfdc0bfb7bUL, 0x636cc64d1001550bUL, // 1e225 * 2**-620
+   0xd8210befd30efa5aUL, 0x3c47f7e05401aa4eUL, // 1e226 * 2**-623
+   0x8714a775e3e95c78UL, 0x65acfaec34810a71UL, // 1e227 * 2**-627
+   0xa8d9d1535ce3b396UL, 0x7f1839a741a14d0dUL, // 1e228 * 2**-630
+   0xd31045a8341ca07cUL, 0x1ede48111209a050UL, // 1e229 * 2**-633
+   0x83ea2b892091e44dUL, 0x934aed0aab460432UL, // 1e230 * 2**-637
+   0xa4e4b66b68b65d60UL, 0xf81da84d5617853fUL, // 1e231 * 2**-640
+   0xce1de40642e3f4b9UL, 0x36251260ab9d668eUL, // 1e232 * 2**-643
+   0x80d2ae83e9ce78f3UL, 0xc1d72b7c6b426019UL, // 1e233 * 2**-647
+   0xa1075a24e4421730UL, 0xb24cf65b8612f81fUL, // 1e234 * 2**-650
+   0xc94930ae1d529cfcUL, 0xdee033f26797b627UL, // 1e235 * 2**-653
+   0xfb9b7cd9a4a7443cUL, 0x169840ef017da3b1UL, // 1e236 * 2**-656
+   0x9d412e0806e88aa5UL, 0x8e1f289560ee864eUL, // 1e237 * 2**-660
+   0xc491798a08a2ad4eUL, 0xf1a6f2bab92a27e2UL, // 1e238 * 2**-663
+   0xf5b5d7ec8acb58a2UL, 0xae10af696774b1dbUL, // 1e239 * 2**-666
+   0x9991a6f3d6bf1765UL, 0xacca6da1e0a8ef29UL, // 1e240 * 2**-670
+   0xbff610b0cc6edd3fUL, 0x17fd090a58d32af3UL, // 1e241 * 2**-673
+   0xeff394dcff8a948eUL, 0xddfc4b4cef07f5b0UL, // 1e242 * 2**-676
+   0x95f83d0a1fb69cd9UL, 0x4abdaf101564f98eUL, // 1e243 * 2**-680
+   0xbb764c4ca7a4440fUL, 0x9d6d1ad41abe37f1UL, // 1e244 * 2**-683
+   0xea53df5fd18d5513UL, 0x84c86189216dc5edUL, // 1e245 * 2**-686
+   0x92746b9be2f8552cUL, 0x32fd3cf5b4e49bb4UL, // 1e246 * 2**-690
+   0xb7118682dbb66a77UL, 0x3fbc8c33221dc2a1UL, // 1e247 * 2**-693
+   0xe4d5e82392a40515UL, 0x0fabaf3feaa5334aUL, // 1e248 * 2**-696
+   0x8f05b1163ba6832dUL, 0x29cb4d87f2a7400eUL, // 1e249 * 2**-700
+   0xb2c71d5bca9023f8UL, 0x743e20e9ef511012UL, // 1e250 * 2**-703
+   0xdf78e4b2bd342cf6UL, 0x914da9246b255416UL, // 1e251 * 2**-706
+   0x8bab8eefb6409c1aUL, 0x1ad089b6c2f7548eUL, // 1e252 * 2**-710
+   0xae9672aba3d0c320UL, 0xa184ac2473b529b1UL, // 1e253 * 2**-713
+   0xda3c0f568cc4f3e8UL, 0xc9e5d72d90a2741eUL, // 1e254 * 2**-716
+   0x8865899617fb1871UL, 0x7e2fa67c7a658892UL, // 1e255 * 2**-720
+   0xaa7eebfb9df9de8dUL, 0xddbb901b98feeab7UL, // 1e256 * 2**-723
+   0xd51ea6fa85785631UL, 0x552a74227f3ea565UL, // 1e257 * 2**-726
+   0x8533285c936b35deUL, 0xd53a88958f87275fUL, // 1e258 * 2**-730
+   0xa67ff273b8460356UL, 0x8a892abaf368f137UL, // 1e259 * 2**-733
+   0xd01fef10a657842cUL, 0x2d2b7569b0432d85UL, // 1e260 * 2**-736
+   0x8213f56a67f6b29bUL, 0x9c3b29620e29fc73UL, // 1e261 * 2**-740
+   0xa298f2c501f45f42UL, 0x8349f3ba91b47b8fUL, // 1e262 * 2**-743
+   0xcb3f2f7642717713UL, 0x241c70a936219a73UL, // 1e263 * 2**-746
+   0xfe0efb53d30dd4d7UL, 0xed238cd383aa0110UL, // 1e264 * 2**-749
+   0x9ec95d1463e8a506UL, 0xf4363804324a40aaUL, // 1e265 * 2**-753
+   0xc67bb4597ce2ce48UL, 0xb143c6053edcd0d5UL, // 1e266 * 2**-756
+   0xf81aa16fdc1b81daUL, 0xdd94b7868e94050aUL, // 1e267 * 2**-759
+   0x9b10a4e5e9913128UL, 0xca7cf2b4191c8326UL, // 1e268 * 2**-763
+   0xc1d4ce1f63f57d72UL, 0xfd1c2f611f63a3f0UL, // 1e269 * 2**-766
+   0xf24a01a73cf2dccfUL, 0xbc633b39673c8cecUL, // 1e270 * 2**-769
+   0x976e41088617ca01UL, 0xd5be0503e085d813UL, // 1e271 * 2**-773
+   0xbd49d14aa79dbc82UL, 0x4b2d8644d8a74e18UL, // 1e272 * 2**-776
+   0xec9c459d51852ba2UL, 0xddf8e7d60ed1219eUL, // 1e273 * 2**-779
+   0x93e1ab8252f33b45UL, 0xcabb90e5c942b503UL, // 1e274 * 2**-783
+   0xb8da1662e7b00a17UL, 0x3d6a751f3b936243UL, // 1e275 * 2**-786
+   0xe7109bfba19c0c9dUL, 0x0cc512670a783ad4UL, // 1e276 * 2**-789
+   0x906a617d450187e2UL, 0x27fb2b80668b24c5UL, // 1e277 * 2**-793
+   0xb484f9dc9641e9daUL, 0xb1f9f660802dedf6UL, // 1e278 * 2**-796
+   0xe1a63853bbd26451UL, 0x5e7873f8a0396973UL, // 1e279 * 2**-799
+   0x8d07e33455637eb2UL, 0xdb0b487b6423e1e8UL, // 1e280 * 2**-803
+   0xb049dc016abc5e5fUL, 0x91ce1a9a3d2cda62UL, // 1e281 * 2**-806
+   0xdc5c5301c56b75f7UL, 0x7641a140cc7810fbUL, // 1e282 * 2**-809
+   0x89b9b3e11b6329baUL, 0xa9e904c87fcb0a9dUL, // 1e283 * 2**-813
+   0xac2820d9623bf429UL, 0x546345fa9fbdcd44UL, // 1e284 * 2**-816
+   0xd732290fbacaf133UL, 0xa97c177947ad4095UL, // 1e285 * 2**-819
+   0x867f59a9d4bed6c0UL, 0x49ed8eabcccc485dUL, // 1e286 * 2**-823
+   0xa81f301449ee8c70UL, 0x5c68f256bfff5a74UL, // 1e287 * 2**-826
+   0xd226fc195c6a2f8cUL, 0x73832eec6fff3111UL, // 1e288 * 2**-829
+   0x83585d8fd9c25db7UL, 0xc831fd53c5ff7eabUL, // 1e289 * 2**-833
+   0xa42e74f3d032f525UL, 0xba3e7ca8b77f5e55UL, // 1e290 * 2**-836
+   0xcd3a1230c43fb26fUL, 0x28ce1bd2e55f35ebUL, // 1e291 * 2**-839
+   0x80444b5e7aa7cf85UL, 0x7980d163cf5b81b3UL, // 1e292 * 2**-843
+   0xa0555e361951c366UL, 0xd7e105bcc332621fUL, // 1e293 * 2**-846
+   0xc86ab5c39fa63440UL, 0x8dd9472bf3fefaa7UL, // 1e294 * 2**-849
+   0xfa856334878fc150UL, 0xb14f98f6f0feb951UL, // 1e295 * 2**-852
+   0x9c935e00d4b9d8d2UL, 0x6ed1bf9a569f33d3UL, // 1e296 * 2**-856
+   0xc3b8358109e84f07UL, 0x0a862f80ec4700c8UL, // 1e297 * 2**-859
+   0xf4a642e14c6262c8UL, 0xcd27bb612758c0faUL, // 1e298 * 2**-862
+   0x98e7e9cccfbd7dbdUL, 0x8038d51cb897789cUL, // 1e299 * 2**-866
+   0xbf21e44003acdd2cUL, 0xe0470a63e6bd56c3UL, // 1e300 * 2**-869
+   0xeeea5d5004981478UL, 0x1858ccfce06cac74UL, // 1e301 * 2**-872
+   0x95527a5202df0ccbUL, 0x0f37801e0c43ebc8UL, // 1e302 * 2**-876
+   0xbaa718e68396cffdUL, 0xd30560258f54e6baUL, // 1e303 * 2**-879
+   0xe950df20247c83fdUL, 0x47c6b82ef32a2069UL, // 1e304 * 2**-882
+   0x91d28b7416cdd27eUL, 0x4cdc331d57fa5441UL, // 1e305 * 2**-886
+   0xb6472e511c81471dUL, 0xe0133fe4adf8e952UL, // 1e306 * 2**-889
+   0xe3d8f9e563a198e5UL, 0x58180fddd97723a6UL, // 1e307 * 2**-892
+   0x8e679c2f5e44ff8fUL, 0x570f09eaa7ea7648UL, // 1e308 * 2**-896
+   0xb201833b35d63f73UL, 0x2cd2cc6551e513daUL, // 1e309 * 2**-899
+   0xde81e40a034bcf4fUL, 0xf8077f7ea65e58d1UL, // 1e310 * 2**-902
+   0x8b112e86420f6191UL, 0xfb04afaf27faf782UL, // 1e311 * 2**-906
+   0xadd57a27d29339f6UL, 0x79c5db9af1f9b563UL, // 1e312 * 2**-909
+   0xd94ad8b1c7380874UL, 0x18375281ae7822bcUL, // 1e313 * 2**-912
+   0x87cec76f1c830548UL, 0x8f2293910d0b15b5UL, // 1e314 * 2**-916
+   0xa9c2794ae3a3c69aUL, 0xb2eb3875504ddb22UL, // 1e315 * 2**-919
+   0xd433179d9c8cb841UL, 0x5fa60692a46151ebUL, // 1e316 * 2**-922
+   0x849feec281d7f328UL, 0xdbc7c41ba6bcd333UL, // 1e317 * 2**-926
+   0xa5c7ea73224deff3UL, 0x12b9b522906c0800UL, // 1e318 * 2**-929
+   0xcf39e50feae16befUL, 0xd768226b34870a00UL, // 1e319 * 2**-932
+   0x81842f29f2cce375UL, 0xe6a1158300d46640UL, // 1e320 * 2**-936
+   0xa1e53af46f801c53UL, 0x60495ae3c1097fd0UL, // 1e321 * 2**-939
+   0xca5e89b18b602368UL, 0x385bb19cb14bdfc4UL, // 1e322 * 2**-942
+   0xfcf62c1dee382c42UL, 0x46729e03dd9ed7b5UL, // 1e323 * 2**-945
+   0x9e19db92b4e31ba9UL, 0x6c07a2c26a8346d1UL, // 1e324 * 2**-949
+   0xc5a05277621be293UL, 0xc7098b7305241885UL, // 1e325 * 2**-952
+   0xf70867153aa2db38UL, 0xb8cbee4fc66d1ea7UL, // 1e326 * 2**-955
+   0x9a65406d44a5c903UL, 0x737f74f1dc043328UL, // 1e327 * 2**-959
+   0xc0fe908895cf3b44UL, 0x505f522e53053ff2UL, // 1e328 * 2**-962
+   0xf13e34aabb430a15UL, 0x647726b9e7c68fefUL, // 1e329 * 2**-965
+   0x96c6e0eab509e64dUL, 0x5eca783430dc19f5UL, // 1e330 * 2**-969
+   0xbc789925624c5fe0UL, 0xb67d16413d132072UL, // 1e331 * 2**-972
+   0xeb96bf6ebadf77d8UL, 0xe41c5bd18c57e88fUL, // 1e332 * 2**-975
+   0x933e37a534cbaae7UL, 0x8e91b962f7b6f159UL, // 1e333 * 2**-979
+   0xb80dc58e81fe95a1UL, 0x723627bbb5a4adb0UL, // 1e334 * 2**-982
+   0xe61136f2227e3b09UL, 0xcec3b1aaa30dd91cUL, // 1e335 * 2**-985
+   0x8fcac257558ee4e6UL, 0x213a4f0aa5e8a7b1UL, // 1e336 * 2**-989
+   0xb3bd72ed2af29e1fUL, 0xa988e2cd4f62d19dUL, // 1e337 * 2**-992
+   0xe0accfa875af45a7UL, 0x93eb1b80a33b8605UL, // 1e338 * 2**-995
+   0x8c6c01c9498d8b88UL, 0xbc72f130660533c3UL, // 1e339 * 2**-999
+   0xaf87023b9bf0ee6aUL, 0xeb8fad7c7f8680b4UL, // 1e340 * 2**-1002
+   0xdb68c2ca82ed2a05UL, 0xa67398db9f6820e1UL, // 1e341 * 2**-1005
+   0x892179be91d43a43UL, 0x88083f8943a1148cUL, // 1e342 * 2**-1009
+   0xab69d82e364948d4UL, 0x6a0a4f6b948959b0UL, // 1e343 * 2**-1012
+   0xd6444e39c3db9b09UL, 0x848ce34679abb01cUL, // 1e344 * 2**-1015
+   0x85eab0e41a6940e5UL, 0xf2d80e0c0c0b4e11UL, // 1e345 * 2**-1019
+   0xa7655d1d2103911fUL, 0x6f8e118f0f0e2195UL, // 1e346 * 2**-1022
+   0xd13eb46469447567UL, 0x4b7195f2d2d1a9fbUL, // 1e347 * 2**-1025
+]
diff --git a/internal/strconv/strconv_eisel_lemire_wbtest.mbt b/internal/strconv/strconv_eisel_lemire_wbtest.mbt
new file mode 100644
index 0000000000..746749641e
--- /dev/null
+++ b/internal/strconv/strconv_eisel_lemire_wbtest.mbt
@@ -0,0 +1,71 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+test "Eisel-Lemire agrees with exact Decimal conversion" {
+  let cases : FixedArray[(String, UInt64, Int64, Bool)] = [
+    ("-65.613616999999977", 65613616999999977UL, -15L, true),
+    ("43.420273000000009", 43420273000000009UL, -15L, false),
+    ("-65.619720000000029", 65619720000000029UL, -15L, true),
+    ("43.418052999999986", 43418052999999986UL, -15L, false),
+    ("9.999999999999999999", 9999999999999999999UL, -18L, false),
+    ("1.234567890123456789e100", 1234567890123456789UL, 82L, false),
+    ("1e-307", 1UL, -307L, false),
+    ("1e308", 1UL, 308L, false),
+  ]
+  for case in cases {
+    let (input, mantissa, exponent, negative) = case
+    let fast = try_eisel_lemire64(mantissa, exponent, negative)
+    if fast.is_nan() {
+      fail("Eisel-Lemire unexpectedly rejected \{input}")
+    }
+    let exact = parse_decimal_priv(input).to_double_priv()
+    @test.assert_eq(fast.reinterpret_as_uint64(), exact.reinterpret_as_uint64())
+  }
+}
+
+///|
+test "Eisel-Lemire preserves negative zero" {
+  inspect(
+    try_eisel_lemire64(0UL, 0L, true).reinterpret_as_uint64(),
+    content="9223372036854775808",
+  )
+}
+
+///|
+test "Eisel-Lemire rejects values reserved for the exact fallback" {
+  inspect(try_eisel_lemire64(1UL, -348L, false).is_nan(), content="true")
+  inspect(try_eisel_lemire64(1UL, 348L, false).is_nan(), content="true")
+}
+
+///|
+test "Eisel-Lemire differential exponent coverage" {
+  let mantissas : FixedArray[UInt64] = [
+    1UL, 10UL, 9007199254740993UL, 65613616999999977UL, 1234567890123456789UL, 9223372036854775807UL,
+    9999999999999999999UL,
+  ]
+  for mantissa in mantissas {
+    for exponent in -340..<=340 {
+      let fast = try_eisel_lemire64(mantissa, exponent.to_int64(), false)
+      if !fast.is_nan() {
+        let input = "\{mantissa}e\{exponent}"
+        let exact = parse_decimal_priv(input).to_double_priv()
+        @test.assert_eq(
+          fast.reinterpret_as_uint64(),
+          exact.reinterpret_as_uint64(),
+        )
+      }
+    }
+  }
+}
diff --git a/internal/strconv/strconv_number.mbt b/internal/strconv/strconv_number.mbt
index 30440754ce..c0fb4d7ee8 100644
--- a/internal/strconv/strconv_number.mbt
+++ b/internal/strconv/strconv_number.mbt
@@ -130,7 +130,7 @@ fn parse_number(s : StringView) -> Number? raise {
 
   // handle uncommon case with many digits
   if n_digits <= 19 {
-    return Some({ exponent, mantissa, negative, many_digits: false })
+    return Some({ exponent, mantissa, negative, many_digits: false, })
   }
   n_digits -= 19
   let mut many_digits = false
@@ -162,7 +162,7 @@ fn parse_number(s : StringView) -> Number? raise {
     }).to_int64()
     exponent += exp_number
   } // add back the explicit part
-  Some({ exponent, mantissa, negative, many_digits })
+  Some({ exponent, mantissa, negative, many_digits, })
 }
 
 ///|
@@ -186,7 +186,7 @@ fn parse_inf_nan(rest : StringView) -> Double raise {
 
 ///|
 /// Returns None if the multiplication might overflow (there are some false-negative corner cases).
-/// Otherwise, returns Some(m), where m = self * b.
+/// Otherwise, returns Some(m), where m = a * b.
 /// WARNING: Note this function is only used internally in the strconv module,
 /// the current implementation is not completely safe against overflows.
 fn checked_mul(a : UInt64, b : UInt64) -> UInt64? {
diff --git a/internal/strconv/strconv_string_view.mbt b/internal/strconv/strconv_string_view.mbt
index b874ba8997..99519a1af5 100644
--- a/internal/strconv/strconv_string_view.mbt
+++ b/internal/strconv/strconv_string_view.mbt
@@ -13,7 +13,7 @@
 // limitations under the License.
 
 ///|
-/// Returns the accumulated value, the slice left, and the number of digits consumed.
+/// Returns the slice left, the accumulated value, and the number of digits consumed.
 /// It ignores underscore and stops when a non-digit character is found.
 fn[T] StringView::fold_digits(
   self : Self,
diff --git a/json/README.mbt.md b/json/README.mbt.md
index e33bf48baf..b34fc8b30e 100644
--- a/json/README.mbt.md
+++ b/json/README.mbt.md
@@ -51,6 +51,35 @@ test "parse and validate jsons" {
 }
 ```
 
+#### What may appear inside a string
+
+Every string in a parsed document is well-formed Unicode, so each `\uXXXX`
+escape must denote a Unicode scalar value on its own or be one half of a
+correctly ordered surrogate pair. An escaped leading surrogate must be
+followed immediately by an escaped trailing surrogate, and the pair decodes
+to the single character it stands for; an escape that cannot pair up is a
+parse error, reported at the backslash that opens it.
+
+```mbt check
+///|
+test "surrogate escapes" {
+  // A surrogate pair decodes to the one character it denotes.
+  assert_true(@json.parse("\"\\uD83D\\uDE00\"") == Json::string("😀"))
+  // An escape that cannot pair up is rejected rather than producing a
+  // string that is not well-formed Unicode.
+  assert_false(@json.valid("\"\\uD800\""))
+}
+```
+
+RFC 8259 §9 leaves what a string may contain to the implementation, and this
+is where MoonBit draws that line: a `String` is required to be well-formed,
+so the alternatives would be to hand one back that is not, or to substitute
+U+FFFD and lose the difference between two distinct keys. Note that
+`JSON.stringify` in JavaScript does emit lone surrogates this way, so a
+document JavaScript and Python accept can be rejected here — as it is by
+Rust's serde_json when parsing into `String` or `Value`; Go's
+`encoding/json` substitutes U+FFFD instead.
+
 ### Object Navigation
 
 ```mbt check
diff --git a/json/derive_json_test.mbt b/json/derive_json_test.mbt
index b537eef34a..ad114d588c 100644
--- a/json/derive_json_test.mbt
+++ b/json/derive_json_test.mbt
@@ -21,7 +21,7 @@ priv struct Hello[A, B] {
 
 ///|
 test {
-  let v = Hello::{ fieldA: 3, fieldB: 2, data: { "a": 3 } }
+  let v = Hello::{ fieldA: 3, fieldB: 2, data: { "a": 3 }, }
   let vj = ToJson::to_json(v)
   let v0 : Hello[Int, Int] = @json.from_json(vj)
   debug_inspect(
@@ -49,7 +49,7 @@ priv struct Hello3[A, B] {
 
 ///|
 test {
-  let h = Hello::{ fieldA: 1, fieldB: "hello", data: { "a": 1, "b": 2 } }
+  let h = Hello::{ fieldA: 1, fieldB: "hello", data: { "a": 1, "b": 2 }, }
   let j = ToJson::to_json(h)
   debug_inspect(
     j,
@@ -63,7 +63,7 @@ test {
       #|)
     ),
   )
-  let h3 = Hello3::{ fieldAX: 1, fieldB: "hello", data: { 1: 1, 2: 2 } }
+  let h3 = Hello3::{ fieldAX: 1, fieldB: "hello", data: { 1: 1, 2: 2 }, }
   let j3 = ToJson::to_json(h3)
   debug_inspect(
     j3,
diff --git a/json/escape_quickcheck_wbtest.mbt b/json/escape_quickcheck_wbtest.mbt
new file mode 100644
index 0000000000..7046ab1f8b
--- /dev/null
+++ b/json/escape_quickcheck_wbtest.mbt
@@ -0,0 +1,164 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Property-based tests for the `Json::stringify` escape fast path: the SIMD
+// `need_escape` must agree with the scalar reference, and `escape` must agree
+// with a straightforward per-code-unit model and with the real parser,
+// including at SIMD block boundaries and on surrogate halves.
+
+///|
+/// Builds a string whose code units are biased toward everything the escaper
+/// branches on: quotes, backslashes, slashes, control characters, both sides
+/// of the 0x20 boundary, and surrogate halves (whose high bit set exercises
+/// the unsigned SIMD comparisons). With `allow_surrogates=false` the
+/// surrogate range is remapped so the string is well-formed UTF-16.
+fn adversarial_string(seeds : Array[Int], allow_surrogates~ : Bool) -> String {
+  let buf = StringBuilder(size_hint=seeds.length())
+  for seed in seeds {
+    let code : UInt16 = match seed & 0xF {
+      0 => '"'
+      1 => '\\'
+      2 => '/'
+      3 => '\n'
+      4 => 0x1F
+      5 => ' '
+      6 => 0x0C
+      7 => 0xD800
+      8 => 0xDFFF
+      9 => 0xFFFF
+      10 => 'a'
+      _ => ((seed >> 4) & 0xFFFF).to_uint16()
+    }
+    let code = if !allow_surrogates && code is (0xD800..=0xDFFF) {
+      code ^ 0x2000
+    } else {
+      code
+    }
+    buf.write_char(code.unsafe_to_char())
+  }
+  buf.to_string()
+}
+
+///|
+/// Per-code-unit model of `escape`, written directly from the JSON string
+/// grammar with no fast path and no SIMD.
+fn model_escape(str : String, escape_slash : Bool) -> String {
+  let buf = StringBuilder(size_hint=str.length())
+  for code in str.code_units() {
+    match code.to_int() {
+      0x22 => buf.write_string("\\\"")
+      0x5C => buf.write_string("\\\\")
+      0x2F => buf.write_string(if escape_slash { "\\/" } else { "/" })
+      0x08 => buf.write_string("\\b")
+      0x09 => buf.write_string("\\t")
+      0x0A => buf.write_string("\\n")
+      0x0C => buf.write_string("\\f")
+      0x0D => buf.write_string("\\r")
+      c =>
+        if c < 0x20 {
+          buf.write_string("\\u00")
+          buf.write_string(c.to_byte().to_hex())
+        } else {
+          buf.write_char(code.unsafe_to_char())
+        }
+    }
+  }
+  buf.to_string()
+}
+
+///|
+test "quickcheck: need_escape agrees with the scalar reference" {
+  @quickcheck.check(count=300, (input : (Array[Int], Bool)) => {
+    let (seeds, escape_slash) = input
+    let str = adversarial_string(seeds, allow_surrogates=true)
+    need_escape(str, escape_slash) ==
+    need_escape_scalar(str, escape_slash, 0, str.length())
+  })
+  // Also over ordinary ASCII-biased Unicode strings, which cover other
+  // lengths and multi-code-unit characters.
+  @quickcheck.check(count=300, (input : (String, Bool)) => {
+    let (str, escape_slash) = input
+    need_escape(str, escape_slash) ==
+    need_escape_scalar(str, escape_slash, 0, str.length())
+  })
+}
+
+///|
+test "quickcheck: write_escaped matches the per-code-unit model" {
+  fn actual(str : String, escape_slash : Bool) -> String {
+    let buf = StringBuilder()
+    write_escaped(buf, str, escape_slash~)
+    buf.to_string()
+  }
+  @quickcheck.check(count=300, (input : (Array[Int], Bool)) => {
+    let (seeds, escape_slash) = input
+    let str = adversarial_string(seeds, allow_surrogates=true)
+    guard actual(str, escape_slash) == model_escape(str, escape_slash) else {
+      return false
+    }
+    (actual(str, escape_slash) == str) ==
+    !need_escape_scalar(str, escape_slash, 0, str.length())
+  })
+  @quickcheck.check(count=300, (input : (String, Bool)) => {
+    let (str, escape_slash) = input
+    actual(str, escape_slash) == model_escape(str, escape_slash)
+  })
+}
+
+///|
+/// Roundtrip through the real parser, whose string lexer is an independent
+/// implementation of the same grammar. Surrogate halves are excluded because
+/// well-formed MoonBit strings contain no unpaired surrogates.
+test "quickcheck: stringify/parse roundtrip on adversarial strings" {
+  @quickcheck.check(count=300, (input : (Array[Int], Bool)) => {
+    let (seeds, escape_slash) = input
+    let json = Json::string(adversarial_string(seeds, allow_surrogates=false))
+    parse(json.stringify(escape_slash~)) == json
+  })
+}
+
+///|
+/// Exhaustively places each escapable code unit at every position of an
+/// otherwise clean string, for every length spanning several 8-unit SIMD
+/// blocks, so block starts, block interiors, and the scalar tail are all
+/// covered for both `escape_slash` values.
+test "need_escape boundary sweep" {
+  let specials : Array[UInt16] = ['"', '\\', '\n', 0x00, 0x1F]
+  fn place(len : Int, pos : Int, special : UInt16) -> String {
+    let buf = StringBuilder(size_hint=len)
+    for i in 0.. ParseContext {
-  { offset: 0, input, end_offset: input.length() }
+  { offset: 0, input, end_offset: input.length(), }
 }
 
 ///|
diff --git a/json/json.mbt b/json/json.mbt
index 82081eadec..d0af0bab17 100644
--- a/json/json.mbt
+++ b/json/json.mbt
@@ -86,24 +86,30 @@ pub fn Json::value(self : Json, key : String) -> Json? {
 }
 
 ///|
-fn indent_str(level : Int, indent : Int) -> String {
-  if indent == 0 {
-    ""
-  } else {
-    let spaces = indent * level
-    match spaces {
-      0 => "\n"
-      1 => "\n "
-      2 => "\n  "
-      3 => "\n   "
-      4 => "\n    "
-      5 => "\n     "
-      6 => "\n      "
-      7 => "\n       "
-      8 => "\n        "
-      _ => "\n" + " ".repeat(spaces)
+#inline
+fn write_indent(
+  buf : StringBuilder,
+  cache : Array[String],
+  level : Int,
+  indent : Int,
+) -> Unit {
+  // Each level is the previous one plus a single indentation unit. Building a
+  // level is still O(indent * level) — concatenation copies `last` — but it
+  // replaces a fresh `repeat(indent * level)` with a `repeat(indent)`, roughly
+  // halving the bytes written per level. It also never computes the product
+  // `indent * level`, which overflows `Int` to a negative value for a large
+  // `indent` and is then rejected by `String::repeat`.
+  //
+  // The unit is built here rather than once per `stringify` call so that a
+  // document emitting no indentation at all never allocates it: callers reach
+  // this only when `indent > 0`, so `repeat` never sees a negative count.
+  while cache.length() <= level {
+    match cache {
+      [.., last] => cache.push(last + " ".repeat(indent))
+      [] => cache.push("\n")
     }
   }
+  buf.write_string(cache[level])
 }
 
 ///|
@@ -175,7 +181,7 @@ pub fn Replacer::Replacer(f : (String, Json) -> Json?) -> Replacer {
 /// }
 /// ```
 pub fn Replacer::keep(array : ArrayView[StringView]) -> Replacer {
-  { f: (idx, value) => if array.contains(idx) { Some(value) } else { None } }
+  { f: (idx, value) => if array.contains(idx) { Some(value) } else { None }, }
 }
 
 ///|
@@ -197,7 +203,7 @@ pub fn Replacer::keep(array : ArrayView[StringView]) -> Replacer {
 /// }
 /// ```
 pub fn Replacer::exclude(array : ArrayView[StringView]) -> Replacer {
-  { f: (idx, value) => if array.contains(idx) { None } else { Some(value) } }
+  { f: (idx, value) => if array.contains(idx) { None } else { Some(value) }, }
 }
 
 ///|
@@ -280,6 +286,7 @@ pub fn Json::stringify(
   replacer? : Replacer,
 ) -> String {
   let buf = StringBuilder(size_hint=0)
+  let indent_cache : Array[String] = []
 
   // Explicit stack to replace recursive calls
   let stack : Array[WriteFrame] = []
@@ -294,7 +301,9 @@ pub fn Json::stringify(
             } else {
               depth += 1
               buf.write_char('{')
-              buf.write_string(indent_str(depth, indent))
+              if indent > 0 {
+                write_indent(buf, indent_cache, depth, indent)
+              }
               // After child value printed, we resume from this frame
               stack.push(Object(members.iter(), first=true))
             }
@@ -304,12 +313,14 @@ pub fn Json::stringify(
             } else {
               depth += 1
               buf.write_char('[')
-              buf.write_string(indent_str(depth, indent))
+              if indent > 0 {
+                write_indent(buf, indent_cache, depth, indent)
+              }
               stack.push(Array(arr, i=0))
             }
           String(s) => {
             buf.write_char('\"')
-            buf.write_string(escape(s, escape_slash~))
+            write_escaped(buf, s, escape_slash~)
             buf.write_char('\"')
           }
           Number(n, repr~) =>
@@ -333,13 +344,17 @@ pub fn Json::stringify(
               frame.i = i + 1
               if i > 0 {
                 buf.write_char(',')
-                buf.write_string(indent_str(depth, indent))
+                if indent > 0 {
+                  write_indent(buf, indent_cache, depth, indent)
+                }
               }
               continue Some(element)
             } else {
               depth -= 1
               ignore(stack.pop())
-              buf.write_string(indent_str(depth, indent))
+              if indent > 0 {
+                write_indent(buf, indent_cache, depth, indent)
+              }
               buf.write_char(']')
               continue None
             }
@@ -356,10 +371,12 @@ pub fn Json::stringify(
                 }
                 if !first {
                   buf.write_char(',')
-                  buf.write_string(indent_str(depth, indent))
+                  if indent > 0 {
+                    write_indent(buf, indent_cache, depth, indent)
+                  }
                 }
                 buf.write_char('\"')
-                buf.write_string(escape(k, escape_slash~))
+                write_escaped(buf, k, escape_slash~)
                 buf.write_char('\"')
                 buf.write_char(':')
                 if indent > 0 {
@@ -371,7 +388,9 @@ pub fn Json::stringify(
               None => {
                 depth -= 1
                 ignore(stack.pop())
-                buf.write_string(indent_str(depth, indent))
+                if indent > 0 {
+                  write_indent(buf, indent_cache, depth, indent)
+                }
                 buf.write_char('}')
                 continue None
               }
@@ -383,36 +402,107 @@ pub fn Json::stringify(
 }
 
 ///|
-fn escape(str : String, escape_slash~ : Bool) -> String {
-  let buf = StringBuilder(size_hint=str.length())
-  for c in str {
-    match c {
+#inline
+fn need_escape_scalar(
+  str : String,
+  escape_slash : Bool,
+  start : Int,
+  end : Int,
+) -> Bool {
+  for i in start.. Unit {
+  ignore(@v128.i16x8_splat(0))
+}
+
+///|
+#cfg(not(any(target="native", target="wasm")))
+fn need_escape(str : String, escape_slash : Bool) -> Bool {
+  need_escape_scalar(str, escape_slash, 0, str.length())
+}
+
+///|
+// Scan eight UTF-16 code units at a time on linear-memory backends, then scan
+// the remaining tail one code unit at a time.
+#cfg(any(target="native", target="wasm"))
+fn need_escape(str : String, escape_slash : Bool) -> Bool {
+  let len = str.length()
+  guard len >= 8 else { return need_escape_scalar(str, escape_slash, 0, len) }
+  let control_limit = @v128.i16x8_splat(' ')
+  let quote = @v128.i16x8_splat('"')
+  let backslash = @v128.i16x8_splat('\\')
+  let slash = @v128.i16x8_splat('/')
+  let tail_start = for pos = 0; pos + 8 <= len; {
+    let block = @v128.v128_load_i16x8(str, pos)
+    let escaped = @v128.v128_or_(
+      @v128.i16x8_lt_u(block, control_limit),
+      @v128.v128_or_(
+        @v128.i16x8_eq(block, quote),
+        @v128.i16x8_eq(block, backslash),
+      ),
+    )
+    let escaped = if escape_slash {
+      @v128.v128_or_(escaped, @v128.i16x8_eq(block, slash))
+    } else {
+      escaped
+    }
+    if @v128.v128_any_true(escaped) {
+      return true
+    }
+    continue pos + 8
+  } nobreak {
+    pos
+  }
+  need_escape_scalar(str, escape_slash, tail_start, len)
+}
+
+///|
+fn write_escaped(
+  buf : StringBuilder,
+  str : String,
+  escape_slash~ : Bool,
+) -> Unit {
+  if !need_escape(str, escape_slash) {
+    buf.write_string(str)
+    return
+  }
+  for code in str.code_units() {
+    match code {
       '"' => buf.write_string("\\\"")
       '\\' => buf.write_string("\\\\")
       '/' =>
         if escape_slash {
           buf.write_string("\\/")
         } else {
-          buf.write_char(c)
+          buf.write_char('/')
         }
       '\n' => buf.write_string("\\n")
       '\r' => buf.write_string("\\r")
       '\b' => buf.write_string("\\b")
       '\t' => buf.write_string("\\t")
-      _ => {
-        let code = c.to_int()
-        if code == 0x0C {
-          buf.write_string("\\f")
-        } else if code < ' ' {
+      0x0C => buf.write_string("\\f")
+      _ =>
+        if code < ' ' {
           buf.write_string("\\u00")
           buf.write_string(code.to_byte().to_hex())
         } else {
-          buf.write_char(c)
+          buf.write_char(code.unsafe_to_char())
         }
-      }
     }
   }
-  buf.to_string()
 }
 
 ///|
@@ -455,7 +545,9 @@ fn escape(str : String, escape_slash~ : Bool) -> String {
 /// ## Behavior
 /// 
 /// - Recursively applies the replacer to all nested objects
-/// - Non-object values (arrays, strings, numbers, etc.) are returned unchanged
+/// - Arrays are rebuilt element by element, so objects nested inside an array are
+///   transformed as well (the replacer itself is only applied to object members)
+/// - All other values (strings, numbers, booleans, null) are returned unchanged
 /// - The original JSON value is not modified; a new value is returned
 pub fn Json::transform(self : Self, replacer : Replacer) -> Json {
   match self {
diff --git a/json/json_coverage_test.mbt b/json/json_coverage_test.mbt
index 7fccb0bee8..48032a62d8 100644
--- a/json/json_coverage_test.mbt
+++ b/json/json_coverage_test.mbt
@@ -102,7 +102,7 @@ test "nesting beyond the configured depth limit is rejected" {
   let deep_array = String::make(9, '[') + String::make(9, ']')
   inspect(parse_raises(deep_array, max_nesting_depth=8), content="true")
   // the same for deeply nested objects (a separate depth check)
-  let sb = StringBuilder::new()
+  let sb = StringBuilder()
   for _ in 0..<9 {
     sb.write_string("{\"a\":")
   }
diff --git a/json/json_encode_decode_test.mbt b/json/json_encode_decode_test.mbt
index 131b37c9b2..0179757b5b 100644
--- a/json/json_encode_decode_test.mbt
+++ b/json/json_encode_decode_test.mbt
@@ -27,7 +27,7 @@ fn[T] array_to_json(arr : Array[T], f~ : (T) -> Json) -> Json {
 
 ///|
 fn AllThree::to_json(self : AllThree) -> Json {
-  let { ints, floats, strings } = self
+  let { ints, floats, strings, } = self
   {
     "ints": ints |> array_to_json(f=x => Json::number(x.to_double())),
     "floats": floats |> array_to_json(f=x => Json::number(x)),
@@ -64,7 +64,7 @@ fn of_json(jv : Json) -> AllThree raise DecodeError {
         guard s is String(s) else { () } // error handling here
         strings_result.push(s)
       }
-      { ints: ints_result, floats: floats_result, strings: strings_result }
+      { ints: ints_result, floats: floats_result, strings: strings_result, }
     }
     _ => raise DecodeError("Expected an object of ints, floats, and strings")
   }
diff --git a/json/json_inspect_test.mbt b/json/json_inspect_test.mbt
index da46af2d98..f83df6f08e 100644
--- a/json/json_inspect_test.mbt
+++ b/json/json_inspect_test.mbt
@@ -45,10 +45,10 @@ priv struct Line {
 
 ///|
 test "json inspect" {
-  let p1 = { x: 0, y: 0, color: Red }
-  let p2 = { x: 1, y: 2, color: Green }
+  let p1 = { x: 0, y: 0, color: Red, }
+  let p2 = { x: 1, y: 2, color: Green, }
   @json.json_inspect(p1, content={ "x": 0, "y": 0, "color": "Red" })
-  let line = { p1, p2, color: Blue }
+  let line = { p1, p2, color: Blue, }
   @json.json_inspect(line, content={
     "p1": { "x": 0, "y": 0, "color": "Red" },
     "p2": { "x": 1, "y": 2, "color": "Green" },
diff --git a/json/json_path.mbt b/json/json_path.mbt
index 06ca70746f..d8eda153f2 100644
--- a/json/json_path.mbt
+++ b/json/json_path.mbt
@@ -49,8 +49,8 @@ pub impl Show for JsonPath with fn output(self, logger) {
     }
     for ch in token.iter() {
       match ch {
-        '~' => logger.write_string("~0")
-        '/' => logger.write_string("~1")
+        '~' => logger <+ "~0"
+        '/' => logger <+ "~1"
         _ => logger.write_char(ch)
       }
     }
@@ -60,16 +60,12 @@ pub impl Show for JsonPath with fn output(self, logger) {
   fn build_path(path : JsonPath, logger : &Logger) -> Unit {
     match path {
       Root => ()
-      Key(parent, key~) => {
-        build_path(parent, logger)
-        logger.write_char('/')
-        write_token(logger, key)
-      }
-      Index(parent, index~) => {
-        build_path(parent, logger)
-        logger.write_char('/')
-        logger.write_object(index)
-      }
+      Key(parent, key~) =>
+        logger <+
+          "\{cb => build_path(parent, cb)}/\{cb => write_token(cb, key)}"
+      Index(parent, index~) =>
+        logger <+
+          "\{cb => build_path(parent, cb)}/\{cb => cb.write_object(index)}"
     }
   }
 
diff --git a/json/json_test.mbt b/json/json_test.mbt
index 5b5ca32fc7..c90ed1e763 100644
--- a/json/json_test.mbt
+++ b/json/json_test.mbt
@@ -481,7 +481,7 @@ test "stringify with indent" {
 ///|
 test "nested json" {
   let u : Int = 3
-  let h = NestedJsonValue::{ w: 3 }
+  let h = NestedJsonValue::{ w: 3, }
   let v : Json = {
     "nestedmap": {
       "key1": u, // auto to json
@@ -613,6 +613,11 @@ test "transformer with array" {
 }
 
 ///|
+// Kept for testing purposes: the `FromJson` bound is unused in the body (hence
+// the suppression), but it is asserted at the call site in the test below —
+// `test_json_export(42)` type-checks only because `Int` implements `FromJson`.
+// Do not remove the bound or the suppression: that would silently weaken the
+// test into a plain identity check.
 #warnings("-unused_trait_bound")
 fn[A : FromJson] test_json_export(x : A) -> A {
   x
diff --git a/json/json_traverse_test.mbt b/json/json_traverse_test.mbt
index 3f69c64b09..9593af3971 100644
--- a/json/json_traverse_test.mbt
+++ b/json/json_traverse_test.mbt
@@ -49,7 +49,7 @@ fn Json::prune_loc(self : Json) -> Json? {
   match self {
     Null | True | False | Number(_, ..) | String(_) => Some(self)
     Array(arr) => {
-      let pruned_arr = Array::new()
+      let pruned_arr = Array()
       for item in arr {
         guard item.prune_loc() is Some(pruned_item) else { () } // drop this item
         pruned_arr.push(pruned_item)
diff --git a/json/lex_main.mbt b/json/lex_main.mbt
index 47b27a2283..a6e1010209 100644
--- a/json/lex_main.mbt
+++ b/json/lex_main.mbt
@@ -49,22 +49,24 @@ fn ParseContext::lex_value(
     Some('-') =>
       match ctx.read_char() {
         Some('0') => {
-          let { value: n, repr } = ctx.lex_zero(start=ctx.offset - 2)
+          let { value: n, repr, } = ctx.lex_zero(start=ctx.offset - 2)
           return Number(n, repr.map(repr => repr.to_owned()))
         }
         Some('1'..='9') => {
-          let { value: n, repr } = ctx.lex_decimal_integer(start=ctx.offset - 2)
+          let { value: n, repr, } = ctx.lex_decimal_integer(
+            start=ctx.offset - 2,
+          )
           return Number(n, repr.map(repr => repr.to_owned()))
         }
         Some(_) => ctx.invalid_char(shift=-1)
         None => raise InvalidEof
       }
     Some('0') => {
-      let { value: n, repr } = ctx.lex_zero(start=ctx.offset - 1)
+      let { value: n, repr, } = ctx.lex_zero(start=ctx.offset - 1)
       return Number(n, repr.map(repr => repr.to_owned()))
     }
     Some('1'..='9') => {
-      let { value: n, repr } = ctx.lex_decimal_integer(start=ctx.offset - 1)
+      let { value: n, repr, } = ctx.lex_decimal_integer(start=ctx.offset - 1)
       return Number(n, repr.map(repr => repr.to_owned()))
     }
     Some('"') => {
diff --git a/json/lex_number.mbt b/json/lex_number.mbt
index aa03bc3659..416c07f26e 100644
--- a/json/lex_number.mbt
+++ b/json/lex_number.mbt
@@ -118,10 +118,9 @@ fn JsonNumberScan::try_fast_double(self : JsonNumberScan) -> Double {
     }
   } else {
     let shift = self.exponent - MAX_EXPONENT_FAST_PATH
-    let mantissa = match
-      checked_mul(self.mantissa, int_pow10_table[shift.to_int()]) {
-      Some(m) => m
-      None => return @double.not_a_number
+    guard checked_mul(self.mantissa, int_pow10_table[shift.to_int()])
+      is Some(mantissa) else {
+      return @double.not_a_number
     }
     if mantissa > MAX_MANTISSA_FAST_PATH {
       return @double.not_a_number
@@ -232,7 +231,7 @@ fn ParseContext::lex_integer_end(
   for i = number_start, acc = 0L {
     if i >= end {
       let value = if negative { -acc } else { acc }
-      break { value: value.to_double(), repr: None }
+      break { value: value.to_double(), repr: None, }
     }
     let digit = (ctx.input.unsafe_get(i).to_int() - '0').to_int64()
     if acc > (SAFE_INTEGER_LIMIT - digit) / 10L {
@@ -244,13 +243,13 @@ fn ParseContext::lex_integer_end(
       let s = ctx.input.view(start_offset=start, end_offset=end)
       try {
         let value = @internal/strconv.parse_double(s)
-        return { value, repr: Some(s) }
+        return { value, repr: Some(s), }
       } catch {
         _ =>
           return if negative {
-            { value: @double.neg_infinity, repr: Some(s) }
+            { value: @double.neg_infinity, repr: Some(s), }
           } else {
-            { value: @double.infinity, repr: Some(s) }
+            { value: @double.infinity, repr: Some(s), }
           }
       }
     }
@@ -383,8 +382,9 @@ fn ParseContext::lex_number_end(
 ) -> LexedNumber {
   // Fast path for JSON numbers: the lexer has already validated the grammar,
   // so scan raw UTF-16 digits once and bypass the general strconv parser for
-  // safe integers and Clinger-style fast-path doubles. Fall back to strconv for
-  // large or precision-sensitive numbers so existing rounding behavior is kept.
+  // safe integers, Clinger-style exact doubles, and Eisel-Lemire conversions.
+  // Fall back to strconv for truncated or ambiguous numbers so the exact
+  // Decimal converter preserves existing rounding behavior.
   let scan = ctx.scan_json_number(start, end)
   if scan.is_integer {
     // `is_integer` is set by `scan_json_number` only when no `.` and no `e/E`
@@ -408,26 +408,36 @@ fn ParseContext::lex_number_end(
       scan.mantissa <= SAFE_INTEGER_LIMIT.reinterpret_as_uint64() {
       let v = scan.mantissa.reinterpret_as_int64().to_double()
       let value = if scan.negative { -v } else { v }
-      return { value, repr: None }
+      return { value, repr: None, }
     }
     return ctx.lex_integer_end(start, end)
   }
   let fast = scan.try_fast_double()
   if !fast.is_nan() {
-    return { value: fast, repr: None }
+    return { value: fast, repr: None, }
+  }
+  if !scan.many_digits {
+    let fast = @internal/strconv.try_eisel_lemire64(
+      scan.mantissa,
+      scan.exponent,
+      scan.negative,
+    )
+    if !fast.is_nan() {
+      return { value: fast, repr: None, }
+    }
   }
   let s = ctx.input.view(start_offset=start, end_offset=end)
   try {
     let d = @internal/strconv.parse_double(s)
     // For normal values, return without string representation
-    { value: d, repr: None }
+    { value: d, repr: None, }
   } catch {
     // If parsing fails as a double, treat it as infinity and preserve the string
     _ =>
       if scan.negative {
-        { value: @double.neg_infinity, repr: Some(s) }
+        { value: @double.neg_infinity, repr: Some(s), }
       } else {
-        { value: @double.infinity, repr: Some(s) }
+        { value: @double.infinity, repr: Some(s), }
       }
   }
 }
diff --git a/json/lex_number_test.mbt b/json/lex_number_test.mbt
index 8d14be2a7a..ffcf1a8773 100644
--- a/json/lex_number_test.mbt
+++ b/json/lex_number_test.mbt
@@ -74,6 +74,24 @@ test "parse negative huge exponent" {
   )
 }
 
+///|
+test "parse_number 17-19 digit mantissas" {
+  let cases : FixedArray[(String, UInt64)] = [
+    ("-65.613616999999977", 13857689601620889920UL),
+    ("43.420273000000009", 4631307677475222056UL),
+    ("-65.619720000000029", 13857690031081335640UL),
+    ("43.418052999999986", 4631307365037997904UL),
+    ("9.999999999999999999", 4621819117588971520UL),
+  ]
+  for case in cases {
+    let (input, expected_bits) = case
+    guard @json.parse(input) is Number(value, ..) else {
+      fail("expected JSON number")
+    }
+    assert_eq(value.reinterpret_as_uint64(), expected_bits)
+  }
+}
+
 ///|
 test "parse and stringify large integers" {
   // Test integers at Int boundaries
diff --git a/json/lex_string.mbt b/json/lex_string.mbt
index fbaa8d3872..e1a5c5dc35 100644
--- a/json/lex_string.mbt
+++ b/json/lex_string.mbt
@@ -17,7 +17,10 @@ fn ParseContext::lex_string(ctx : ParseContext) -> String raise ParseError {
   let string_start = ctx.offset
   // Fast path for ordinary strings: scan raw UTF-16 code units and materialize
   // the slice directly when there are no escapes or control characters.
-  for i in string_start..= ctx.end_offset {
+      break
+    }
     let c = ctx.input.unsafe_get(i)
     if c == '"' {
       ctx.offset = i + 1
@@ -29,7 +32,23 @@ fn ParseContext::lex_string(ctx : ParseContext) -> String raise ParseError {
       // \t) are invalid inside a JSON string.
       ctx.offset = i + 1
       ctx.invalid_char(shift=-1)
+    } else if c.is_leading_surrogate() {
+      if i + 1 < ctx.end_offset &&
+        ctx.input.unsafe_get(i + 1).is_trailing_surrogate() {
+        continue i + 2
+      }
+      // MoonBit strings stay Unicode well-formed, so a raw unpaired
+      // surrogate must be rejected rather than smuggled into the parsed
+      // string. (Well-formed input cannot contain one, but unsafe code can
+      // manufacture such a String; a clean error beats undefined behavior.)
+      ctx.offset = i + 1
+      ctx.invalid_char(shift=-1)
+    } else if c.is_trailing_surrogate() {
+      // A bare trailing surrogate can never start a surrogate pair.
+      ctx.offset = i + 1
+      ctx.invalid_char(shift=-1)
     }
+    continue i + 1
   }
   raise InvalidEof
 }
@@ -39,8 +58,14 @@ fn ParseContext::lex_string_slow(ctx : ParseContext) -> String raise ParseError
   let buf = StringBuilder()
   let mut start = ctx.offset
   fn flush(end : Int) {
-    if start > 0 && end > start {
-      buf.write_view(ctx.input[start:end])
+    if end > start {
+      // `view(start_offset~, end_offset~)` only bounds-checks. The checked
+      // `ctx.input[start:end]` would abort on a trailing surrogate at a
+      // slice boundary, which a raw lone surrogate inside the string could
+      // place there; unpaired surrogates now raise a ParseError before any
+      // flush spans them, and the unchecked slice keeps this loop's
+      // totality independent of that validation order.
+      buf.write_view(ctx.input.view(start_offset=start, end_offset=end))
     }
   }
 
@@ -62,26 +87,84 @@ fn ParseContext::lex_string_slow(ctx : ParseContext) -> String raise ParseError
           Some('\\') => buf.write_char('\\')
           Some('/') => buf.write_char('/')
           Some('u') => {
+            // The backslash that opened this escape. `ctx.offset` is just
+            // past the `u`, and `\` and `u` are one code unit each.
+            let escape_start = ctx.offset - 2
             let c = ctx.lex_hex_digits(4)
-            buf.write_char(c.unsafe_to_char())
+            if c is (0xD800..=0xDBFF) {
+              // A leading-surrogate escape is only meaningful as the first
+              // half of an escaped surrogate pair; combine it with the
+              // immediately following trailing-surrogate escape into one
+              // Unicode scalar value. Anything else would manufacture a
+              // string containing an unpaired surrogate, which MoonBit
+              // strings disallow (RFC 8259 calls the behavior for such
+              // escapes unpredictable; I-JSON forbids them).
+              match ctx.read_char() {
+                Some('\\') => ()
+                Some(_) => ctx.unpaired_surrogate(escape_start)
+                None => raise InvalidEof
+              }
+              match ctx.read_char() {
+                Some('u') => ()
+                Some(_) => ctx.unpaired_surrogate(escape_start)
+                None => raise InvalidEof
+              }
+              let c2 = ctx.lex_hex_digits(4)
+              if c2 is (0xDC00..=0xDFFF) {
+                let combined = (c << 10) + c2 - 0x35fdc00
+                buf.write_char(combined.unsafe_to_char())
+              } else {
+                ctx.unpaired_surrogate(escape_start)
+              }
+            } else if c is (0xDC00..=0xDFFF) {
+              // A bare trailing-surrogate escape can never form a scalar
+              // value.
+              ctx.unpaired_surrogate(escape_start)
+            } else {
+              buf.write_char(c.unsafe_to_char())
+            }
           }
-          Some(_) => ctx.invalid_char(shift=-1)
+          Some(c) => ctx.invalid_char(shift=-c.utf16_len())
           None => raise InvalidEof
         }
         start = ctx.offset
       }
-      Some(ch) =>
-        if ch.to_int() < 32 {
+      Some(ch) => {
+        let code = ch.to_int()
+        if code < 32 {
+          ctx.invalid_char(shift=-1)
+        } else if code is (0xD800..=0xDFFF) {
+          // `read_char` only yields a surrogate-range value when the raw
+          // code unit is unpaired; keep parsed strings Unicode well-formed
+          // by rejecting it (previously this aborted the process when a
+          // later flush sliced across the surrogate).
           ctx.invalid_char(shift=-1)
         } else {
           continue
         }
+      }
       None => raise InvalidEof
     }
   }
   buf.to_string()
 }
 
+///|
+/// Reports the `\uXXXX` escape that begins at `escape_start` as invalid.
+///
+/// Every rejection of an unpaired surrogate points here — at the backslash
+/// opening the offending escape — rather than at whichever character the
+/// scan happened to stop on. Blaming the stopping point would name a
+/// perfectly valid hex digit for `"\uDC00"`, and for a leading surrogate
+/// followed by a non-BMP character it would name a position inside that
+/// character.
+fn[T] ParseContext::unpaired_surrogate(
+  ctx : ParseContext,
+  escape_start : Int,
+) -> T raise ParseError {
+  ctx.invalid_char(shift=escape_start - ctx.offset)
+}
+
 ///|
 fn ParseContext::lex_hex_digits(
   ctx : ParseContext,
@@ -92,7 +175,9 @@ fn ParseContext::lex_hex_digits(
       Some('0'..='9' as c) => c.to_int() - '0'
       Some('A'..='F' as c) => c.to_int() - 'A' + 10
       Some('a'..='f' as c) => c.to_int() - 'a' + 10
-      Some(_) => ctx.invalid_char(shift=-1)
+      // `-1` would land inside the character when it is not in the BMP,
+      // reporting a broken half at the wrong column.
+      Some(c) => ctx.invalid_char(shift=-c.utf16_len())
       None => raise InvalidEof
     }
     continue (r << 4) | d
diff --git a/json/lex_string_test.mbt b/json/lex_string_test.mbt
index 31303069cb..cf794aef07 100644
--- a/json/lex_string_test.mbt
+++ b/json/lex_string_test.mbt
@@ -89,3 +89,187 @@ test "lex_hex_digits accepts all hex digit ranges" {
     ),
   )
 }
+
+// Regression for #4062. The parser used to decode every `\uXXXX` escape by
+// writing the hex value into the result unchecked, manufacturing an
+// ill-formed lone-surrogate string out of valid ASCII JSON input. MoonBit
+// strings stay well-formed Unicode, so an escaped leading surrogate must be
+// followed immediately by an escaped trailing surrogate — the pair decodes
+// to the one character it denotes — and every unpaired spelling is a
+// `ParseError`.
+//
+// The cases are split by shape so that one failing assertion cannot mask the
+// rest. The old parser accepted the *unpaired-surrogate* spellings below —
+// it already rejected the ones that also run out of input or misspell the
+// second escape — and aborted the process outright on the escaped-leading +
+// raw-trailing one, which is why that case is kept last.
+
+///|
+test "unpaired leading-surrogate escape is rejected" {
+  // A closing quote after it, and nothing after it at all. (The second was
+  // already an EOF error before the surrogate rule.)
+  assert_false(@json.valid("\"\\uD800\""))
+  assert_false(@json.valid("\"\\uD800"))
+  // A character that is not the start of an escape.
+  assert_false(@json.valid("\"\\uD800x\""))
+  // An escape that is not `\u`.
+  assert_false(@json.valid("\"\\uD800\\n\""))
+  // Two leading-surrogate escapes in a row.
+  assert_false(@json.valid("\"\\uD800\\uD800\""))
+  // A second escape that is present but malformed or cut short.
+  assert_false(@json.valid("\"\\uD800\\uZZZZ\""))
+  assert_false(@json.valid("\"\\uD800\\u00\""))
+  assert_false(@json.valid("\"\\uD800\\u\""))
+  assert_false(@json.valid("\"\\uD800\\u"))
+}
+
+///|
+test "bare trailing-surrogate escape is rejected" {
+  assert_false(@json.valid("\"\\uDC00\""))
+  assert_false(@json.valid("\"a\\uDFFF b\""))
+  // An escaped pair in reverse order: the trailing half is bare.
+  assert_false(@json.valid("\"\\uDE00\\uD83D\""))
+}
+
+///|
+test "unpaired surrogate escapes are rejected in object keys too" {
+  assert_false(@json.valid("{\"\\uD800\": 1}"))
+  assert_false(@json.valid("{\"a\": 1, \"\\uDC00\": 2}"))
+  // ...and a valid pair in a key still works.
+  assert_true(@json.valid("{\"\\uD83D\\uDE00\": 1}"))
+}
+
+///|
+test "the surrogate error names the escape that could not pair up" {
+  // The position is the backslash opening the offending escape, not
+  // whichever character the scan stopped on — that would blame the last,
+  // perfectly valid, hex digit. Columns count code units from zero, so the
+  // backslash after the opening quote is column 1.
+  debug_inspect(
+    expect_parse_error("\"\\uDC00\"", "expected InvalidChar"),
+    content=(
+      #|InvalidChar({ line: 1, column: 1 }, '\\')
+    ),
+  )
+  debug_inspect(
+    expect_parse_error("\"ab\\uD800x\"", "expected InvalidChar"),
+    content=(
+      #|InvalidChar({ line: 1, column: 3 }, '\\')
+    ),
+  )
+  // A non-BMP character after the escape occupies two code units; the
+  // reported column must not land inside it.
+  debug_inspect(
+    expect_parse_error("\"\\uD800\u{1F600}\"", "expected InvalidChar"),
+    content=(
+      #|InvalidChar({ line: 1, column: 1 }, '\\')
+    ),
+  )
+  // The same position whichever way the pairing fails: a follower that is
+  // not a backslash, a backslash not followed by `u`, and a second escape
+  // that is well formed but is not a trailing surrogate.
+  debug_inspect(
+    expect_parse_error("\"\\uD800\\n\"", "expected InvalidChar"),
+    content=(
+      #|InvalidChar({ line: 1, column: 1 }, '\\')
+    ),
+  )
+  debug_inspect(
+    expect_parse_error("\"\\uD800\\u0041\"", "expected InvalidChar"),
+    content=(
+      #|InvalidChar({ line: 1, column: 1 }, '\\')
+    ),
+  )
+  // Running out of input is still an EOF error rather than a character one,
+  // including on a trailing backslash where the second escape should start.
+  debug_inspect(
+    expect_parse_error("\"\\uD800", "expected InvalidEof"),
+    content="InvalidEof",
+  )
+  debug_inspect(
+    expect_parse_error("\"\\uD800\\", "expected InvalidEof"),
+    content="InvalidEof",
+  )
+}
+
+///|
+test "an escape naming a non-BMP character reports it whole" {
+  // These two arms read a character and then step back by its width. Using
+  // a fixed step of one code unit landed inside a non-BMP character and
+  // reported a broken half one column further on.
+  debug_inspect(
+    expect_parse_error("\"\\\u{1F600}\"", "expected InvalidChar"),
+    content=(
+      #|InvalidChar({ line: 1, column: 2 }, '😀')
+    ),
+  )
+  debug_inspect(
+    expect_parse_error("\"\\u1\u{1F600}23\"", "expected InvalidChar"),
+    content=(
+      #|InvalidChar({ line: 1, column: 4 }, '😀')
+    ),
+  )
+}
+
+///|
+test "valid surrogate pairs decode to one scalar value" {
+  // Both ends of the supplementary range.
+  assert_true(@json.parse("\"\\uD800\\uDC00\"") == Json::string("\u{10000}"))
+  assert_true(@json.parse("\"\\udbff\\udfff\"") == Json::string("\u{10FFFF}"))
+  // Any hex digit case, and in any position within the string.
+  assert_true(@json.parse("\"\\uD83D\\uDE00\"") == Json::string("\u{1F600}"))
+  assert_true(
+    @json.parse("\"a\\uD83D\\uDE00b\\n\"") == Json::string("a\u{1F600}b\n"),
+  )
+  // Two pairs running together are decoded independently.
+  assert_true(
+    @json.parse("\"\\uD83D\\uDE00\\uD83D\\uDE01\"") ==
+    Json::string("\u{1F600}\u{1F601}"),
+  )
+  // Non-surrogate escapes are unaffected, including the replacement
+  // character, which is a scalar value like any other.
+  assert_true(@json.parse("\"\\u0041\\uFFFD\"") == Json::string("A\u{FFFD}"))
+}
+
+///|
+test "escaped and raw surrogate halves do not pair up" {
+  // Kept last: before the surrogate rule this input aborted the process
+  // rather than raising, so a regression here would take the whole test
+  // binary with it.
+  let lone_low = String::from_array([(0xDC00).unsafe_to_char()])
+  assert_false(@json.valid("\"\\uD800" + lone_low + "\""))
+}
+
+///|
+/// Regression for #4049. MoonBit strings must stay Unicode well-formed, so
+/// the string lexer rejects raw unpaired surrogates with a ParseError.
+/// Previously the escape-free fast path silently accepted them (producing
+/// ill-formed strings), and combining one with an escape sequence aborted
+/// the process: the slow path's `flush` sliced with the checked
+/// `[start:end]`, which panics when a slice boundary lands on a trailing
+/// surrogate. Well-formed input cannot contain raw lone surrogates, but
+/// unsafe code can manufacture such a String; a clean error beats an abort.
+test "raw unpaired surrogates are rejected with a clean parse error" {
+  let lone_high = String::from_array([(0xD800).unsafe_to_char()])
+  let lone_low = String::from_array([(0xDC00).unsafe_to_char()])
+  // Escape-free strings (fast path; used to be silently accepted).
+  assert_false(@json.valid("\"" + lone_high + "\""))
+  assert_false(@json.valid("\"" + lone_low + "\""))
+  // A reversed pair (low then high) is two unpaired surrogates.
+  assert_false(@json.valid("\"" + lone_low + lone_high + "\""))
+  // Next to escapes (slow path; used to abort the process).
+  assert_false(@json.valid("\"" + lone_low + "\\n\""))
+  assert_false(@json.valid("\"\\n" + lone_low + "\""))
+  assert_false(@json.valid("\"" + lone_high + "\\t\""))
+  // The failure is the documented ParseError, not an abort.
+  debug_inspect(
+    expect_parse_error("\"" + lone_low + "\"", "expected InvalidChar"),
+    content=(
+      #|InvalidChar({ line: 1, column: 1 }, '�')
+    ),
+  )
+  // Well-formed surrogate pairs still parse, raw or next to escapes.
+  assert_true(@json.parse("\"\u{1F600}\"") == Json::string("\u{1F600}"))
+  let json = Json::string("\u{10FFFF}\\\u{1F600}\n")
+  assert_true(@json.parse(json.stringify()) == json)
+}
diff --git a/json/moon.pkg b/json/moon.pkg
index 197f8e7fb7..9f1be5d443 100644
--- a/json/moon.pkg
+++ b/json/moon.pkg
@@ -8,6 +8,7 @@ import {
   "moonbitlang/core/internal/strconv" @internal/strconv,
   "moonbitlang/core/option",
   "moonbitlang/core/buffer",
+  "moonbitlang/core/v128", // Used only by the native and wasm SIMD implementation.
 }
 
 import {
@@ -21,3 +22,7 @@ import {
   "moonbitlang/core/quickcheck/shrink",
   "moonbitlang/core/quickcheck/splitmix",
 } for "test"
+
+import {
+  "moonbitlang/core/quickcheck",
+} for "wbtest"
diff --git a/json/number_bench_test.mbt b/json/number_bench_test.mbt
index 8ce159e09d..faee4dbd28 100644
--- a/json/number_bench_test.mbt
+++ b/json/number_bench_test.mbt
@@ -34,8 +34,27 @@ fn make_float_array_json(count : Int) -> String {
     if i > 0 {
       buf.write_char(',')
     }
-    buf.write_string((i * 2654435).to_string())
-    buf.write_string(".125e-2")
+    buf <+ "\{i * 2654435}.125e-2"
+  }
+  buf.write_char(']')
+  buf.to_string()
+}
+
+///|
+let long_mantissa_json_numbers : FixedArray[String] = [
+  "-65.613616999999977", "43.420273000000009", "-65.619720000000029", "43.418052999999986",
+  "-65.625000000000000", "43.412101000000000", "-65.630279999999994", "43.406101000000010",
+]
+
+///|
+fn make_long_mantissa_array_json(count : Int) -> String {
+  let buf = StringBuilder(size_hint=count * 22)
+  buf.write_char('[')
+  for i in 0.. 0 {
+      buf.write_char(',')
+    }
+    buf.write_string(long_mantissa_json_numbers[i % 8])
   }
   buf.write_char(']')
   buf.to_string()
@@ -52,3 +71,9 @@ test "bench json parse float array n=10000" (it : @bench.T) {
   let input = make_float_array_json(10000)
   it.bench(fn() { it.keep(try! @json.parse(input)) })
 }
+
+///|
+test "bench json parse long mantissa array n=10000" (it : @bench.T) {
+  let input = make_long_mantissa_array_json(10000)
+  it.bench(fn() { it.keep(try! @json.parse(input)) })
+}
diff --git a/json/number_quickcheck_test.mbt b/json/number_quickcheck_test.mbt
new file mode 100644
index 0000000000..1ea8ad04b9
--- /dev/null
+++ b/json/number_quickcheck_test.mbt
@@ -0,0 +1,70 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Property tests for JSON number parsing.
+//
+// `lex_number_end` routes a literal to one of several conversions —
+// safe-integer, Clinger, Eisel-Lemire, or the exact strconv fallback —
+// based purely on the literal's shape. Different spellings of the same
+// value therefore take different routes, which yields the property that
+// pins them all together: every spelling must parse to the same double,
+// bit for bit. Padding the fraction with zeros pushes the significant-digit
+// count past the 19 the fast routes tolerate, forcing the exact fallback
+// and turning it into the oracle for the fast ones.
+
+///|
+fn number_value_bits(text : String) -> UInt64? {
+  let json = @json.parse(text) catch { _ => return None }
+  guard json is Number(value, ..) else { return None }
+  Some(value.reinterpret_as_uint64())
+}
+
+///|
+test "quickcheck: json number value is independent of its spelling" {
+  @quickcheck.check(
+    (input : (UInt64, Int, Int, Bool)) => {
+      let (raw, shift_code, exp_code, negative) = input
+      // The shift spreads mantissas across every magnitude; exponents
+      // overshoot the double range on both sides so the overflow and
+      // underflow fallbacks are exercised too.
+      let mantissa = raw >> wrap_index(shift_code, 64)
+      let exponent = wrap_index(exp_code, 723) - 361
+      let sign = if negative { "-" } else { "" }
+      let digits = mantissa.to_string()
+      let plain = "\{sign}\{digits}e\{exponent}"
+      // Fraction zeros leave the value unchanged but push the significant
+      // digit count past 19, forcing the exact fallback.
+      let padded = "\{sign}\{digits}.\{String::make(21, '0')}e\{exponent}"
+      // The decimal point moved behind the first digit, with the exponent
+      // compensating — the same value split differently between mantissa
+      // and exponent by the scanner.
+      let len = digits.length()
+      let pointed = if len == 1 {
+        "\{sign}\{digits}.0e\{exponent}"
+      } else {
+        "\{sign}\{digits[0:1]}.\{digits[1:len]}e\{exponent + len - 1}"
+      }
+      match
+        (
+          number_value_bits(plain),
+          number_value_bits(padded),
+          number_value_bits(pointed),
+        ) {
+        (Some(a), Some(b), Some(c)) => a == b && b == c
+        _ => false
+      }
+    },
+    count=10000,
+  )
+}
diff --git a/json/parse.mbt b/json/parse.mbt
index 174e361dd4..a6acc6d16f 100644
--- a/json/parse.mbt
+++ b/json/parse.mbt
@@ -13,7 +13,10 @@
 // limitations under the License.
 
 ///|
-/// Validate input and return whether it is valid.
+/// Returns whether `input` parses. "Valid" here means exactly what `parse`
+/// accepts, so it includes the restriction on string contents described
+/// there: a document whose only defect is an unpaired surrogate escape is
+/// reported as invalid.
 pub fn valid(input : StringView) -> Bool {
   try {
     parse(input) |> ignore
@@ -25,6 +28,37 @@ pub fn valid(input : StringView) -> Bool {
 
 ///|
 /// Parse a JSON input string into a Json value, with an optional maximum nesting depth (default is 1024)
+///
+/// ## What strings may contain
+///
+/// Every string in the result is well-formed Unicode: each `\uXXXX` escape
+/// must denote a Unicode scalar value on its own, or be one half of a
+/// correctly ordered surrogate pair. An escaped leading surrogate
+/// (`\uD800`–`\uDBFF`) must therefore be followed immediately by an escaped
+/// trailing surrogate (`\uDC00`–`\uDFFF`), and the pair is decoded as the
+/// one character it stands for.
+///
+/// An escape that cannot pair up raises `InvalidChar` positioned at the
+/// backslash that opens it — that one position, whatever the scan actually
+/// stopped on. Input that simply runs out still raises `InvalidEof`, and a
+/// second escape that is itself malformed is reported as the hex-digit
+/// error it is, at the offending digit.
+///
+/// This is a limit on what a string may *contain*, which RFC 8259 §9 leaves
+/// to the implementation — not a claim about which documents are
+/// grammatically well formed, since §8.2 admits unpaired surrogate escapes,
+/// nor a claim of I-JSON (RFC 7493) conformance, which restricts more than
+/// this. It is chosen because MoonBit's `String` is required to be
+/// well-formed, so the alternatives are to hand back a string that violates
+/// that invariant, or to substitute U+FFFD and silently lose the
+/// distinction between two different keys. A parse error is the only one of
+/// the three a caller can see and act on.
+///
+/// The cost is real: `JSON.stringify` in JavaScript emits lone surrogates as
+/// `\uXXXX`, so some JSON that JavaScript and Python accept is rejected
+/// here. Rust's serde_json rejects it too when parsing into `String` or
+/// `Value`, though its byte-oriented mode admits WTF-8; Go's `encoding/json`
+/// substitutes U+FFFD, while its experimental v2 parser is stricter.
 #label_migration(max_nesting_depth, fill=false)
 pub fn parse(
   input : StringView,
diff --git a/json/quickcheck_adversarial_test.mbt b/json/quickcheck_adversarial_test.mbt
new file mode 100644
index 0000000000..f4d7cc2be5
--- /dev/null
+++ b/json/quickcheck_adversarial_test.mbt
@@ -0,0 +1,380 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Adversarial property-based tests for the json package, complementing
+// `quickcheck_test.mbt`:
+//
+// - strings built from hostile UTF-16 sequences (every control character,
+//   JSON syntax characters, astral pairs), used both as values and as
+//   object keys;
+// - the fully `\uXXXX`-escaped spelling of those strings;
+// - *lone surrogates* injected into any position of such strings — raw or
+//   as `\uXXXX` escapes — which the parser must always reject with a clean
+//   parse error (strings stay Unicode well-formed) and never abort on;
+// - textual zero literals (`-0`, `-0.0e7`, ...) which must preserve the
+//   IEEE-754 sign of zero;
+// - integer literals across the full Int64/UInt64 range, whose text must be
+//   preserved exactly by `stringify` (via `repr`) beyond 2^53;
+// - random legal whitespace inserted between tokens;
+// - objects spelled with duplicate keys (last occurrence wins);
+// - single-code-unit deletions/replacements of valid documents, which must
+//   never make the parser panic and must keep `parse` consistent with
+//   `valid`.
+
+///|
+/// Characters drawn from pools that stress every branch of the escaper and
+/// the string lexer: control characters (escaped as `\uXXXX` or short
+/// escapes), JSON syntax characters, BMP boundary values, and astral code
+/// points (surrogate pairs in UTF-16). Only Unicode scalar values appear
+/// here — MoonBit strings stay Unicode well-formed, so unpaired surrogates
+/// are generated separately and asserted to be *rejected* by the parser.
+fn adversarial_char_gen() -> @quickcheck.Generator[Char] {
+  @quickcheck.frequency([
+    // Every control character U+0000..U+001F.
+    (3, @quickcheck.int_range(0, 0x20).map(i => i.unsafe_to_char())),
+    // Characters that interact with JSON syntax and escaping.
+    (
+      3,
+      @quickcheck.elements(['"', '\\', '/', '{', '}', '[', ']', ',', ':', ' ']),
+    ),
+    // BMP boundaries and astral code points (encoded as surrogate pairs).
+    (
+      2,
+      @quickcheck.elements([
+        '\u{7F}', '\u{80}', '\u{7FF}', '\u{800}', '\u{D7FF}', '\u{E000}', '\u{FFFD}',
+        '\u{FFFF}', '\u{10000}', '\u{1F600}', '\u{10FFFF}',
+      ]),
+    ),
+    // Ordinary ASCII so escapes sit inside unescaped runs.
+    (3, @quickcheck.char_range('a', 'z')),
+  ])
+}
+
+///|
+fn adversarial_string_gen() -> @quickcheck.Generator[String] {
+  @quickcheck.int_range(0, 24)
+  .flat_map(n => adversarial_char_gen().array_with_size(n))
+  .map(chars => String::from_array(chars))
+}
+
+///|
+priv struct AdvString(String) derive(@debug.Debug)
+
+///|
+impl @quickcheck.Arbitrary for AdvString with fn arbitrary(size, state) {
+  AdvString(adversarial_string_gen().run(size, state))
+}
+
+///|
+/// Shrinks by dropping one character at a time. Working at the `Char` level
+/// keeps every candidate Unicode well-formed, so a shrunk counterexample
+/// fails for the same reason as the original instead of tripping the
+/// parser's unpaired-surrogate rejection.
+impl @shrink.Shrink for AdvString with fn shrink(self) {
+  let chars = self.0.to_array()
+  let n = chars.length()
+  if n == 0 {
+    return Iter::empty()
+  }
+  Iter::singleton(AdvString("")).concat(
+    (0)
+    .until(n)
+    .map(i => {
+      let copy = chars.copy()
+      ignore(copy.remove(i))
+      AdvString(String::from_array(copy))
+    }),
+  )
+}
+
+///|
+/// The fully `\uXXXX`-escaped spelling of a string: every UTF-16 code unit
+/// as a hex escape, with the digit case alternating per position.
+fn fully_escaped(s : String) -> String {
+  let hex_lower = "0123456789abcdef".to_array()
+  let hex_upper = "0123456789ABCDEF".to_array()
+  let buf = StringBuilder()
+  buf.write_char('"')
+  for i, unit in s.code_units() {
+    let code = unit.to_int()
+    let hex = if i % 2 == 0 { hex_lower } else { hex_upper }
+    buf.write_char('\\')
+    buf.write_char('u')
+    buf.write_char(hex[(code >> 12) & 0xF])
+    buf.write_char(hex[(code >> 8) & 0xF])
+    buf.write_char(hex[(code >> 4) & 0xF])
+    buf.write_char(hex[code & 0xF])
+  }
+  buf.write_char('"')
+  buf.to_string()
+}
+
+///|
+test "adversarial strings roundtrip as values and as object keys" {
+  @quickcheck.check((input : (AdvString, AdvString, Int, Bool)) => {
+    let (key, value, raw_indent, escape_slash) = input
+    let doc = Json::object(Map([(key.0, Json::array([Json::string(value.0)]))]))
+    let text = doc.stringify(indent=wrap_index(raw_indent, 5), escape_slash~)
+    @json.parse(text) == doc
+  })
+}
+
+///|
+/// Spells every UTF-16 code unit of the string as a `\uXXXX` escape
+/// (alternating hex-digit case) and checks the parser reassembles the exact
+/// original string — including surrogate pairs split across two escapes.
+test "fully \\uXXXX-escaped strings parse back to the original" {
+  @quickcheck.check((s : AdvString) => {
+    @json.parse(fully_escaped(s.0)) == Json::string(s.0)
+  })
+}
+
+///|
+/// A lone surrogate — raw or spelled as a `\uXXXX` escape — injected at any
+/// position of an otherwise hostile string must always be rejected with a
+/// clean parse error (`parse` raises, `valid` is false, nothing aborts):
+/// parsed strings stay Unicode well-formed. The surrounding prefix/suffix
+/// supply nearby escapes, astral pairs, and control characters, exercising
+/// both the escape-free fast path and the slow path of the string lexer.
+test "lone surrogates are rejected in every position" {
+  @quickcheck.check((input : (AdvString, AdvString, Int, Bool)) => {
+    let (prefix, suffix, raw_unit, escape_spelling) = input
+    let unit = 0xD800 + wrap_index(raw_unit, 0x800)
+    let lone = String::from_array([unit.unsafe_to_char()])
+    let content = prefix.0 + lone + suffix.0
+    let text = if escape_spelling {
+      fully_escaped(content)
+    } else {
+      // `stringify` writes the lone surrogate raw; prefix/suffix contribute
+      // short escapes and `\uXXXX` escapes when they contain control or
+      // quote characters.
+      Json::string(content).stringify()
+    }
+    parse_succeeds(text) == false && @json.valid(text) == false
+  })
+}
+
+///|
+/// Every spelling of zero (`-0`, `-0.00`, `-0e13`, `0.0E-7`, ...) must parse
+/// to an IEEE-754 zero whose sign bit matches the literal's sign. The
+/// integer spelling `-0` used to lose the sign because the integer fast path
+/// negated an `Int64` (where `-0 == 0`) before converting to `Double`.
+test "zero literals preserve the sign of zero" {
+  @quickcheck.check((input : (Bool, Int, Int, Int)) => {
+    let (negative, raw_frac, raw_exp_kind, raw_exp) = input
+    let text = StringBuilder()
+    if negative {
+      text.write_char('-')
+    }
+    text.write_char('0')
+    let frac_digits = wrap_index(raw_frac, 4)
+    if frac_digits > 0 {
+      text.write_char('.')
+      text.write_string("0".repeat(frac_digits))
+    }
+    match wrap_index(raw_exp_kind, 4) {
+      0 => ()
+      1 => text.write_string("e" + wrap_index(raw_exp, 400).to_string())
+      2 => text.write_string("E+" + wrap_index(raw_exp, 400).to_string())
+      _ => text.write_string("e-" + wrap_index(raw_exp, 400).to_string())
+    }
+    guard @json.parse(text.to_string()) is Number(n, ..) else { return false }
+    n == 0.0 && (n.reinterpret_as_int64() < 0L) == negative
+  })
+}
+
+///|
+/// Integer literals over the full Int64/UInt64 range: `parse` must produce
+/// the correctly rounded double, and `stringify` must reproduce the source
+/// text exactly — beyond 2^53 that requires the preserved `repr`.
+test "integer literals roundtrip through parse and stringify textually" {
+  @quickcheck.check((x : Int64) => {
+    let text = x.to_string()
+    let parsed = @json.parse(text)
+    parsed == Json::number(x.to_double()) && parsed.stringify() == text
+  })
+  @quickcheck.check((x : UInt64) => {
+    let text = x.to_string()
+    let parsed = @json.parse(text)
+    parsed == Json::number(x.to_double()) && parsed.stringify() == text
+  })
+}
+
+///|
+/// Inserting random legal whitespace (space, tab, CR, LF) around structural
+/// tokens never changes the parsed value.
+test "whitespace between tokens does not change the parsed value" {
+  @quickcheck.check((input : (ArbJson, UInt64)) => {
+    let (json, seed) = input
+    let rng = @splitmix.new(seed~)
+    let ws : ReadOnlyArray[Char] = [' ', '\t', '\n', '\r']
+    let text = json.0.stringify()
+    let buf = StringBuilder()
+    fn maybe_ws() {
+      if rng.next_uint() % 2 == 0 {
+        let n = (rng.next_uint() % 3).reinterpret_as_int()
+        for _ in 0..<(n + 1) {
+          buf.write_char(ws[(rng.next_uint() % 4).reinterpret_as_int()])
+        }
+      }
+    }
+
+    maybe_ws()
+    let mut in_string = false
+    let mut escaped = false
+    for unit in text.code_units() {
+      let c = unit.to_int().unsafe_to_char()
+      buf.write_char(c)
+      if in_string {
+        if escaped {
+          escaped = false
+        } else if c == '\\' {
+          escaped = true
+        } else if c == '"' {
+          in_string = false
+          maybe_ws()
+        }
+      } else {
+        match c {
+          '"' => in_string = true
+          '[' | ']' | '{' | '}' | ',' | ':' => maybe_ws()
+          _ => ()
+        }
+      }
+    }
+    maybe_ws()
+    @json.parse(buf.to_string()) == json.0
+  })
+}
+
+///|
+/// Objects spelled with duplicate keys parse with the last occurrence of
+/// each key winning, matching `Map` insert semantics.
+test "duplicate object keys: last occurrence wins" {
+  @quickcheck.check((entries : Array[(Int, Int)]) => {
+    let text = StringBuilder()
+    text.write_char('{')
+    let expected : Map[String, Json] = Map([])
+    for i, entry in entries {
+      let (raw_key, value) = entry
+      // A pool of three keys guarantees duplicates in most runs.
+      let key = "k" + wrap_index(raw_key, 3).to_string()
+      if i > 0 {
+        text.write_char(',')
+      }
+      text.write_string("\"" + key + "\":" + value.to_string())
+      expected[key] = Json::number(value.to_double())
+    }
+    text.write_char('}')
+    @json.parse(text.to_string()) == Json::object(expected)
+  })
+}
+
+///|
+/// Deleting or replacing one UTF-16 code unit of a valid document must keep
+/// the parser total: it either succeeds or raises a parse error (`valid`
+/// agrees with `parse`), and when the mutant still parses, the parsed value
+/// is a fixed point of restringify-and-reparse.
+///
+/// Mutating at the code-unit level (not the `Char` level) means an astral
+/// character can lose half of its surrogate pair, and the replacement unit —
+/// drawn from the full 16-bit range — can itself be a lone surrogate.
+test "parse stays total under single code-unit deletion and replacement" {
+  @quickcheck.check((input : (ArbJson, Int, Int)) => {
+    let (json, position, raw_replacement) = input
+    let units = json.0.stringify().code_units()
+    guard units.length() > 0 else { return true }
+    let idx = wrap_index(position, units.length())
+    let replacement = wrap_index(raw_replacement, 0x10000)
+    let deleted = StringBuilder(size_hint=units.length())
+    let replaced = StringBuilder(size_hint=units.length())
+    for i, unit in units {
+      if i != idx {
+        deleted.write_char(unit.to_int().unsafe_to_char())
+        replaced.write_char(unit.to_int().unsafe_to_char())
+      } else {
+        replaced.write_char(replacement.unsafe_to_char())
+      }
+    }
+    for mutant in [deleted.to_string(), replaced.to_string()] {
+      guard parse_succeeds(mutant) == @json.valid(mutant) else { return false }
+      if @json.valid(mutant) {
+        let value = @json.parse(mutant)
+        guard @json.parse(value.stringify()) == value else { return false }
+      }
+    }
+    true
+  })
+}
+
+///|
+/// The default nesting limit is exactly 1024: a document 1024 levels deep
+/// parses (and roundtrips), 1025 levels raises `DepthLimitExceeded`, for
+/// both arrays and objects. Also pins that `stringify` itself is iterative
+/// and survives a 1024-deep tree on every backend.
+test "default nesting limit boundary at depth 1024" {
+  fn outcome(text : String) -> String {
+    try {
+      ignore(@json.parse(text))
+      "parsed"
+    } catch {
+      DepthLimitExceeded => "depth limit"
+      _ => "other error"
+    }
+  }
+
+  let deep_array = "[".repeat(1024) + "0" + "]".repeat(1024)
+  assert_eq(outcome(deep_array), "parsed")
+  let too_deep_array = "[".repeat(1025) + "0" + "]".repeat(1025)
+  assert_eq(outcome(too_deep_array), "depth limit")
+  let deep_object = "{\"k\":".repeat(1024) + "0" + "}".repeat(1024)
+  assert_eq(outcome(deep_object), "parsed")
+  let too_deep_object = "{\"k\":".repeat(1025) + "0" + "}".repeat(1025)
+  assert_eq(outcome(too_deep_object), "depth limit")
+  let mut tree : Json = Json::number(0.0)
+  for _ in 0..<1024 {
+    tree = Json::array([tree])
+  }
+  assert_true(@json.parse(tree.stringify()) == tree)
+}
+
+///|
+/// Deterministic pins for the surrogate cases the properties above explore
+/// randomly, so a regression shows up with a readable diff. Parsed strings
+/// stay Unicode well-formed: every unpaired surrogate — raw or escaped — is
+/// a clean parse error, never an abort and never an ill-formed string.
+test "surrogate handling pins" {
+  let lone_high = String::from_array([(0xD800).unsafe_to_char()])
+  let lone_low = String::from_array([(0xDC00).unsafe_to_char()])
+  // Raw lone surrogates, escape-free (fast path).
+  assert_false(@json.valid("\"" + lone_high + "\""))
+  assert_false(@json.valid("\"" + lone_low + "\""))
+  // A reversed pair (low then high) is two unpaired surrogates.
+  assert_false(@json.valid("\"" + lone_low + lone_high + "\""))
+  // Raw lone surrogates next to escapes (slow path; used to abort the
+  // process via checked slicing in `flush`).
+  assert_false(@json.valid("\"" + lone_low + "\\n\""))
+  assert_false(@json.valid("\"\\n" + lone_low + "\""))
+  assert_false(@json.valid("\"" + lone_high + "\\t\""))
+  // Escaped lone surrogates.
+  assert_false(@json.valid("\"\\uD800\""))
+  assert_false(@json.valid("\"\\uDC00\""))
+  assert_false(@json.valid("\"\\uD800\\uD800\""))
+  // Mixed raw/escaped halves do not pair up.
+  assert_false(@json.valid("\"\\uD800" + lone_low + "\""))
+  assert_false(@json.valid("\"" + lone_high + "\\uDC00\""))
+  // Well-formed pairs still parse, raw or escaped.
+  assert_true(@json.parse("\"\\uD83D\\uDE00\"") == Json::string("\u{1F600}"))
+  assert_true(@json.parse("\"\u{1F600}\"") == Json::string("\u{1F600}"))
+}
diff --git a/json/quickcheck_test.mbt b/json/quickcheck_test.mbt
index ccec11b9bf..d3d66d8d2f 100644
--- a/json/quickcheck_test.mbt
+++ b/json/quickcheck_test.mbt
@@ -113,16 +113,16 @@ impl @quickcheck.Arbitrary for ArbJson with fn arbitrary(size, state) {
 /// counterexamples.
 fn shrink_json(json : Json) -> Iter[Json] {
   match json {
-    Null => Iter::empty()
-    True | False => Iter::singleton(Json::null())
+    Null => [||]
+    True | False => [|Json::null()|]
     Number(n, ..) =>
       if n == 0.0 {
-        Iter::singleton(Json::null())
+        [|Json::null()|]
       } else {
-        [Json::null(), Json::number(0.0)].iter()
+        [|Json::null(), Json::number(0.0)|]
       }
     String(s) =>
-      Iter::singleton(Json::null()).concat(
+      [|Json::null()|].concat(
         @shrink.Shrink::shrink(s).map(smaller => Json::string(smaller)),
       )
     Array(elements) => {
@@ -143,10 +143,7 @@ fn shrink_json(json : Json) -> Iter[Json] {
             Json::array(copy)
           })
         })
-      Iter::singleton(Json::null())
-      .concat(elements.iter())
-      .concat(dropped)
-      .concat(replaced)
+      [|Json::null()|].concat(elements.iter()).concat(dropped).concat(replaced)
     }
     Object(members) => {
       let pairs = members.to_array()
@@ -173,7 +170,7 @@ fn shrink_json(json : Json) -> Iter[Json] {
             Json::object(copy)
           })
         })
-      Iter::singleton(Json::null())
+      [|Json::null()|]
       .concat(pairs.iter().map(pair => pair.1))
       .concat(dropped)
       .concat(replaced)
diff --git a/json/stringify_escape_bench_test.mbt b/json/stringify_escape_bench_test.mbt
new file mode 100644
index 0000000000..de7ae652d6
--- /dev/null
+++ b/json/stringify_escape_bench_test.mbt
@@ -0,0 +1,45 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+let stringify_escape_bench_count = 2048
+
+///|
+fn make_stringify_escape_bench_array(value : String) -> Json {
+  Array::makei(stringify_escape_bench_count, i => value + i.to_string()).to_json()
+}
+
+///|
+test "bench Json::stringify strings no escape n=2048" (it : @bench.T) {
+  let json = make_stringify_escape_bench_array("moonbit-core-json-value-")
+  it.bench(fn() { it.keep(json.stringify().length()) })
+}
+
+///|
+test "bench Json::stringify strings slash no escape n=2048" (it : @bench.T) {
+  let json = make_stringify_escape_bench_array("moonbit/core/json/value/")
+  it.bench(fn() { it.keep(json.stringify().length()) })
+}
+
+///|
+test "bench Json::stringify strings slash escaped n=2048" (it : @bench.T) {
+  let json = make_stringify_escape_bench_array("moonbit/core/json/value/")
+  it.bench(fn() { it.keep(json.stringify(escape_slash=true).length()) })
+}
+
+///|
+test "bench Json::stringify strings quotes controls n=2048" (it : @bench.T) {
+  let json = make_stringify_escape_bench_array("moonbit\"core\\json\nvalue")
+  it.bench(fn() { it.keep(json.stringify().length()) })
+}
diff --git a/json/stringify_indent_bench_test.mbt b/json/stringify_indent_bench_test.mbt
new file mode 100644
index 0000000000..563e8b1f90
--- /dev/null
+++ b/json/stringify_indent_bench_test.mbt
@@ -0,0 +1,69 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+fn make_deep_wide_json() -> Json {
+  let mut value = Array::makei(10000, i => Json::number(i.to_double())).to_json()
+  for _ in 0..<6 {
+    value = Json::array([value])
+  }
+  value
+}
+
+///|
+/// A document shaped like ordinary API/config output: a few hundred small
+/// objects nested four deep. Unlike `make_deep_wide_json`, this does not park
+/// every separator past the old 8-space lookup cutoff, so it reflects what
+/// pretty-printing actually costs on realistic input.
+fn make_shallow_wide_json() -> Json {
+  let rows = Array::makei(500, i => {
+    let row : Json = {
+      "id": Json::number(i.to_double()),
+      "name": Json::string("item-\{i}"),
+      "meta": {
+        "active": Json::boolean(i % 2 == 0),
+        "score": Json::number(1.5),
+      },
+    }
+    row
+  })
+  { "items": rows.to_json(), "total": Json::number(500.0) }
+}
+
+///|
+test "bench Json::stringify deep wide indent=2 n=10000" (it : @bench.T) {
+  let json = make_deep_wide_json()
+  it.bench(fn() { it.keep(json.stringify(indent=2).length()) })
+}
+
+///|
+/// The default, and by far the most common, path. The indentation cache is
+/// never consulted here, so this guards the compact path against regressions
+/// from indentation work.
+test "bench Json::stringify compact n=10000" (it : @bench.T) {
+  let json = make_deep_wide_json()
+  it.bench(fn() { it.keep(json.stringify().length()) })
+}
+
+///|
+test "bench Json::stringify shallow wide indent=2 n=500" (it : @bench.T) {
+  let json = make_shallow_wide_json()
+  it.bench(fn() { it.keep(json.stringify(indent=2).length()) })
+}
+
+///|
+test "bench Json::stringify shallow wide compact n=500" (it : @bench.T) {
+  let json = make_shallow_wide_json()
+  it.bench(fn() { it.keep(json.stringify().length()) })
+}
diff --git a/json/stringify_indent_test.mbt b/json/stringify_indent_test.mbt
new file mode 100644
index 0000000000..5f152fbb1f
--- /dev/null
+++ b/json/stringify_indent_test.mbt
@@ -0,0 +1,103 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+/// Counts the leading spaces of a line. The oracle below compares this against
+/// `indent * level` computed straight from the definition, rather than against
+/// anything the implementation produces, so it cannot reproduce a bug in the
+/// cached version.
+fn leading_spaces(line : StringView) -> Int {
+  let mut n = 0
+  for c in line {
+    if c != ' ' {
+      break
+    }
+    n += 1
+  }
+  n
+}
+
+///|
+/// `[[[...[0]...]]]` nested `depth` levels deep.
+fn nest(depth : Int) -> Json {
+  let mut value : Json = Json::number(0.0)
+  for _ in 0..
+    InvalidChar({ line, column, }, c) =>
       logger <+
         $|Invalid character \{c.escape()} at line \{line}, column \{column}
-    InvalidEof => logger.write_string("Unexpected end of file")
-    InvalidNumber({ line, column }, s) =>
+    InvalidEof => logger <+ "Unexpected end of file"
+    InvalidNumber({ line, column, }, s) =>
       logger <+
         $|Invalid number \{s} at line \{line}, column \{column}
-    InvalidIdentEscape({ line, column }) =>
+    InvalidIdentEscape({ line, column, }) =>
       logger <+
         $|Invalid escape sequence in identifier at line \{line}, column \{column}
     DepthLimitExceeded =>
-      logger.write_string(
-        "Depth limit exceeded, please increase the max_nesting_depth parameter",
-      )
+      logger <+
+        $|Depth limit exceeded, please increase the max_nesting_depth parameter
   }
 }
 
@@ -57,34 +56,22 @@ pub impl Show for Json
 #warnings("-deprecated")
 pub impl Show for Json with fn output(self, logger) {
   match self {
-    Null => logger.write_string("Null")
-    True => logger.write_string("True")
-    False => logger.write_string("False")
+    Null => logger <+ "Null"
+    True => logger <+ "True"
+    False => logger <+ "False"
     Number(n, repr~) => {
-      logger.write_string("Number(")
-      Show::output(n, logger)
-      if repr is Some(repr) {
-        logger.write_string(", repr=")
-        logger.write_string("Some(")
-        Show::output(repr, logger)
-        logger.write_string(")")
+      fn write_contents(cb : &Logger) -> Unit {
+        Show::output(n, cb)
+        if repr is Some(repr) {
+          cb.write_string(", repr=Some(")
+          Show::output(repr, cb)
+          cb.write_string(")")
+        }
       }
-      logger.write_string(")")
-    }
-    String(s) => {
-      logger.write_string("String(")
-      Show::output(s, logger)
-      logger.write_string(")")
-    }
-    Array(a) => {
-      logger.write_string("Array(")
-      Show::output(a, logger)
-      logger.write_string(")")
-    }
-    Object(o) => {
-      logger.write_string("Object(")
-      Show::output(o, logger)
-      logger.write_string(")")
+      logger <+ "Number(\{cb => write_contents(cb)})"
     }
+    String(s) => logger <+ "String(\{cb => Show::output(s, cb)})"
+    Array(a) => logger <+ "Array(\{cb => Show::output(a, cb)})"
+    Object(o) => logger <+ "Object(\{cb => Show::output(o, cb)})"
   }
 }
diff --git a/json/types_test.mbt b/json/types_test.mbt
index 92e4197100..9b9740c9da 100644
--- a/json/types_test.mbt
+++ b/json/types_test.mbt
@@ -14,10 +14,13 @@
 
 ///|
 test "ParseError::to_string coverage" {
-  let invalidCharError = @json.InvalidChar({ line: 1, column: 0 }, 'a')
+  let invalidCharError = @json.InvalidChar({ line: 1, column: 0, }, 'a')
   let invalidEofError = @json.InvalidEof
-  let invalidNumberError = @json.InvalidNumber({ line: 1, column: 0 }, "123abc")
-  let invalidIdentEscapeError = @json.InvalidIdentEscape({ line: 1, column: 0 })
+  let invalidNumberError = @json.InvalidNumber(
+    { line: 1, column: 0, },
+    "123abc",
+  )
+  let invalidIdentEscapeError = @json.InvalidIdentEscape({ line: 1, column: 0, })
   assert_eq(
     invalidCharError.to_string(),
     "Invalid character 'a' at line 1, column 0",
@@ -35,7 +38,7 @@ test "ParseError::to_string coverage" {
 
 ///|
 test "Debug for Position" {
-  let pos : @json.Position = { line: 3, column: 7 }
+  let pos : @json.Position = { line: 3, column: 7, }
   @debug.debug_inspect(
     pos,
     content=(
diff --git a/json/utils.mbt b/json/utils.mbt
index 25eef4808c..fa7145cb54 100644
--- a/json/utils.mbt
+++ b/json/utils.mbt
@@ -21,7 +21,7 @@ fn offset_to_position(input : StringView, offset : Int) -> Position {
       continue line, column + 1
     }
   } nobreak {
-    { line, column }
+    { line, column, }
   }
 }
 
diff --git a/lazy/lazy.mbt b/lazy/lazy.mbt
index bb144811e8..d1aa60fce9 100644
--- a/lazy/lazy.mbt
+++ b/lazy/lazy.mbt
@@ -86,7 +86,7 @@ struct Lazy[A] {
 /// ```
 #owned(thunk)
 pub fn[A] Lazy::Lazy(thunk : () -> A) -> Lazy[A] {
-  { state: Unforced(thunk) }
+  { state: Unforced(thunk), }
 }
 
 ///|
@@ -101,7 +101,7 @@ pub fn[A] Lazy::Lazy(thunk : () -> A) -> Lazy[A] {
 /// ```
 #owned(value)
 pub fn[A] Lazy::ready(value : A) -> Lazy[A] {
-  { state: Forced(value) }
+  { state: Forced(value), }
 }
 
 ///|
diff --git a/lazy_list/README.mbt.md b/lazy_list/README.mbt.md
index 51239e8aa5..d973099277 100644
--- a/lazy_list/README.mbt.md
+++ b/lazy_list/README.mbt.md
@@ -1,9 +1,9 @@
 # LazyList
 
-A persistent, re-traversable linked list with memoized lazy tails. Built on
-top of `@lazy.Lazy`. Use it when you have a pull-based source (`Iter`,
-generator, recursive definition) that you want to consume *more than once*
-without re-running the underlying computation.
+A persistent, re-traversable linked list with memoized lazy tails. Use it
+when you have a pull-based source (`Iter`, generator, recursive definition)
+that you want to consume *more than once* without re-running the underlying
+computation.
 
 ## Table of Contents
 
@@ -13,7 +13,7 @@ without re-running the underlying computation.
 4. [Observing](#observing)
 5. [Transforming](#transforming)
 6. [Consuming strictly](#consuming-strictly)
-7. [Inspecting with `@debug.Debug`](#inspecting-with-debug)
+7. [Inspecting with `@debug.Debug`](#inspecting-with-debugdebug)
 8. [Design trade-offs](#design-trade-offs)
 
 ---
@@ -45,6 +45,10 @@ so a second traversal walks the cached cells instead of re-evaluating.
 This shape mirrors Haskell's lazy lists / Scala's `LazyList` and is sometimes
 called "odd-style" laziness.
 
+Caching in place is a mutation, so a `LazyList` is not thread-safe even though
+it is persistent: traversing one from two threads needs external
+synchronization, the same contract `@lazy.Lazy` documents.
+
 ---
 
 ## Constructing
@@ -95,7 +99,7 @@ without re-running the source.
 ```mbt check
 ///|
 test {
-  let xs = @lazy_list.from_iter([1, 2, 3].iter())
+  let xs = @lazy_list.from_iter([|1, 2, 3|])
   // First traversal computes and caches cells.
   debug_inspect(xs.to_array(), content="[1, 2, 3]")
   // Second traversal walks the cache — no further pulls from the iter.
@@ -110,7 +114,7 @@ list, an array, an unfolding generator. We deliberately don't provide
 ```mbt check
 ///|
 test {
-  let from_arr = @lazy_list.from_iter([1, 2, 3].iter())
+  let from_arr = @lazy_list.from_iter([|1, 2, 3|])
   let from_list = @lazy_list.from_iter(@list.List([4, 5, 6]).iter())
   debug_inspect(from_arr.to_array(), content="[1, 2, 3]")
   debug_inspect(from_list.to_array(), content="[4, 5, 6]")
@@ -128,7 +132,7 @@ cell.
 ```mbt check
 ///|
 test {
-  let xs = @lazy_list.from_iter([1, 2, 3].iter())
+  let xs = @lazy_list.from_iter([|1, 2, 3|])
   inspect(xs.is_empty(), content="false")
   debug_inspect(xs.head(), content="Some(1)")
   debug_inspect(xs.tail().unwrap().head(), content="Some(2)")
@@ -141,7 +145,7 @@ fresh traversal — the underlying `LazyList` is not consumed.
 ```mbt check
 ///|
 test {
-  let xs = @lazy_list.from_iter([1, 2, 3].iter())
+  let xs = @lazy_list.from_iter([|1, 2, 3|])
   let i1 = xs.iter()
   let i2 = xs.iter()
   debug_inspect(i1.next(), content="Some(1)")
@@ -161,7 +165,7 @@ structure.
 ```mbt check
 ///|
 test {
-  let xs = @lazy_list.from_iter([1, 2, 3, 4, 5].iter())
+  let xs = @lazy_list.from_iter([|1, 2, 3, 4, 5|])
   debug_inspect(xs.map(x => x * x).to_array(), content="[1, 4, 9, 16, 25]")
   debug_inspect(xs.filter(x => x % 2 == 0).to_array(), content="[2, 4]")
   debug_inspect(xs.take(3).to_array(), content="[1, 2, 3]")
@@ -191,10 +195,10 @@ test {
 ```mbt check
 ///|
 test {
-  let xs = @lazy_list.from_iter([1, 2, 3].iter())
-  let ys = @lazy_list.from_iter(["a", "b", "c"].iter())
+  let xs = @lazy_list.from_iter([|1, 2, 3|])
+  let ys = @lazy_list.from_iter([|"a", "b", "c"|])
   debug_inspect(
-    xs.flat_map(x => @lazy_list.from_iter([x, x * 10].iter())).to_array(),
+    xs.flat_map(x => @lazy_list.from_iter([|x, x * 10|])).to_array(),
     content="[1, 10, 2, 20, 3, 30]",
   )
   debug_inspect(
@@ -217,7 +221,7 @@ through `@list.from_iter`:
 ```mbt check
 ///|
 test {
-  let xs = @lazy_list.from_iter([1, 2, 3].iter())
+  let xs = @lazy_list.from_iter([|1, 2, 3|])
   let strict : @list.List[Int] = @list.from_iter(xs.iter())
   @debug.debug_inspect(strict, content="")
 }
@@ -226,7 +230,7 @@ test {
 ```mbt check
 ///|
 test {
-  let xs = @lazy_list.from_iter([1, 2, 3, 4].iter())
+  let xs = @lazy_list.from_iter([|1, 2, 3, 4|])
   inspect(xs.fold(init=0, (acc, x) => acc + x), content="10")
   let total : Ref[Int] = Ref(0)
   xs.each(x => total.val = total.val + x)
@@ -241,7 +245,7 @@ full `Iter` combinator set:
 ```mbt check
 ///|
 test {
-  let xs = @lazy_list.from_iter([1, 2, 3, 4, 5].iter())
+  let xs = @lazy_list.from_iter([|1, 2, 3, 4, 5|])
   inspect(xs.iter().count(), content="5")
   debug_inspect(xs.iter().nth(2), content="Some(3)")
 }
@@ -286,6 +290,52 @@ and never trigger user-supplied thunks. The alternative, even-style
 emptiness check itself a forcing operation. Odd-style is what Haskell's `[]`
 and Scala's `LazyList` use, and it's simpler to reason about.
 
+### Why `concat` is stack-safe at any nesting
+
+How a chain of `concat`s is associated is the caller's choice, and the most
+natural way to write one is also the hardest case:
+
+```mbt check
+///|
+test {
+  let mut acc : @lazy_list.LazyList[Int] = @lazy_list.empty()
+  for i in 0..<10000 {
+    acc = acc.concat(@lazy_list.from_iter([|i|]))
+  }
+  debug_inspect(acc.take(3).to_array(), content="[0, 1, 2]")
+  inspect(acc.iter().count(), content="10000")
+}
+```
+
+Every step nests the previous result inside the *left* argument of the next
+`concat`, so the value is `((([] ++ a) ++ b) ++ c) ++ …`. The obvious
+implementation — `lazy_cons(x, () => tail.force().concat(other))` — builds
+that in O(1) per step but cannot force it: the outermost tail thunk forces
+the one below it, which forces the one below that, one stack frame per `++`.
+It also re-nests the chain to the same depth after every element, which makes
+a full traversal quadratic.
+
+So the pending right-hand sides live on the cell's tail as *data* instead of
+being closed over by a thunk. Forcing walks down to the innermost tail in a
+loop, collecting what is still owed, and hands the remainder to the cell it
+produces. Stack use is constant in the nesting depth, and a full traversal is
+linear — including when you append and consume at the same time, since
+joining two pending sequences is O(1). `flat_map` suspends its inner lists
+through the same representation.
+
+What this does *not* cover is composing arbitrarily many lazy transforms:
+`xs.map(f).map(f)...` ten thousand deep nests ten thousand thunks, and so
+does `flat_map`, so forcing one cell walks all of them. That is a property of
+composed transforms rather than of `concat`'s associativity, and it is
+unchanged here.
+
+That suspended-append state is the one reason a cell's tail is not simply a
+`@lazy.Lazy`. It is otherwise the same thing — the same states, the same
+at-most-once guarantee, the same reentrancy check — and the state lives
+*inside* the memoized cell rather than wrapping one because a `LazyList`
+allocates a tail per element, so an extra box per cell would cost every
+traversal, not just the ones that go through `concat`.
+
 ### Why is `drop_while` eager but `take_while` lazy?
 
 ```mbt check
@@ -319,16 +369,17 @@ This is the same shape as Haskell:
 
 ```
 takeWhile p (x:xs) | p x = x : takeWhile p xs   -- lazy cons
-dropWhile p xs@(x:_) | p x = dropWhile p xs'    -- eager recursion
+dropWhile p xs@(x:xs') | p x = dropWhile p xs'  -- eager recursion
 ```
 
 ### Why doesn't `map` / `filter` / `take_while` / `flat_map` accept `raise?`?
 
-Because their callbacks fire from inside `@lazy.Lazy` thunks. Thunks are
-non-raising by design (see `@lazy/lazy.mbt`): a raising thunk would force
-every consumer of every lazy cell to handle the effect, and memoizing
-failure forces an awkward choice between "cache the error and re-raise on
-every retry" and "retry on every force."
+Because their callbacks fire from inside the memoized tail thunks. Those are
+non-raising by design, for the reasons `@lazy` sets out (see
+`@lazy/lazy.mbt`): a raising thunk would force every consumer of every lazy
+cell to handle the effect, and memoizing failure forces an awkward choice
+between "cache the error and re-raise on every retry" and "retry on every
+force."
 
 If you need a fallible transform, wrap the result yourself:
 
@@ -343,7 +394,7 @@ test {
     }
   }
 
-  let xs = @lazy_list.from_iter([1, 2, -1, 3].iter()).map(maybe_double)
+  let xs = @lazy_list.from_iter([|1, 2, -1, 3|]).map(maybe_double)
   let results = xs.to_array()
   debug_inspect(
     results,
diff --git a/lazy_list/concat_bench_test.mbt b/lazy_list/concat_bench_test.mbt
new file mode 100644
index 0000000000..0506c55790
--- /dev/null
+++ b/lazy_list/concat_bench_test.mbt
@@ -0,0 +1,79 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// The three shapes that decided the tail representation, kept runnable so
+// the trade-off can be re-measured instead of re-argued:
+//
+//   * `build and traverse` is the ordinary path, one tail allocated per
+//     element. It is why `Tail` subsumes `@lazy.Lazy` rather than wrapping
+//     one — a wrapper adds a box and a dispatch to every cell here, which
+//     measured ~30% on this benchmark.
+//   * `left-nested concat` is the shape from #4066: with a recursive
+//     forcing path it overflows the stack rather than running slowly.
+//   * `concat while consuming` is why the pending sequence is a catenable
+//     tree — a flat list copies the whole backlog on every step.
+
+///|
+let concat_bench_len : Int = 2048
+
+///|
+fn bench_left_nested(depth : Int) -> @lazy_list.LazyList[Int] {
+  let mut acc : @lazy_list.LazyList[Int] = @lazy_list.empty()
+  for i in 0.. i)
+  it.bench(fn() {
+    let xs = @lazy_list.from_iter(source.iter())
+    it.keep(xs.iter().count())
+  })
+}
+
+///|
+test "bench LazyList re-traverse a forced list n=2048" (it : @bench.T) {
+  let xs = @lazy_list.from_iter(Array::makei(concat_bench_len, i => i).iter())
+  it.keep(xs.iter().count())
+  // Every cell is cached now, so this measures the walk alone.
+  it.bench(fn() { it.keep(xs.iter().count()) })
+}
+
+///|
+test "bench LazyList left-nested concat, build only n=2048" (it : @bench.T) {
+  it.bench(fn() { it.keep(bench_left_nested(concat_bench_len).is_empty()) })
+}
+
+///|
+test "bench LazyList left-nested concat, build and traverse n=2048" (
+  it : @bench.T,
+) {
+  it.bench(fn() { it.keep(bench_left_nested(concat_bench_len).iter().count()) })
+}
+
+///|
+test "bench LazyList concat while consuming n=2048" (it : @bench.T) {
+  let source = Array::makei(concat_bench_len + 1, i => i)
+  it.bench(fn() {
+    let mut cur = @lazy_list.from_iter(source.iter())
+    for i in 0.. LazyList[A] {
-  { cell: Cons(head, tail=@lazy.Lazy::ready(tail)) }
+  { cell: Cons(head, tail=Tail::ready(tail)), }
 }
 
 // =====================================================================
@@ -41,7 +41,7 @@ fn[A] cons(head : A, tail : LazyList[A]) -> LazyList[A] {
 /// ```
 #as_free_fn
 pub fn[A] LazyList::empty() -> LazyList[A] {
-  { cell: Empty }
+  { cell: Empty, }
 }
 
 ///|
@@ -66,7 +66,7 @@ pub fn[A] LazyList::lazy_cons(
   head : A,
   tail : () -> LazyList[A],
 ) -> LazyList[A] {
-  { cell: Cons(head, tail=Lazy(tail)) }
+  { cell: Cons(head, tail=Tail::delay(tail)), }
 }
 
 ///|
@@ -79,7 +79,7 @@ pub fn[A] LazyList::lazy_cons(
 ///
 /// ```mbt check
 /// test {
-///   let xs = @lazy_list.from_iter([1, 2, 3].iter())
+///   let xs = @lazy_list.from_iter([|1, 2, 3|])
 ///   debug_inspect(xs.to_array(), content="[1, 2, 3]")
 ///   // The lazy list is re-traversable.
 ///   debug_inspect(xs.to_array(), content="[1, 2, 3]")
@@ -210,8 +210,8 @@ pub fn[A] LazyList::take_while(
 /// called eagerly during the skip; a raising `pred` propagates here.
 ///
 /// (Contrast with `map` / `filter` / `take_while` / `flat_map`, whose
-/// callbacks also run inside lazy thunks. `@lazy.Lazy` thunks are
-/// non-raising by design — see `@lazy`'s rationale — so those
+/// callbacks also run inside lazy thunks. Tail thunks are non-raising by
+/// design — the same rationale `@lazy` sets out — so those
 /// combinators cannot accept a raising callback. `drop_while` only runs
 /// `pred` during the strict skip phase, so it can.)
 pub fn[A] LazyList::drop_while(
@@ -228,6 +228,14 @@ pub fn[A] LazyList::drop_while(
 ///|
 /// Concatenates two lazy lists. The right-hand side is not forced until
 /// the left-hand side has been fully consumed.
+///
+/// Building and traversing a chain of `concat`s is stack-safe however the
+/// chain is associated, so the accumulator idiom `acc = acc.concat(xs)` is
+/// safe at any length. The pending right-hand sides are recorded in the
+/// resulting cell's tail as data rather than closed over by a recursive
+/// thunk, so forcing a left-nested chain `((a ++ b) ++ c) ++ d` flattens it
+/// with a loop instead of recursing once per `++`, and a full traversal
+/// stays linear. See `Tail::force`.
 #owned(other)
 pub fn[A] LazyList::concat(
   self : LazyList[A],
@@ -235,14 +243,21 @@ pub fn[A] LazyList::concat(
 ) -> LazyList[A] {
   match self.cell {
     Empty => other
-    Cons(x, tail~) => LazyList::lazy_cons(x, () => tail.force().concat(other))
+    Cons(x, tail~) =>
+      // Appending nothing is the identity; keeping empty segments out of
+      // the pending list keeps repeated `xs.concat(empty())` free.
+      if other.cell is Empty {
+        self
+      } else {
+        { cell: Cons(x, tail=tail.append(One(Tail::ready(other)))), }
+      }
   }
 }
 
 ///|
 /// Variant of `concat` whose second argument is itself a thunk; used
 /// internally by `flat_map` to chain lazy lists without forcing them
-/// prematurely.
+/// prematurely. Stack-safe under nesting for the same reason `concat` is.
 fn[A] LazyList::concat_lazy(
   self : LazyList[A],
   other_thunk : () -> LazyList[A],
@@ -250,7 +265,7 @@ fn[A] LazyList::concat_lazy(
   match self.cell {
     Empty => other_thunk()
     Cons(x, tail~) =>
-      LazyList::lazy_cons(x, () => tail.force().concat_lazy(other_thunk))
+      { cell: Cons(x, tail=tail.append(One(Tail::delay(other_thunk)))), }
   }
 }
 
diff --git a/lazy_list/lazy_list_test.mbt b/lazy_list/lazy_list_test.mbt
index d6a23fb3ad..d799240540 100644
--- a/lazy_list/lazy_list_test.mbt
+++ b/lazy_list/lazy_list_test.mbt
@@ -22,7 +22,7 @@ test "empty / is_empty / head / tail" {
 
 ///|
 test "lazy_cons / head / tail" {
-  let ys = @lazy_list.from_iter([2, 3].iter())
+  let ys = @lazy_list.from_iter([|2, 3|])
   let xs = @lazy_list.lazy_cons(1, () => ys)
   debug_inspect(xs.head(), content="Some(1)")
   debug_inspect(xs.tail().unwrap().to_array(), content="[2, 3]")
@@ -74,31 +74,31 @@ test "infinite streams are safe to take a prefix from" {
 
 ///|
 test "map / filter (lazy)" {
-  let xs = @lazy_list.from_iter([1, 2, 3, 4, 5].iter())
+  let xs = @lazy_list.from_iter([|1, 2, 3, 4, 5|])
   debug_inspect(xs.map(x => x + 10).to_array(), content="[11, 12, 13, 14, 15]")
   debug_inspect(xs.filter(x => x % 2 == 0).to_array(), content="[2, 4]")
 }
 
 ///|
 test "take / drop / take_while / drop_while" {
-  let xs = @lazy_list.from_iter([1, 2, 3, 4, 5].iter())
+  let xs = @lazy_list.from_iter([|1, 2, 3, 4, 5|])
   debug_inspect(xs.take(3).to_array(), content="[1, 2, 3]")
   debug_inspect(xs.take(0).to_array(), content="[]")
   debug_inspect(xs.take(99).to_array(), content="[1, 2, 3, 4, 5]")
   debug_inspect(xs.drop(2).to_array(), content="[3, 4, 5]")
   debug_inspect(xs.drop(99).to_array(), content="[]")
-  let ys = @lazy_list.from_iter([1, 2, 3, 4, 1, 2].iter())
+  let ys = @lazy_list.from_iter([|1, 2, 3, 4, 1, 2|])
   debug_inspect(ys.take_while(x => x < 3).to_array(), content="[1, 2]")
   debug_inspect(ys.drop_while(x => x < 3).to_array(), content="[3, 4, 1, 2]")
 }
 
 ///|
 test "concat / flat_map / zip" {
-  let xs = @lazy_list.from_iter([1, 2, 3].iter())
-  let ys = @lazy_list.from_iter([4, 5].iter())
+  let xs = @lazy_list.from_iter([|1, 2, 3|])
+  let ys = @lazy_list.from_iter([|4, 5|])
   debug_inspect(xs.concat(ys).to_array(), content="[1, 2, 3, 4, 5]")
   debug_inspect(
-    xs.flat_map(x => @lazy_list.from_iter([x, x * 10].iter())).to_array(),
+    xs.flat_map(x => @lazy_list.from_iter([|x, x * 10|])).to_array(),
     content="[1, 10, 2, 20, 3, 30]",
   )
   // flat_map skipping empty mappings.
@@ -119,7 +119,7 @@ test "concat / flat_map / zip" {
 
 ///|
 test "each / fold" {
-  let xs = @lazy_list.from_iter([10, 20, 30, 40].iter())
+  let xs = @lazy_list.from_iter([|10, 20, 30, 40|])
   inspect(xs.fold(init=0, (acc, x) => acc + x), content="100")
   let buf = []
   xs.each(x => buf.push(x))
@@ -128,7 +128,7 @@ test "each / fold" {
 
 ///|
 test "iter bridge is independent per call" {
-  let xs = @lazy_list.from_iter([1, 2, 3].iter())
+  let xs = @lazy_list.from_iter([|1, 2, 3|])
   let i1 = xs.iter()
   let i2 = xs.iter()
   debug_inspect(i1.next(), content="Some(1)")
@@ -161,8 +161,8 @@ suberror NegativeFound
 test "drop_while accepts a raising predicate" {
   // Strict skip phase: a raising predicate propagates immediately. The
   // lazy combinators (map / filter / take_while / flat_map) cannot
-  // accept raise? because they run the callback inside `@lazy.Lazy`
-  // thunks, which are non-raising by design.
+  // accept raise? because they run the callback inside the memoized
+  // tail thunks, which are non-raising by design.
   fn pred(x : Int) -> Bool raise NegativeFound {
     if x < 0 {
       raise NegativeFound
@@ -170,14 +170,14 @@ test "drop_while accepts a raising predicate" {
     x < 3
   }
 
-  let xs = @lazy_list.from_iter([1, 2, 3, 4].iter())
+  let xs = @lazy_list.from_iter([|1, 2, 3, 4|])
   debug_inspect(xs.drop_while(pred).to_array(), content="[3, 4]")
   // Short-circuits: pred(3) returns false, so pred(-1) is never called
   // and no error is raised even though -1 would trigger one.
-  let zs = @lazy_list.from_iter([1, 3, -1].iter())
+  let zs = @lazy_list.from_iter([|1, 3, -1|])
   debug_inspect(zs.drop_while(pred).to_array(), content="[3, -1]")
   // A predicate that raises mid-skip propagates out of drop_while.
-  let ys = @lazy_list.from_iter([1, -1, 5].iter())
+  let ys = @lazy_list.from_iter([|1, -1, 5|])
   try ys.drop_while(pred) catch {
     _ => ()
   } noraise {
@@ -225,7 +225,7 @@ test "zip's left bias: right-shorter forces one extra left tail" {
 ///|
 test "lazy semantics: map does not force tail until observed" {
   let pulls : Ref[Int] = Ref(0)
-  let xs = @lazy_list.from_iter([1, 2, 3].iter()).map(x => {
+  let xs = @lazy_list.from_iter([|1, 2, 3|]).map(x => {
     pulls.val += 1
     x * 2
   })
@@ -241,7 +241,7 @@ test "lazy semantics: map does not force tail until observed" {
 ///|
 test "Debug walks only the forced prefix" {
   // Fully forced: every element printed.
-  let strict = @lazy_list.from_iter([1, 2, 3].iter()).map(x => x)
+  let strict = @lazy_list.from_iter([|1, 2, 3|]).map(x => x)
   // Force everything.
   let _ = strict.to_array()
   @debug.debug_inspect(strict, content="")
@@ -262,3 +262,110 @@ test "Debug walks only the forced prefix" {
   let empty : @lazy_list.LazyList[Int] = @lazy_list.empty()
   @debug.debug_inspect(empty, content="")
 }
+
+// =====================================================================
+// Stack safety of `concat`.
+// =====================================================================
+
+///|
+/// `((([] ++ [from]) ++ [from + 1]) ++ ...) ++ [until - 1]` — every
+/// `concat` ends up inside the left argument of the next one, which is
+/// what the accumulator idiom `acc = acc.concat(...)` builds.
+fn left_nested_concat(from : Int, until : Int) -> @lazy_list.LazyList[Int] {
+  let mut acc : @lazy_list.LazyList[Int] = @lazy_list.empty()
+  for i in from.. @lazy_list.LazyList[Int] {
+  let mut acc : @lazy_list.LazyList[Int] = @lazy_list.empty()
+  for i = until - 1; i >= from; i = i - 1 {
+    acc = @lazy_list.from_iter([|i|]).concat(acc)
+  }
+  acc
+}
+
+///|
+test "concat: a left-nested chain forces without recursing" {
+  // Reaching the *second* element is already the whole problem: it is
+  // the first tail force, and it is the one that has to see through
+  // every pending append at once.
+  let xs = left_nested_concat(0, 50000)
+  debug_inspect(xs.take(3).to_array(), content="[0, 1, 2]")
+  // ...and the rest of the traversal has to stay linear: a forcing path
+  // that re-nests the chain after every step would not finish here.
+  inspect(xs.fold(init=0, (acc, x) => acc + x), content="1249975000")
+}
+
+///|
+test "concat: a right-nested chain forces without recursing" {
+  let xs = right_nested_concat(0, 50000)
+  debug_inspect(xs.take(3).to_array(), content="[0, 1, 2]")
+  inspect(xs.fold(init=0, (acc, x) => acc + x), content="1249975000")
+}
+
+///|
+test "concat: appending empty lists neither accumulates nor diverges" {
+  let mut acc = @lazy_list.from_iter([|1, 2, 3|])
+  for _ in 0..<50000 {
+    acc = acc.concat(@lazy_list.empty())
+  }
+  debug_inspect(acc.to_array(), content="[1, 2, 3]")
+}
+
+///|
+test "flat_map over a deeply nested concat is stack safe" {
+  // `flat_map` chains through `concat_lazy`, which shares `concat`'s
+  // representation, so it has to survive the same shape.
+  let xs = left_nested_concat(0, 20000).flat_map(x => {
+    @lazy_list.from_iter([|x, x|])
+  })
+  inspect(xs.iter().count(), content="40000")
+}
+
+///|
+test "concat leaves the right-hand side untouched until the left ends" {
+  let pulls : Ref[Int] = Ref(0)
+  let left = @lazy_list.from_iter([|1, 2|])
+  let right = @lazy_list.lazy_cons(3, () => {
+    pulls.val += 1
+    @lazy_list.empty()
+  })
+  let both = left.concat(right)
+  debug_inspect(both.take(3).to_array(), content="[1, 2, 3]")
+  // Only the right-hand *head* has been observed; its tail thunk has
+  // not run.
+  inspect(pulls.val, content="0")
+  debug_inspect(both.to_array(), content="[1, 2, 3]")
+  inspect(pulls.val, content="1")
+}
+
+///|
+test "concat keeps both sides memoized across traversals" {
+  let pulls : Ref[Int] = Ref(0)
+  fn counted(source : Array[Int]) -> @lazy_list.LazyList[Int] {
+    let underlying = source.iter()
+    @lazy_list.from_iter(
+      Iter::new(() => {
+        match underlying.next() {
+          None => None
+          Some(x) => {
+            pulls.val += 1
+            Some(x)
+          }
+        }
+      }),
+    )
+  }
+
+  let both = counted([1, 2]).concat(counted([3, 4]))
+  debug_inspect(both.to_array(), content="[1, 2, 3, 4]")
+  inspect(pulls.val, content="4")
+  // Second traversal walks cached cells only — no further pulls.
+  debug_inspect(both.to_array(), content="[1, 2, 3, 4]")
+  inspect(pulls.val, content="4")
+}
diff --git a/lazy_list/moon.pkg b/lazy_list/moon.pkg
index 5657807c9d..e66ce5c37b 100644
--- a/lazy_list/moon.pkg
+++ b/lazy_list/moon.pkg
@@ -1,10 +1,16 @@
 import {
   "moonbitlang/core/builtin",
-  "moonbitlang/core/lazy",
   "moonbitlang/core/array",
   "moonbitlang/core/debug",
 }
 
 import {
   "moonbitlang/core/list",
+  "moonbitlang/core/cmp",
+  "moonbitlang/core/quickcheck",
+  "moonbitlang/core/bench",
 } for "test"
+
+options(
+  targets: { "panic_test.mbt": [ "not", "native", "llvm" ] },
+)
diff --git a/lazy_list/panic_test.mbt b/lazy_list/panic_test.mbt
new file mode 100644
index 0000000000..900025fdfd
--- /dev/null
+++ b/lazy_list/panic_test.mbt
@@ -0,0 +1,35 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+test "panic reentrant force on a tail aborts" {
+  // A thunk that forces the very tail it is producing. Letting it through
+  // would re-run the thunk and break the at-most-once guarantee, so it
+  // aborts instead — the same trade-off `@lazy.Lazy::force` makes.
+  let slot : Array[@lazy_list.LazyList[Int]] = []
+  let xs = @lazy_list.lazy_cons(1, () => slot[0].tail().unwrap())
+  slot.push(xs)
+  ignore(xs.tail())
+}
+
+///|
+test "panic reentrant force through a suspended concat aborts" {
+  // Same, but the reentrant force lands on a suspended append rather
+  // than on a thunk.
+  let slot : Array[@lazy_list.LazyList[Int]] = []
+  let left = @lazy_list.lazy_cons(1, () => slot[0].tail().unwrap())
+  let both = left.concat(@lazy_list.from_iter([|2|]))
+  slot.push(both)
+  ignore(both.tail())
+}
diff --git a/lazy_list/quickcheck_test.mbt b/lazy_list/quickcheck_test.mbt
new file mode 100644
index 0000000000..ac22d81f53
--- /dev/null
+++ b/lazy_list/quickcheck_test.mbt
@@ -0,0 +1,718 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Specification tests for `LazyList`.
+//
+// A lazy list has two specifications that have to hold at once, and a
+// bug in either is invisible to a test that only checks the other:
+//
+//   * an *extensional* one — what elements come out. Here that is
+//     settled by an `Array` model: every combinator is mirrored by a
+//     three-line strict definition, and the two must agree. The
+//     properties drive whole randomly generated *pipelines* rather
+//     than single combinators, because the interesting failures in a
+//     lazy structure live in the composition (`flat_map` feeding
+//     `take`, `concat` feeding `zip`) and not in any one operation.
+//
+//   * an *intensional* one — how much gets forced, and when. That is
+//     the part that makes the structure worth having, and it is stated
+//     precisely in the doc comments: `take` forces "exactly the cells
+//     of the prefix — no look-ahead", `drop` forces "up to `n` cells",
+//     `head` "does not force the tail", tails are memoized so a thunk
+//     runs "at most once", and `concat` does not touch its right-hand
+//     side until the left is exhausted. Those are checked here as
+//     *exact thunk counts* against an instrumented infinite source —
+//     an implementation that quietly forced one cell too many would
+//     still pass every extensional test, but would turn `take` on an
+//     infinite list from a total function into a hang.
+
+// =====================================================================
+// Instrumented sources.
+// =====================================================================
+
+///|
+/// The infinite list `0, 1, 2, ...`, counting tail thunks as they run.
+///
+/// Cell `i`'s head is available without running any thunk; reaching
+/// cell `i + 1` runs exactly one. So "forced `n` cells deep" means
+/// `counter.val == n`.
+fn nats(counter : Ref[Int]) -> @lazy_list.LazyList[Int] {
+  fn from(i : Int) -> @lazy_list.LazyList[Int] {
+    @lazy_list.lazy_cons(i, () => {
+      counter.val += 1
+      from(i + 1)
+    })
+  }
+
+  from(0)
+}
+
+///|
+/// The finite list `0 ..< length`, counting tail thunks as they run.
+/// Reaching the end runs one thunk per element, the last of which
+/// yields `Empty`.
+fn finite(length : Int, counter : Ref[Int]) -> @lazy_list.LazyList[Int] {
+  fn from(i : Int) -> @lazy_list.LazyList[Int] {
+    if i >= length {
+      @lazy_list.empty()
+    } else {
+      @lazy_list.lazy_cons(i, () => {
+        counter.val += 1
+        from(i + 1)
+      })
+    }
+  }
+
+  from(0)
+}
+
+///|
+/// Maps an arbitrary `Int` into `0.. Int {
+  let r = value % modulus
+  if r < 0 {
+    r + modulus
+  } else {
+    r
+  }
+}
+
+// =====================================================================
+// The Array model.
+// =====================================================================
+
+///|
+/// One step of a randomly generated pipeline. Each variant carries the
+/// parameters the combinator needs, already wrapped into a sensible
+/// range by `decode_op`.
+priv enum Op {
+  Map(Int)
+  Filter(Int, Int)
+  Take(Int)
+  Drop(Int)
+  TakeWhile(Int)
+  DropWhile(Int)
+  Concat(Array[Int])
+  FlatMap(Int)
+  Zip(Array[Int])
+} derive(@debug.Debug)
+
+///|
+/// Turns an arbitrary `(Int, Int)` into an `Op`. Every combination is
+/// meaningful, so the generator never has to discard a case.
+fn decode_op(tag : Int, value : Int) -> Op {
+  let side = Array::makei(wrap_index(value, 5), i => value + i * 7)
+  match wrap_index(tag, 9) {
+    0 => Map(value % 100)
+    1 => Filter(2 + wrap_index(value, 4), wrap_index(value / 4, 2))
+    2 => Take(value % 12)
+    3 => Drop(value % 12)
+    4 => TakeWhile(value % 12)
+    5 => DropWhile(value % 12)
+    6 => Concat(side)
+    // `FlatMap(0)` maps every element to the empty list, which is the
+    // case `flat_map`'s iterative empty-skipping exists to handle.
+    7 => FlatMap(wrap_index(value, 3))
+    _ => Zip(side)
+  }
+}
+
+///|
+/// The predicate `Filter` and the `*While` ops share, kept in one place
+/// so the model and the lazy list cannot drift apart on the predicate
+/// itself — only on how it is applied.
+fn keep(x : Int, modulus : Int, residue : Int) -> Bool {
+  wrap_index(x, modulus) == residue
+}
+
+///|
+/// `FlatMap(k)`'s expansion of a single element.
+fn expand(x : Int, k : Int) -> Array[Int] {
+  match k {
+    0 => []
+    1 => [x]
+    _ => [x, x + 1000]
+  }
+}
+
+///|
+/// How `Zip` recombines a pair into an `Int`, so pipelines stay
+/// monomorphic and can be chained to any depth.
+fn combine(a : Int, b : Int) -> Int {
+  a * 31 + b
+}
+
+///|
+/// The model: each `Op` as the obvious strict `Array` computation.
+fn apply_model(xs : Array[Int], op : Op) -> Array[Int] {
+  match op {
+    Map(k) => xs.map(x => x + k)
+    Filter(m, r) => xs.filter(x => keep(x, m, r))
+    Take(n) => {
+      let n = if n < 0 { 0 } else if n > xs.length() { xs.length() } else { n }
+      xs[0:n].to_owned()
+    }
+    Drop(n) => {
+      let n = if n < 0 { 0 } else if n > xs.length() { xs.length() } else { n }
+      xs[n:].to_owned()
+    }
+    TakeWhile(k) => {
+      let out = []
+      for x in xs {
+        if x >= k {
+          break
+        }
+        out.push(x)
+      }
+      out
+    }
+    DropWhile(k) => {
+      let mut i = 0
+      while i < xs.length() && xs[i] < k {
+        i += 1
+      }
+      xs[i:].to_owned()
+    }
+    Concat(other) => xs + other
+    FlatMap(k) => {
+      let out = []
+      for x in xs {
+        for y in expand(x, k) {
+          out.push(y)
+        }
+      }
+      out
+    }
+    Zip(other) => {
+      let out = []
+      for i in 0..<@cmp.minimum(xs.length(), other.length()) {
+        out.push(combine(xs[i], other[i]))
+      }
+      out
+    }
+  }
+}
+
+///|
+/// The same `Op`, applied to a `LazyList`.
+fn apply_lazy(
+  xs : @lazy_list.LazyList[Int],
+  op : Op,
+) -> @lazy_list.LazyList[Int] {
+  match op {
+    Map(k) => xs.map(x => x + k)
+    Filter(m, r) => xs.filter(x => keep(x, m, r))
+    Take(n) => xs.take(n)
+    Drop(n) => xs.drop(n)
+    TakeWhile(k) => xs.take_while(x => x < k)
+    DropWhile(k) => xs.drop_while(x => x < k)
+    Concat(other) => xs.concat(@lazy_list.from_iter(other.iter()))
+    FlatMap(k) => xs.flat_map(x => @lazy_list.from_iter(expand(x, k).iter()))
+    Zip(other) =>
+      xs
+      .zip(@lazy_list.from_iter(other.iter()))
+      .map(pair => combine(pair.0, pair.1))
+  }
+}
+
+// =====================================================================
+// Extensional specification: agreement with the model.
+// =====================================================================
+
+///|
+test "quickcheck: a pipeline of combinators agrees with the Array model" {
+  @quickcheck.check(
+    (input : (Array[Int], Array[(Int, Int)])) => {
+      let (source, steps) = input
+      let mut lazy_result = @lazy_list.from_iter(source.iter())
+      let mut model = source
+      for step in steps {
+        let op = decode_op(step.0, step.1)
+        lazy_result = apply_lazy(lazy_result, op)
+        model = apply_model(model, op)
+      }
+      lazy_result.to_array() == model
+    },
+    counterexample_context=input => {
+      let ops = input.1.map(step => decode_op(step.0, step.1))
+      "source=" + @debug.to_string(input.0) + " ops=" + @debug.to_string(ops)
+    },
+    count=2000,
+  )
+}
+
+///|
+test "quickcheck: the queries agree with the model" {
+  @quickcheck.check(
+    (source : Array[Int]) => {
+      let xs = @lazy_list.from_iter(source.iter())
+      xs.is_empty() == source.is_empty() &&
+      xs.head() == source.get(0) &&
+      (match xs.tail() {
+        None => source.is_empty()
+        Some(rest) => rest.to_array() == source[1:].to_owned()
+      }) &&
+      xs.fold(init=0, (acc, x) => acc * 3 + x) ==
+      source.fold(init=0, (acc, x) => acc * 3 + x) &&
+      xs.iter().to_array() == source
+    },
+    count=1000,
+  )
+}
+
+///|
+test "quickcheck: each visits every element in order, exactly once" {
+  @quickcheck.check(
+    (source : Array[Int]) => {
+      let seen = []
+      @lazy_list.from_iter(source.iter()).each(x => seen.push(x))
+      seen == source
+    },
+    count=500,
+  )
+}
+
+// =====================================================================
+// Algebraic laws.
+// =====================================================================
+
+///|
+test "quickcheck: functor and monad laws" {
+  @quickcheck.check(
+    (input : (Array[Int], Int, Int)) => {
+      let (source, j, k) = input
+      let xs = () => @lazy_list.from_iter(source.iter())
+      let f = (x : Int) => x + j
+      let g = (x : Int) => x * k
+      // map id == id, and map is composition-preserving
+      xs().map(x => x).to_array() == source &&
+      xs().map(f).map(g).to_array() == xs().map(x => g(f(x))).to_array() &&
+      // flat_map with a singleton is map (the monad's right identity
+      // composed with map)
+      xs().flat_map(x => @lazy_list.from_iter([|f(x)|])).to_array() ==
+      xs().map(f).to_array() &&
+      // ...and with the empty list it annihilates
+      xs().flat_map(_ => @lazy_list.empty()).to_array() == ([] : Array[Int])
+    },
+    count=1000,
+  )
+}
+
+///|
+test "quickcheck: concat is a monoid, and splits at take/drop" {
+  @quickcheck.check(
+    (input : (Array[Int], Array[Int], Array[Int], Int)) => {
+      let (a, b, c, n) = input
+      let la = () => @lazy_list.from_iter(a.iter())
+      let lb = () => @lazy_list.from_iter(b.iter())
+      let lc = () => @lazy_list.from_iter(c.iter())
+      let empty : @lazy_list.LazyList[Int] = @lazy_list.empty()
+      // associativity
+      la().concat(lb()).concat(lc()).to_array() ==
+      la().concat(lb().concat(lc())).to_array() &&
+      // two-sided identity
+      la().concat(empty).to_array() == a &&
+      empty.concat(la()).to_array() == a &&
+      // take and drop partition the list at any index
+      la().take(n).to_array() + la().drop(n).to_array() == a &&
+      // ...and iterating take/drop is the same as taking/dropping once
+      la().take(n).take(n).to_array() == la().take(n).to_array() &&
+      la().drop(n).drop(n).to_array() ==
+      la().drop(if n <= 0 { 0 } else { n * 2 }).to_array()
+    },
+    filter=input => input.3 < 1000000, // keep `n * 2` from overflowing
+    count=1000,
+  )
+}
+
+///|
+test "quickcheck: take_while and drop_while partition at the same point" {
+  @quickcheck.check(
+    (input : (Array[Int], Int)) => {
+      let (source, k) = input
+      let xs = () => @lazy_list.from_iter(source.iter())
+      let pred = (x : Int) => x < k
+      let front = xs().take_while(pred).to_array()
+      let back = xs().drop_while(pred).to_array()
+      front + back == source &&
+      // the split is exactly at the first failure
+      front.all(pred) &&
+      (back.is_empty() || !pred(back[0]))
+    },
+    count=1000,
+  )
+}
+
+///|
+test "quickcheck: filter is idempotent and commutes with itself" {
+  @quickcheck.check(
+    (input : (Array[Int], Int, Int)) => {
+      let (source, j, k) = input
+      let xs = () => @lazy_list.from_iter(source.iter())
+      let p = (x : Int) => wrap_index(x, 2 + wrap_index(j, 5)) == 0
+      let q = (x : Int) => wrap_index(x, 2 + wrap_index(k, 7)) == 1
+      xs().filter(p).filter(p).to_array() == xs().filter(p).to_array() &&
+      xs().filter(p).filter(q).to_array() == xs().filter(q).filter(p).to_array() &&
+      xs().filter(p).filter(q).to_array() ==
+      xs().filter(x => p(x) && q(x)).to_array()
+    },
+    count=1000,
+  )
+}
+
+///|
+test "quickcheck: zip stops at the shorter side and preserves both projections" {
+  @quickcheck.check(
+    (input : (Array[Int], Array[Int])) => {
+      let (a, b) = input
+      let zipped = @lazy_list.from_iter(a.iter())
+        .zip(@lazy_list.from_iter(b.iter()))
+        .to_array()
+      let n = @cmp.minimum(a.length(), b.length())
+      zipped.length() == n &&
+      zipped.map(pair => pair.0) == a[0:n].to_owned() &&
+      zipped.map(pair => pair.1) == b[0:n].to_owned()
+    },
+    count=1000,
+  )
+}
+
+// =====================================================================
+// Intensional specification: exactly how much is forced.
+// =====================================================================
+
+///|
+test "quickcheck: take forces the prefix and never looks ahead" {
+  // The load-bearing property of the whole structure. `take(n)` on an
+  // infinite list must terminate, which it can only do by leaving the
+  // n-th cell's tail unforced — so the count is `n - 1`, not `n`.
+  @quickcheck.check(
+    (value : Int) => {
+      let n = wrap_index(value, 40)
+      let forced = Ref(0)
+      let taken = nats(forced).take(n).to_array()
+      let expected = Array::makei(n, i => i)
+      taken == expected && forced.val == (if n <= 0 { 0 } else { n - 1 })
+    },
+    count=200,
+  )
+}
+
+///|
+test "quickcheck: drop forces exactly n cells" {
+  @quickcheck.check(
+    (value : Int) => {
+      let n = wrap_index(value, 40)
+      let forced = Ref(0)
+      let rest = nats(forced).drop(n)
+      forced.val == n && rest.head() == Some(n) && forced.val == n
+    },
+    count=200,
+  )
+}
+
+///|
+test "quickcheck: take_while stops at the first failure and no further" {
+  @quickcheck.check(
+    (value : Int) => {
+      let n = wrap_index(value, 40)
+      let forced = Ref(0)
+      let taken = nats(forced).take_while(x => x < n).to_array()
+      // Reaching the failing cell `n` costs `n` thunks; nothing past it
+      // is touched.
+      taken == Array::makei(n, i => i) && forced.val == n
+    },
+    count=200,
+  )
+}
+
+///|
+test "quickcheck: filter forces only up to the next match" {
+  @quickcheck.check(
+    (value : Int) => {
+      let m = 1 + wrap_index(value, 30)
+      let forced = Ref(0)
+      // The first element satisfying `x % m == m - 1` is `m - 1`, so
+      // the scan must walk exactly that far and stop.
+      let matched = nats(forced).filter(x => wrap_index(x, m) == m - 1).take(1)
+      forced.val == m - 1 && matched.to_array() == [m - 1]
+    },
+    count=200,
+  )
+}
+
+///|
+test "queries and map force nothing on their own" {
+  let forced = Ref(0)
+  let xs = nats(forced)
+  assert_eq(xs.is_empty(), false)
+  assert_eq(xs.head(), Some(0))
+  assert_eq(forced.val, 0)
+  // `map` is head-strict: it produces the first output head at once,
+  // but must not walk any further.
+  let applied = Ref(0)
+  let mapped = xs.map(x => {
+    applied.val += 1
+    x * 2
+  })
+  assert_eq(forced.val, 0)
+  assert_eq(applied.val, 1)
+  assert_eq(mapped.head(), Some(0))
+  assert_eq(forced.val, 0)
+  assert_eq(applied.val, 1)
+  // `tail` forces exactly one cell.
+  assert_eq(xs.tail().unwrap().head(), Some(1))
+  assert_eq(forced.val, 1)
+}
+
+///|
+test "quickcheck: map applies its function once per cell reached" {
+  @quickcheck.check(
+    (value : Int) => {
+      let n = 1 + wrap_index(value, 30)
+      let forced = Ref(0)
+      let applied = Ref(0)
+      let taken = nats(forced)
+        .map(x => {
+          applied.val += 1
+          x * 2
+        })
+        .take(n)
+        .to_array()
+      taken == Array::makei(n, i => i * 2) &&
+      applied.val == n &&
+      forced.val == n - 1
+    },
+    count=200,
+  )
+}
+
+///|
+test "quickcheck: tails are memoized, so re-traversal is free" {
+  @quickcheck.check(
+    (value : Int) => {
+      let length = wrap_index(value, 30)
+      let forced = Ref(0)
+      let xs = finite(length, forced)
+      let first = xs.to_array()
+      let after_first = forced.val
+      // Every subsequent walk must run no thunk at all.
+      let second = xs.to_array()
+      let third = xs.map(x => x).to_array()
+      first == Array::makei(length, i => i) &&
+      second == first &&
+      third == first &&
+      after_first == length &&
+      forced.val == after_first
+    },
+    count=200,
+  )
+}
+
+///|
+test "quickcheck: concat leaves its right-hand side untouched" {
+  @quickcheck.check(
+    (input : (Int, Int)) => {
+      let length = wrap_index(input.0, 20)
+      let prefix = wrap_index(input.1, 20)
+      let left_forced = Ref(0)
+      let right_forced = Ref(0)
+      let joined = finite(length, left_forced).concat(nats(right_forced))
+      let taken = joined.take(prefix).to_array()
+      // both sides count up from 0, so the boundary is visible in the
+      // elements as well as in the counters
+      let expected = Array::makei(prefix, i => {
+        if i < length {
+          i
+        } else {
+          i - length
+        }
+      })
+      if prefix <= length {
+        // consuming a prefix that fits inside the left side must not
+        // force the right side at all
+        taken == expected && right_forced.val == 0
+      } else {
+        // past the boundary, only the overshoot is forced — and one
+        // cell less than that, since `take` never looks ahead
+        taken == expected && right_forced.val == prefix - length - 1
+      }
+    },
+    count=400,
+  )
+}
+
+///|
+test "quickcheck: zip is left-biased" {
+  // Documented asymmetry: the left tail is forced before the right, so
+  // a left side that ends first leaves the right untouched, while a
+  // right side that ends first costs one extra left cell.
+  @quickcheck.check(
+    (value : Int) => {
+      let n = 1 + wrap_index(value, 20)
+      let left_forced = Ref(0)
+      let right_forced = Ref(0)
+      let zipped = finite(n, left_forced).zip(nats(right_forced)).to_array()
+      zipped.length() == n &&
+      // the left ran out, so the right was never pulled past the pairs
+      // it supplied
+      right_forced.val == n - 1
+    },
+    count=200,
+  )
+}
+
+///|
+test "quickcheck: flat_map skips runs of empty inner lists without recursing" {
+  // The iterative empty-skip is what keeps a long run of empty
+  // mappings off the stack; a recursive implementation would overflow
+  // well before the end of this list.
+  @quickcheck.check(
+    (value : Int) => {
+      let threshold = 1 + wrap_index(value, 2000)
+      let source = @lazy_list.from_iter(Array::makei(20000, i => i).iter())
+      let mapped = source.flat_map(x => {
+        if x < threshold {
+          @lazy_list.empty()
+        } else {
+          @lazy_list.from_iter([|x|])
+        }
+      })
+      mapped.head() == Some(threshold)
+    },
+    count=50,
+  )
+}
+
+///|
+/// Concatenates the singletons `[0], [1], ..., [depth - 1]` in order,
+/// associating them into a binary tree whose shape is driven by `seed`.
+///
+/// The segments are combined through a stack, merging the top two
+/// whenever the pseudo-random bit stream says so and draining what is
+/// left at the end. A run of merge bits builds a left-nested comb, a run
+/// without them a right-nested one, and a realistic seed gives a mixture
+/// of both at every scale.
+///
+/// This covers *ordering* across the association shapes the generator
+/// reaches, which is the caller's choice and not the package's. It is
+/// not the stack-safety case: the trees it draws nest far more shallowly
+/// than the 50000-deep combs in `lazy_list_test.mbt`, which are what pin
+/// that down.
+fn shaped_concat(depth : Int, seed : Int) -> @lazy_list.LazyList[Int] {
+  let stack : Array[@lazy_list.LazyList[Int]] = []
+  let mut bits = seed
+  fn next_bit() -> Bool {
+    // A plain LCG: the shape only has to vary, not be well distributed.
+    bits = bits * 1103515245 + 12345
+    (bits >> 16) % 2 == 0
+  }
+
+  fn merge_top() -> Unit {
+    let right = stack.pop().unwrap()
+    let left = stack.pop().unwrap()
+    stack.push(left.concat(right))
+  }
+
+  for i in 0..= 2 && next_bit() {
+      merge_top()
+    }
+  }
+  while stack.length() >= 2 {
+    merge_top()
+  }
+  match stack.pop() {
+    Some(xs) => xs
+    None => @lazy_list.empty()
+  }
+}
+
+///|
+test "quickcheck: a chain of concats forces at generated nesting shapes" {
+  @quickcheck.check(
+    (seed : Int) => {
+      let depth = 20000
+      let xs = shaped_concat(depth, seed)
+      // Reaching the second element is what walks the whole nesting: it
+      // is the first tail force, so it is the one that has to see
+      // through every pending append at once.
+      xs.take(2).to_array() == [0, 1] &&
+      // ...and the rest of the traversal has to come out in order.
+      xs.to_array() == Array::makei(depth, i => i)
+    },
+    count=20,
+  )
+}
+
+///|
+test "quickcheck: appending while consuming keeps the order" {
+  // Growing a list at the end while walking it from the front is the
+  // shape that a flat pending sequence makes quadratic — every step
+  // would copy everything already owed — and that the recursive
+  // implementation overflows outright. What is asserted here is the
+  // order; the quadratic variant still produces it, and shows up only
+  // as runtime.
+  @quickcheck.check(
+    (value : Int) => {
+      let steps = 20000
+      let start = wrap_index(value, 100)
+      let mut cur = @lazy_list.from_iter(Array::makei(steps + 1, i => i).iter())
+      let seen = []
+      for i in 0.. i) &&
+      cur.to_array() == [steps] + Array::makei(steps, i => start + i)
+    },
+    count=5,
+  )
+}
+
+// =====================================================================
+// Infinite sources.
+// =====================================================================
+
+///|
+test "quickcheck: the lazy combinators stay total on an infinite list" {
+  // Every combinator that can produce a finite answer from an infinite
+  // input must do so without diverging. Anything that over-forces by
+  // even one cell hangs here rather than failing, which is exactly why
+  // the counts above are pinned exactly.
+  @quickcheck.check(
+    (value : Int) => {
+      let n = wrap_index(value, 25)
+      let counter = Ref(0)
+      let expected = Array::makei(n, i => i)
+      nats(counter).take(n).to_array() == expected &&
+      nats(counter).drop(n).take(1).to_array() == [n] &&
+      nats(counter).take_while(x => x < n).to_array() == expected &&
+      nats(counter).map(x => x).take(n).to_array() == expected &&
+      nats(counter).filter(x => x >= n).take(1).to_array() == [n] &&
+      nats(counter).zip(nats(counter)).take(n).to_array() ==
+      expected.map(i => (i, i)) &&
+      nats(counter).concat(nats(counter)).take(n).to_array() == expected &&
+      nats(counter)
+      .flat_map(x => @lazy_list.from_iter([|x|]))
+      .take(n)
+      .to_array() ==
+      expected &&
+      nats(counter).iter().take(n).to_array() == expected
+    },
+    count=100,
+  )
+}
diff --git a/lazy_list/tail.mbt b/lazy_list/tail.mbt
new file mode 100644
index 0000000000..89d55cf947
--- /dev/null
+++ b/lazy_list/tail.mbt
@@ -0,0 +1,169 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// =====================================================================
+// `Tail` — the suspended remainder of a cell: `@lazy.Lazy` plus a
+// suspended-append state. Internal; see `types.mbt` for why the append
+// state has to live inside the memoized cell rather than wrap one.
+// =====================================================================
+
+///|
+/// A tail whose value is already known. No thunk is allocated.
+#owned(value)
+fn[A] Tail::ready(value : LazyList[A]) -> Tail[A] {
+  { state: Forced(value), }
+}
+
+///|
+/// A tail computed by `thunk`, which runs at most once.
+#owned(thunk)
+fn[A] Tail::delay(thunk : () -> LazyList[A]) -> Tail[A] {
+  { state: Unforced(thunk), }
+}
+
+///|
+/// `self.append(pending)` is the tail denoting `self` followed by every
+/// list in `pending`. O(1) — nothing is forced, and the pending lists are
+/// recorded as data so that `force` can flatten them iteratively.
+///
+/// `pending` must be non-empty; callers hand back the tail unchanged
+/// rather than wrap it in an append that has nothing to append.
+#owned(pending)
+fn[A] Tail::append(self : Tail[A], pending : Pending[A]) -> Tail[A] {
+  { state: Append(self, pending), }
+}
+
+///|
+/// The tail's value if it is already known, without running anything.
+/// A suspended append reports `None`: its head cell is not determined
+/// until the append is flattened.
+fn[A] Tail::peek(self : Tail[A]) -> LazyList[A]? {
+  match self.state {
+    Forced(v) => Some(v)
+    _ => None
+  }
+}
+
+///|
+/// Forces the tail to a head cell, memoizing the result. Reentrant
+/// forces are detected and aborted, for the reason `@lazy.Lazy::force`
+/// gives: the inner force would silently re-run the thunk and break the
+/// at-most-once guarantee.
+///
+/// The `Append` case is the reason this type is not just `@lazy.Lazy`.
+/// `Append(inner, ps)` denotes `inner` followed by `ps`, and `inner` may
+/// itself be an `Append` — which is exactly the shape a left-nested chain
+/// of `concat`s builds. Instead of recursing into `inner`, walk down to
+/// the innermost non-append tail while accumulating everything still to
+/// be appended, force that single thunk, and re-attach the accumulated
+/// appends with `append_pending`. Stack use is therefore constant in the
+/// nesting depth.
+///
+/// Only the entry node is memoized. The intermediate `Append` nodes the
+/// walk passes through are left alone (and become garbage unless the
+/// caller retained the corresponding lists), which is what keeps a full
+/// traversal linear: the collected `Pending` is handed to the *result*
+/// cell instead of being re-nested once per level.
+fn[A] Tail::force(self : Tail[A]) -> LazyList[A] {
+  match self.state {
+    Forced(v) => v
+    Forcing => abort("LazyList: reentrant force on the same tail")
+    Unforced(thunk) => {
+      self.state = Forcing
+      let v = thunk()
+      self.state = Forced(v)
+      v
+    }
+    Append(inner, pending) => {
+      self.state = Forcing
+      let (base, rest) = for cur = inner, rest = pending {
+        match cur.state {
+          // `cur` denotes `next` followed by `ps`, and the whole denotes
+          // `cur` followed by `rest`, so `ps` goes in front of what we
+          // already owe.
+          Append(next, ps) => continue next, ps.concat(rest)
+          // Not an append, so this force cannot walk any further down.
+          _ => break (cur.force(), rest)
+        }
+      }
+      let value = base.append_pending(rest)
+      self.state = Forced(value)
+      value
+    }
+  }
+}
+
+///|
+/// Appends `pending` after the head cell `self`, returning a head cell.
+/// Empty segments are skipped in a loop, so a long run of empty lists
+/// costs O(1) stack.
+fn[A] LazyList::append_pending(
+  self : LazyList[A],
+  pending : Pending[A],
+) -> LazyList[A] {
+  for cur = self, rest = pending {
+    match cur.cell {
+      // A head is available: whatever is still pending moves onto its
+      // tail, still suspended.
+      Cons(x, tail~) =>
+        break if rest is Nil {
+          cur
+        } else {
+          { cell: Cons(x, tail=tail.append(rest)), }
+        }
+      // This segment contributed no head; move on to the next one.
+      Empty =>
+        match rest.uncons() {
+          None => break cur
+          Some((t, more)) => continue t.force(), more
+        }
+    }
+  }
+}
+
+///|
+/// `self` followed by `other`. O(1): the join is recorded as a `Cat` node
+/// and flattened only as `uncons` walks past it.
+#owned(other)
+fn[A] Pending::concat(self : Pending[A], other : Pending[A]) -> Pending[A] {
+  match (self, other) {
+    (Nil, _) => other
+    (_, Nil) => self
+    _ => Cat(self, other)
+  }
+}
+
+///|
+/// Splits off the first pending list, or `None` if there is none.
+///
+/// Rotates the left spine of the `Cat` tree to the right as it walks, so
+/// consuming a pending sequence once — each remainder handed on to the
+/// next `uncons` — steps over every `Cat` node at most once, i.e. O(1)
+/// amortized per element. The amortization is the usual ephemeral one:
+/// re-consuming a retained earlier remainder replays its rotations.
+/// Iterative, so an arbitrarily deep tree costs O(1) stack.
+fn[A] Pending::uncons(self : Pending[A]) -> (Tail[A], Pending[A])? {
+  for cur = self {
+    match cur {
+      Nil => break None
+      One(t) => break Some((t, Nil))
+      Cat(One(t), b) => break Some((t, b))
+      Cat(Cat(x, y), b) => continue Cat(x, Cat(y, b))
+      // Unreachable: neither `Pending::concat` nor the rotation above
+      // builds a `Cat` with an empty side. Handled rather than asserted
+      // because an empty side means exactly "nothing to append here".
+      Cat(Nil, b) => continue b
+    }
+  }
+}
diff --git a/lazy_list/types.mbt b/lazy_list/types.mbt
index 11e8843379..6084f7303f 100644
--- a/lazy_list/types.mbt
+++ b/lazy_list/types.mbt
@@ -15,10 +15,72 @@
 ///|
 /// Internal cell representation. Kept private so users go through the
 /// `head` / `tail` / `iter` accessors and cannot observe (or accidentally
-/// force) the underlying `@lazy.Lazy` representation.
+/// force) the underlying `Tail` representation.
 priv enum LazyListCell[A] {
   Empty
-  Cons(A, tail~ : @lazy.Lazy[LazyList[A]])
+  Cons(A, tail~ : Tail[A])
+}
+
+///|
+/// The suspended remainder of a `Cons` cell.
+///
+/// This is `@lazy.Lazy` — the same states, the same at-most-once
+/// guarantee, the same reentrancy check — with one extra state, `Append`.
+/// The extra state is what makes `concat` stack-safe, and it has to live
+/// *inside* the memoized cell rather than wrap one: `LazyList` allocates a
+/// tail per element, so an extra box per cell would be an allocation
+/// regression on every traversal, not just on `concat`.
+///
+/// Why the extra state is needed: over a bare thunk, `concat` can only be
+/// written recursively — `xs ++ ys` has to close over a thunk that forces
+/// the tail of `xs` and appends `ys` to the *result*, so forcing a
+/// left-nested chain `((a ++ b) ++ c) ++ d` recurses once per `++`.
+/// `Append` reifies the pending appends as data instead of burying them
+/// in a closure, which lets `Tail::force` flatten the chain with a loop.
+/// See `Tail::force` and `LazyList::concat`.
+priv struct Tail[A] {
+  mut state : TailState[A]
+}
+
+///|
+/// Internal tail state.
+///
+/// - `Unforced(thunk)`: the ordinary case — the rest of the list is
+///   whatever `thunk` returns. It runs at most once.
+/// - `Forcing`: `thunk` is running. Detects a reentrant force on the same
+///   tail, which would otherwise re-run the thunk and break the
+///   at-most-once guarantee. Same rationale as `@lazy.Lazy`'s.
+/// - `Append(inner, pending)`: the rest of the list is `inner` followed by
+///   every list in `pending`, in order. Built by `concat` / `concat_lazy`,
+///   and collapsed to `Forced` by the first `Tail::force`.
+/// - `Forced(v)`: already known to be `v`; the thunk reference is dropped
+///   so its captures can be reclaimed.
+priv enum TailState[A] {
+  Unforced(() -> LazyList[A])
+  Forcing
+  Append(Tail[A], Pending[A])
+  Forced(LazyList[A])
+}
+
+///|
+/// An ordered, immutable sequence of still-suspended lists waiting to be
+/// appended. Elements are `Tail`s rather than `LazyList`s so that both
+/// `concat` (whose right-hand side is already a head cell) and
+/// `concat_lazy` (whose right-hand side is a thunk) can share one
+/// representation.
+///
+/// `Cat` makes joining two of these O(1), which is what keeps the cost of
+/// a `concat` independent of how much is already pending: appending to a
+/// list that is being consumed at the same time would otherwise copy the
+/// whole pending sequence on every step. `Pending::uncons` flattens the
+/// `Cat` spine as it walks, in amortized O(1) per element.
+///
+/// Invariant: `Pending::concat` is the only way a `Cat` is built from the
+/// outside, and it never gives one an empty side.
+priv enum Pending[A] {
+  Nil
+  One(Tail[A])
+  Cat(Pending[A], Pending[A])
 }
 
 ///|
@@ -28,6 +90,13 @@ priv enum LazyListCell[A] {
 ///
 /// Unlike `Iter[A]`, which is a single-use pull-based iterator, a
 /// `LazyList[A]` can be re-traversed any number of times.
+///
+/// ## Concurrency
+///
+/// Caching a tail in place is a mutation, so `LazyList[A]` is not
+/// thread-safe even though it is persistent. Traversing one from two
+/// threads requires external synchronization — the same contract as
+/// `@lazy.Lazy`.
 struct LazyList[A] {
   cell : LazyListCell[A]
 }
diff --git a/lexbuf/async_lexbuf.mbt b/lexbuf/async_lexbuf.mbt
index 7461183803..e38a2e8bd8 100644
--- a/lexbuf/async_lexbuf.mbt
+++ b/lexbuf/async_lexbuf.mbt
@@ -27,7 +27,13 @@ pub struct AsyncLexbuf {
 /// Creates an initially empty lexbuf. `source` asynchronously returns the next
 /// chunk, or `None` at EOF. Empty chunks are ignored.
 pub fn AsyncLexbuf::from_fn(source : async () -> String?) -> AsyncLexbuf {
-  { source, storage: LexbufStorage(), cursor: 0, retained_from: -1, eof: false }
+  {
+    source,
+    storage: LexbufStorage(),
+    cursor: 0,
+    retained_from: -1,
+    eof: false,
+  }
 }
 
 ///|
diff --git a/lexbuf/lexbuf.mbt b/lexbuf/lexbuf.mbt
index 353c59a8c6..fb875ceaa6 100644
--- a/lexbuf/lexbuf.mbt
+++ b/lexbuf/lexbuf.mbt
@@ -27,7 +27,13 @@ pub struct Lexbuf {
 /// Creates an initially empty lexbuf. `source` returns the next chunk, or
 /// `None` at EOF. Empty chunks are ignored.
 pub fn Lexbuf::from_fn(source : () -> String?) -> Lexbuf {
-  { source, storage: LexbufStorage(), cursor: 0, retained_from: -1, eof: false }
+  {
+    source,
+    storage: LexbufStorage(),
+    cursor: 0,
+    retained_from: -1,
+    eof: false,
+  }
 }
 
 ///|
diff --git a/lexbuf/lexbuf_bench_test.mbt b/lexbuf/lexbuf_bench_test.mbt
new file mode 100644
index 0000000000..6d88d7419c
--- /dev/null
+++ b/lexbuf/lexbuf_bench_test.mbt
@@ -0,0 +1,64 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+fn bench_retained_chunks(
+  it : @bench.T,
+  chunk : String,
+  chunk_count : Int,
+) -> Unit {
+  let total = chunk.length() * chunk_count
+  it.bench(fn() {
+    let mut remaining = chunk_count
+    let lexbuf = @lexbuf.Lexbuf::from_fn(() => {
+      if remaining == 0 {
+        None
+      } else {
+        remaining -= 1
+        Some(chunk)
+      }
+    })
+    lexbuf.__refill()
+    lexbuf.__retain_from_cursor()
+    while lexbuf.__get_cursor() < total {
+      let cursor = lexbuf.__get_cursor()
+      lexbuf.__unsafe_code_unit_at(cursor) |> ignore
+      lexbuf.__advance(1)
+      if lexbuf.__get_cursor() == lexbuf.__buffer_end() {
+        lexbuf.__refill()
+      }
+    }
+    it.keep(lexbuf.__get_stringview(0, total).length())
+  })
+}
+
+///|
+test "bench Lexbuf retained token chunks=10000 size=1" (it : @bench.T) {
+  bench_retained_chunks(it, "x", 10000)
+}
+
+///|
+test "bench Lexbuf retained token chunks=1250 size=8" (it : @bench.T) {
+  bench_retained_chunks(it, "x".repeat(8), 1250)
+}
+
+///|
+test "bench Lexbuf retained token chunks=157 size=64" (it : @bench.T) {
+  bench_retained_chunks(it, "x".repeat(64), 157)
+}
+
+///|
+test "bench Lexbuf retained token chunks=20 size=512" (it : @bench.T) {
+  bench_retained_chunks(it, "x".repeat(512), 20)
+}
diff --git a/lexbuf/lexbuf_test.mbt b/lexbuf/lexbuf_test.mbt
index 50d4fd081a..bafc8542b2 100644
--- a/lexbuf/lexbuf_test.mbt
+++ b/lexbuf/lexbuf_test.mbt
@@ -14,7 +14,7 @@
 
 ///|
 test "Lexbuf retains captures across chunks and commits lookahead" {
-  let chunks = ["ab", "cd", "!"].iter()
+  let chunks = [|"ab", "cd", "!"|]
   let lexbuf = @lexbuf.Lexbuf::from_fn(() => chunks.next())
   lexbuf.__refill()
   lexbuf.__advance(1)
@@ -34,7 +34,7 @@ test "Lexbuf retains captures across chunks and commits lookahead" {
 
 ///|
 test "Lexbuf discards consumed input without retention" {
-  let chunks = ["ab", "", "cd"].iter()
+  let chunks = [|"ab", "", "cd"|]
   let lexbuf = @lexbuf.Lexbuf::from_fn(() => chunks.next())
   lexbuf.__refill()
   lexbuf.__advance(2)
@@ -55,7 +55,7 @@ test "Lexbuf reports source EOF while buffered input remains" {
 
 ///|
 test "Lexbuf can commit before a capture-only retention" {
-  let chunks = ["ab"].iter()
+  let chunks = [|"ab"|]
   let lexbuf = @lexbuf.Lexbuf::from_fn(() => chunks.next())
   lexbuf.__refill()
   lexbuf.__advance(1)
@@ -90,10 +90,10 @@ test "Lexbuf retains a token across many chunks" {
 ///|
 test "Lexbuf preserves UTF-16 chunk boundaries" {
   let input = "a😀b"
-  let chunks = [
+  let chunks = [|
     input.unsafe_substring(start=0, end=2),
     input.unsafe_substring(start=2, end=4),
-  ].iter()
+  |]
   let lexbuf = @lexbuf.Lexbuf::from_fn(() => chunks.next())
   lexbuf.__refill()
   lexbuf.__retain_from_cursor()
@@ -107,7 +107,7 @@ test "Lexbuf preserves UTF-16 chunk boundaries" {
 
 ///|
 test "Lexbuf reads multiple chunks after compaction" {
-  let chunks = ["ab", "cd", "ef"].iter()
+  let chunks = [|"ab", "cd", "ef"|]
   let lexbuf = @lexbuf.Lexbuf::from_fn(() => chunks.next())
   lexbuf.__refill()
   lexbuf.__retain_from_cursor()
@@ -132,7 +132,9 @@ test "Lexbuf returns an empty retained view" {
 }
 
 ///|
-async fn[T] lexbuf_test_suspend(register : ((T) -> Unit) -> Unit) -> T noraise = "%async.suspend"
+async fn[T] lexbuf_test_suspend(
+  register : ((T) -> Unit) -> Unit,
+) -> T noraise + nocancel = "%async.suspend"
 
 ///|
 fn lexbuf_test_run_async(work : async () -> Unit noraise) -> Unit = "%async.run"
@@ -144,7 +146,7 @@ struct LexbufTestScheduler {
 
 ///|
 fn LexbufTestScheduler::new() -> LexbufTestScheduler {
-  { continuations: [] }
+  { continuations: [], }
 }
 
 ///|
diff --git a/lexbuf/moon.pkg b/lexbuf/moon.pkg
index c38fafe491..cf61463e1d 100644
--- a/lexbuf/moon.pkg
+++ b/lexbuf/moon.pkg
@@ -1,3 +1,7 @@
 import {
   "moonbitlang/core/builtin",
 }
+
+import {
+  "moonbitlang/core/bench",
+} for "test"
diff --git a/lexbuf/panic_test.mbt b/lexbuf/panic_test.mbt
index 0ae8b8e007..9ede9c21b0 100644
--- a/lexbuf/panic_test.mbt
+++ b/lexbuf/panic_test.mbt
@@ -28,7 +28,7 @@ test "panic Lexbuf rejects a commit without retention" {
 
 ///|
 test "panic Lexbuf rejects refill with unread input" {
-  let chunks = ["a"].iter()
+  let chunks = [|"a"|]
   let lexbuf = @lexbuf.Lexbuf::from_fn(() => chunks.next())
   lexbuf.__refill()
   lexbuf.__refill()
diff --git a/lexbuf/storage.mbt b/lexbuf/storage.mbt
index 1628ecf377..b1d8ada86a 100644
--- a/lexbuf/storage.mbt
+++ b/lexbuf/storage.mbt
@@ -26,7 +26,7 @@ priv struct LexbufStorage {
 }
 
 ///|
-const LEXBUF_MERGED_CHUNK_LIMIT : Int = 512
+const LEXBUF_MERGED_CHUNK_LIMIT : Int = 64
 
 ///|
 fn LexbufStorage::LexbufStorage() -> LexbufStorage {
diff --git a/lexbuf/storage_wbtest.mbt b/lexbuf/storage_wbtest.mbt
index 4fa4f5b4b6..2307c6ea53 100644
--- a/lexbuf/storage_wbtest.mbt
+++ b/lexbuf/storage_wbtest.mbt
@@ -19,12 +19,12 @@ test "LexbufStorage merges input into the last small chunk" {
     storage.append("x")
   }
   inspect(storage.chunks.length(), content="1")
-  inspect(storage.chunks[0].length(), content="512")
+  inspect(storage.chunks[0].length(), content="64")
   inspect(storage.chunk_starts.length(), content="1")
   inspect(storage.chunk_starts[0], content="0")
   inspect(
     storage.get_stringview(0, LEXBUF_MERGED_CHUNK_LIMIT).length(),
-    content="512",
+    content="64",
   )
 }
 
@@ -35,13 +35,16 @@ test "LexbufStorage caps merged chunks" {
     storage.append("x")
   }
   inspect(storage.chunks.length(), content="2")
-  inspect(storage.chunks[0].length(), content="512")
-  inspect(storage.chunks[1].length(), content="512")
+  inspect(storage.chunks[0].length(), content="64")
+  inspect(storage.chunks[1].length(), content="64")
   inspect(storage.chunk_starts.length(), content="2")
   inspect(storage.chunk_starts[0], content="0")
-  inspect(storage.chunk_starts[1], content="512")
-  inspect(storage.unsafe_code_unit_at(511), content="120")
-  inspect(storage.unsafe_code_unit_at(512), content="120")
+  inspect(storage.chunk_starts[1], content="64")
+  inspect(
+    storage.unsafe_code_unit_at(LEXBUF_MERGED_CHUNK_LIMIT - 1),
+    content="120",
+  )
+  inspect(storage.unsafe_code_unit_at(LEXBUF_MERGED_CHUNK_LIMIT), content="120")
 }
 
 ///|
@@ -93,7 +96,7 @@ test "LexbufStorage discards complete chunks and preserves absolute offsets" {
   storage.append("z".repeat(LEXBUF_MERGED_CHUNK_LIMIT + 1))
   storage.discard_before(LEXBUF_MERGED_CHUNK_LIMIT + 1)
   inspect(storage.chunks.length(), content="2")
-  inspect(storage.buffer_start, content="513")
+  inspect(storage.buffer_start, content="65")
   inspect(
     storage.unsafe_code_unit_at(LEXBUF_MERGED_CHUNK_LIMIT + 1),
     content="121",
@@ -102,7 +105,7 @@ test "LexbufStorage discards complete chunks and preserves absolute offsets" {
     LEXBUF_MERGED_CHUNK_LIMIT + 1,
     2 * (LEXBUF_MERGED_CHUNK_LIMIT + 1) + 1,
   )
-  inspect(view.length(), content="514")
+  inspect(view.length(), content="66")
   inspect(view.unsafe_get(0).to_int(), content="121")
   inspect(view.unsafe_get(view.length() - 1).to_int(), content="122")
 }
diff --git a/lexbuf/string_scanner.mbt b/lexbuf/string_scanner.mbt
index a62aad53c4..9381f3191a 100644
--- a/lexbuf/string_scanner.mbt
+++ b/lexbuf/string_scanner.mbt
@@ -25,8 +25,8 @@
 /// `0..=data.length()`. A `StringView` slice is therefore a valid input, and
 /// scanning it does not read outside the slice.
 ///
-/// ```moonbit nocheck
-/// let scanner = @lexbuf.StringScanner::{ data: "hello 42"[:], cursor: 0 }
+/// ```mbt nocheck
+/// let scanner = @lexbuf.StringScanner::{ data: "hello 42"[:], cursor: 0, }
 ///
 /// let token = lexscan scanner {
 ///   re"^[a-z]+" as word => word
diff --git a/list/README.mbt.md b/list/README.mbt.md
index cb146167b4..f17db2a901 100644
--- a/list/README.mbt.md
+++ b/list/README.mbt.md
@@ -364,8 +364,8 @@ test {
 ///|
 test {
   let list = @list.List([1, 2, 3, 4, 5])
-  @test.assert_eq(list.is_prefix(List([1, 2, 3])), true)
-  @test.assert_eq(list.is_suffix(List([4, 5])), true)
+  @test.assert_eq(list.has_prefix(List([1, 2, 3])), true)
+  @test.assert_eq(list.has_suffix(List([4, 5])), true)
 }
 ```
 
@@ -411,7 +411,7 @@ test {
 test {
   let list = @list.List([1, 2, 3])
   debug_inspect(list.iter().to_array(), content="[1, 2, 3]")
-  let list2 = @list.from_iter([4, 5, 6].iter())
+  let list2 = @list.from_iter([|4, 5, 6|])
   @debug.assert_eq(list2, List([4, 5, 6]))
 }
 ```
@@ -486,8 +486,8 @@ test {
 ### Additional Error Cases
 
 - **`nth()` on an empty list or out-of-bounds index**: Returns `None`.  
-- **`tail()` on an empty list**: Returns `Empty`.  
-- **`sort()` with non-comparable elements**: Throws a runtime error.  
+- **`unsafe_tail()` on an empty list**: Panics. Use pattern matching, or `drop(1)`, which returns `Empty`.  
+- **`sort()` on elements without a `Compare` implementation**: Rejected at compile time by the `A : Compare` bound; there is no runtime failure.  
 
 ---
 
diff --git a/list/list.mbt b/list/list.mbt
index f0cfd39c49..e7f6c220ab 100644
--- a/list/list.mbt
+++ b/list/list.mbt
@@ -36,8 +36,6 @@ pub fn[A] List::new() -> List[A] {
 /// 
 /// This function constructs a new list with the given element as the head
 /// and the provided list as the tail.
-/// 
-/// A more familiar name of this function is `cons`.
 ///
 /// # Example
 ///
@@ -103,7 +101,7 @@ pub impl[A : Show] Show for List[A] with fn output(xs, logger) {
 pub impl[A : ToJson] ToJson for List[A] with fn to_json(self) {
   let capacity = self.length()
   guard capacity != 0 else { return [] }
-  let jsons = Array::new(capacity~)
+  let jsons = Array(capacity~)
   for a in self {
     jsons.push(a.to_json())
   }
@@ -732,8 +730,7 @@ pub fn[A, B] List::foldi(
 pub fn[A, B] List::zip(self : List[A], other : List[B]) -> List[(A, B)] {
   let res = for a = self, b = other, acc = Empty {
     match (a, b, acc) {
-      (Empty, _, acc) => break acc
-      (_, Empty, acc) => break acc
+      (Empty, _, acc) | (_, Empty, acc) => break acc
       (More(x, tail=xs), More(y, tail=ys), acc) =>
         continue xs, ys, More((x, y), tail=acc)
     }
@@ -744,7 +741,8 @@ pub fn[A, B] List::zip(self : List[A], other : List[B]) -> List[(A, B)] {
 ///|
 /// map over the list and concat all results.
 ///
-/// `flat_map(f, ls)` equal to `ls.map(f).fold(Empty, (acc, x) => acc.concat(x))))`
+/// `ls.flat_map(f)` is equivalent to
+/// `ls.map(f).fold(init=Empty, (acc, x) => acc.concat(x))`
 ///
 /// # Example
 ///
@@ -1106,17 +1104,8 @@ pub fn[A] List::flatten(self : List[List[A]]) -> List[A] {
 #internal(unsafe, "Panic if the list is empty")
 #doc(hidden)
 pub fn[A : Compare] List::unsafe_maximum(self : List[A]) -> A {
-  match self {
-    Empty => abort("maximum: empty list")
-    More(head, tail~) =>
-      for a = tail, b = head {
-        match (a, b) {
-          (Empty, curr_max) => break curr_max
-          (More(item, tail~), curr_max) =>
-            continue tail, if item > curr_max { item } else { curr_max }
-        }
-      }
-  }
+  guard self.maximum() is Some(max) else { abort("maximum: empty list") }
+  max
 }
 
 ///|
@@ -1136,16 +1125,13 @@ pub fn[A : Compare] List::unsafe_maximum(self : List[A]) -> A {
 /// }
 /// ```
 pub fn[A : Compare] List::maximum(self : List[A]) -> A? {
-  match self {
-    Empty => None
-    More(head, tail~) =>
-      for a = tail, b = head {
-        match (a, b) {
-          (Empty, curr_max) => break Some(curr_max)
-          (More(item, tail~), curr_max) =>
-            continue tail, if item > curr_max { item } else { curr_max }
-        }
-      }
+  guard self is More(head, tail~) else { return None }
+  for rest = tail, curr_max = head {
+    match rest {
+      Empty => break Some(curr_max)
+      More(item, tail~) =>
+        continue tail, if item > curr_max { item } else { curr_max }
+    }
   }
 }
 
@@ -1170,17 +1156,8 @@ pub fn[A : Compare] List::maximum(self : List[A]) -> A? {
 #internal(unsafe, "Panic if the list is empty")
 #doc(hidden)
 pub fn[A : Compare] List::unsafe_minimum(self : List[A]) -> A {
-  match self {
-    Empty => abort("minimum: empty list")
-    More(head, tail~) =>
-      for a = tail, b = head {
-        match (a, b) {
-          (Empty, curr_min) => break curr_min
-          (More(item, tail~), curr_min) =>
-            continue tail, if item < curr_min { item } else { curr_min }
-        }
-      }
-  }
+  guard self.minimum() is Some(min) else { abort("minimum: empty list") }
+  min
 }
 
 ///|
@@ -1200,16 +1177,13 @@ pub fn[A : Compare] List::unsafe_minimum(self : List[A]) -> A {
 /// }
 /// ```
 pub fn[A : Compare] List::minimum(self : List[A]) -> A? {
-  match self {
-    Empty => None
-    More(head, tail~) =>
-      for a = tail, b = head {
-        match (a, b) {
-          (Empty, curr_min) => break Some(curr_min)
-          (More(item, tail~), curr_min) =>
-            continue tail, if item < curr_min { item } else { curr_min }
-        }
-      }
+  guard self is More(head, tail~) else { return None }
+  for rest = tail, curr_min = head {
+    match rest {
+      Empty => break Some(curr_min)
+      More(item, tail~) =>
+        continue tail, if item < curr_min { item } else { curr_min }
+    }
   }
 }
 
@@ -1365,8 +1339,7 @@ pub fn[A] List::take(self : List[A], n : Int) -> List[A] {
         let dest = More(head, tail=Empty)
         for d = dest, t = tail, i = n - 1 {
           match (d, t, i) {
-            (_, Empty, _) => break
-            (_, _, 0) => break
+            (_, Empty, _) | (_, _, 0) => break
             (More(_) as dest, More(x, tail=xs), n) => {
               dest.tail = More(x, tail=Empty)
               continue dest.tail, xs, n - 1
@@ -1733,10 +1706,11 @@ pub fn[A : Eq] List::remove(self : List[A], elem : A) -> List[A] {
 ///
 /// ```mbt check
 /// test {
-///   @test.assert_eq(@list.List([1, 2, 3, 4, 5]).is_prefix(List([1, 2, 3])), true)
+///   @test.assert_eq(@list.List([1, 2, 3, 4, 5]).has_prefix(List([1, 2, 3])), true)
 /// }
 /// ```
-pub fn[A : Eq] List::is_prefix(self : List[A], prefix : List[A]) -> Bool {
+#alias(is_prefix, deprecated)
+pub fn[A : Eq] List::has_prefix(self : List[A], prefix : List[A]) -> Bool {
   for a = self, b = prefix {
     match (a, b) {
       (_, Empty) => break true
@@ -1758,11 +1732,41 @@ pub fn[A : Eq] List::is_prefix(self : List[A], prefix : List[A]) -> Bool {
 ///
 /// ```mbt check
 /// test {
-///   @test.assert_eq(@list.List([1, 2, 3, 4, 5]).is_suffix(List([3, 4, 5])), true)
-/// }
-/// ```
-pub fn[A : Eq] List::is_suffix(self : List[A], suffix : List[A]) -> Bool {
-  self.rev().is_prefix(suffix.rev())
+///   @test.assert_eq(@list.List([1, 2, 3, 4, 5]).has_suffix(List([3, 4, 5])), true)
+/// }
+/// ```
+#alias(is_suffix, deprecated)
+pub fn[A : Eq] List::has_suffix(self : List[A], suffix : List[A]) -> Bool {
+  guard suffix is More(_) else { return true }
+  // One pass, no allocation. First advance `lead` one node per `suffix`
+  // node, so `lead` ends up `suffix.length()` nodes into `self` (or proves
+  // the suffix is longer than the list). Then slide `lag` (from the start)
+  // and `lead` together: when `lead` falls off the end, `lag` is exactly
+  // `suffix.length()` nodes from the end — the only window where the suffix
+  // can match — and an element-wise comparison of that equal-length window
+  // decides. List `==` is deliberately not used for the window: its
+  // physical-equality shortcut would declare a NaN-carrying list a suffix
+  // of itself, changing the observable element-wise behavior.
+  for lead = self, probe = suffix {
+    match (lead, probe) {
+      (_, Empty) =>
+        break for lag = self, ahead = lead {
+          match (lag, ahead) {
+            (_, Empty) => break lag.has_prefix(suffix)
+            (More(_, tail=lag_tail), More(_, tail=ahead_tail)) =>
+              continue lag_tail, ahead_tail
+            (Empty, More(_)) =>
+              // `ahead` starts deeper in the same list than `lag` and both
+              // advance together, so `ahead` empties first; this arm only
+              // satisfies exhaustiveness.
+              break false
+          }
+        }
+      (Empty, More(_)) => break false
+      (More(_, tail=lead_tail), More(_, tail=probe_tail)) =>
+        continue lead_tail, probe_tail
+    }
+  }
 }
 
 ///|
@@ -1910,7 +1914,7 @@ pub fn[A] List::from_iter(iter : Iter[A]) -> List[A] {
 /// 
 /// Creates a list from an iterator, but the resulting list will have elements
 /// in reverse order compared to the iterator. This is more efficient than
-/// `from_iterator` when order doesn't matter.
+/// `from_iter` when order doesn't matter.
 ///
 /// # Example
 ///
@@ -1930,16 +1934,6 @@ pub fn[A] List::from_iter_rev(iter : Iter[A]) -> List[A] {
 }
 
 ///|
-/// Create a list from a FixedArray.
-/// 
-/// Converts a FixedArray into a list with the same elements in the same order.
-///
-/// # Example
-///
-/// ```mbt test
-/// let ls = @list.List([1, 2, 3, 4, 5])
-/// @debug.assert_eq(ls.to_array(), [1, 2, 3, 4, 5])
-/// ```
 
 ///|
 /// Create a list with a single element.
diff --git a/list/list_test.mbt b/list/list_test.mbt
index 14d933a4fd..e535b29c0b 100644
--- a/list/list_test.mbt
+++ b/list/list_test.mbt
@@ -581,16 +581,16 @@ test "remove" {
 }
 
 ///|
-test "is_prefix" {
+test "has_prefix" {
   debug_inspect(
-    @list.List([1, 2, 3, 4, 5]).is_prefix(List([1, 2, 3])),
+    @list.List([1, 2, 3, 4, 5]).has_prefix(List([1, 2, 3])),
     content="true",
   )
   debug_inspect(
-    @list.List([1, 2, 3, 4, 5]).is_prefix(List([3, 2, 3])),
+    @list.List([1, 2, 3, 4, 5]).has_prefix(List([3, 2, 3])),
     content="false",
   )
-  debug_inspect(@list.empty().is_prefix(List([1, 2, 3])), content="false")
+  debug_inspect(@list.empty().has_prefix(List([1, 2, 3])), content="false")
 }
 
 ///|
@@ -601,13 +601,13 @@ test "equal" {
 }
 
 ///|
-test "is_suffix" {
+test "has_suffix" {
   debug_inspect(
-    @list.List([1, 2, 3, 4, 5]).is_suffix(List([3, 4, 5])),
+    @list.List([1, 2, 3, 4, 5]).has_suffix(List([3, 4, 5])),
     content="true",
   )
   debug_inspect(
-    @list.List([1, 2, 3, 4, 5]).is_suffix(List([3, 4, 6])),
+    @list.List([1, 2, 3, 4, 5]).has_suffix(List([3, 4, 6])),
     content="false",
   )
 }
@@ -787,25 +787,22 @@ test "add" {
 
 ///|
 test "from_iter multiple elements iter" {
-  debug_inspect(@list.from_iter([1, 2, 3].iter()), content="")
+  debug_inspect(@list.from_iter([|1, 2, 3|]), content="")
 }
 
 ///|
 test "from_iter_rev multiple elements iter" {
-  debug_inspect(
-    @list.from_iter_rev([1, 2, 3].iter()),
-    content="",
-  )
+  debug_inspect(@list.from_iter_rev([|1, 2, 3|]), content="")
 }
 
 ///|
 test "from_iter single element iter" {
-  debug_inspect(@list.from_iter([1].iter()), content="")
+  debug_inspect(@list.from_iter([|1|]), content="")
 }
 
 ///|
 test "from_iter empty iter" {
-  let pq : @list.List[Int] = @list.from_iter(Iter::empty())
+  let pq : @list.List[Int] = @list.from_iter([||])
   debug_inspect(pq, content="")
 }
 
@@ -1090,29 +1087,29 @@ test "complex list recursion safety" {
 }
 
 ///|
-test "is_prefix and is_suffix complex cases" {
+test "has_prefix and has_suffix complex cases" {
   let l = @list.List([1, 2, 3, 4, 5])
 
-  // Test is_prefix with various scenarios
-  assert_true(l.is_prefix(List([1])))
-  assert_true(l.is_prefix(List([1, 2])))
-  assert_true(l.is_prefix(List([1, 2, 3])))
-  assert_false(l.is_prefix(List([2, 3])))
-  assert_false(l.is_prefix(List([0, 1, 2])))
+  // Test has_prefix with various scenarios
+  assert_true(l.has_prefix(List([1])))
+  assert_true(l.has_prefix(List([1, 2])))
+  assert_true(l.has_prefix(List([1, 2, 3])))
+  assert_false(l.has_prefix(List([2, 3])))
+  assert_false(l.has_prefix(List([0, 1, 2])))
 
-  // Test is_suffix with various scenarios
-  assert_true(l.is_suffix(List([5])))
-  assert_true(l.is_suffix(List([4, 5])))
-  assert_true(l.is_suffix(List([3, 4, 5])))
-  assert_false(l.is_suffix(List([2, 5])))
-  assert_false(l.is_suffix(List([3, 4, 6])))
+  // Test has_suffix with various scenarios
+  assert_true(l.has_suffix(List([5])))
+  assert_true(l.has_suffix(List([4, 5])))
+  assert_true(l.has_suffix(List([3, 4, 5])))
+  assert_false(l.has_suffix(List([2, 5])))
+  assert_false(l.has_suffix(List([3, 4, 6])))
 
   // Test with empty list
   let empty : @list.List[Int] = @list.empty()
-  assert_true(empty.is_prefix(List([])))
-  assert_true(empty.is_suffix(List([])))
-  assert_true(l.is_prefix(List([])))
-  assert_true(l.is_suffix(List([])))
+  assert_true(empty.has_prefix(List([])))
+  assert_true(empty.has_suffix(List([])))
+  assert_true(l.has_prefix(List([])))
+  assert_true(l.has_suffix(List([])))
 }
 
 ///|
@@ -1204,3 +1201,40 @@ test "List default trait" {
   let ls : @list.List[Int] = Default::default()
   debug_inspect(ls, content="")
 }
+
+///|
+test "has_suffix boundaries" {
+  let l = @list.List([1, 2, 3, 4, 5])
+  let empty : @list.List[Int] = @list.empty()
+  // empty suffixes
+  assert_true(l.has_suffix(empty))
+  assert_true(empty.has_suffix(empty))
+  // empty list, non-empty suffix
+  assert_false(empty.has_suffix(l))
+  // the whole list, and a physically shared tail
+  assert_true(l.has_suffix(l))
+  assert_true(l.has_suffix(l.drop(2)))
+  // longer than the list
+  assert_false(l.has_suffix(List([0, 1, 2, 3, 4, 5])))
+  // same length, different content
+  assert_false(l.has_suffix(List([1, 2, 3, 4, 6])))
+  assert_false(l.has_suffix(List([0, 2, 3, 4, 5])))
+  // mismatch only at the window's first element
+  assert_false(l.has_suffix(List([0, 4, 5])))
+  // single element
+  assert_true(l.has_suffix(List([5])))
+  assert_false(l.has_suffix(List([1])))
+}
+
+///|
+test "has_suffix window comparison is element-wise" {
+  // List `==` short-circuits on physical equality, but the suffix window
+  // comparison is element-wise, so a NaN-carrying list is not even its own
+  // suffix — matching the previous rev-based implementation and
+  // has_prefix's behavior.
+  let nan = 0.0 / 0.0
+  let xs = @list.List([nan])
+  assert_true(xs == xs)
+  assert_false(xs.has_suffix(xs))
+  assert_false(xs.has_prefix(xs))
+}
diff --git a/list/pkg.generated.mbti b/list/pkg.generated.mbti
index 2bae3b36ca..7c0ddabd26 100644
--- a/list/pkg.generated.mbti
+++ b/list/pkg.generated.mbti
@@ -54,13 +54,15 @@ pub fn[A] List::from_iter(Iter[A]) -> Self[A]
 pub fn[A] List::from_iter_rev(Iter[A]) -> Self[A]
 #as_free_fn
 pub fn[A : @json.FromJson] List::from_json(Json) -> Self[A] raise @json.JsonDecodeError
+#alias(is_prefix, deprecated)
+pub fn[A : Eq] List::has_prefix(Self[A], Self[A]) -> Bool
+#alias(is_suffix, deprecated)
+pub fn[A : Eq] List::has_suffix(Self[A], Self[A]) -> Bool
 pub fn[A : Hash] List::hash(Self[A]) -> Int
 pub fn[A] List::head(Self[A]) -> A?
 pub fn[A] List::intercalate(Self[Self[A]], Self[A]) -> Self[A]
 pub fn[A] List::intersperse(Self[A], A) -> Self[A]
 pub fn[A] List::is_empty(Self[A]) -> Bool
-pub fn[A : Eq] List::is_prefix(Self[A], Self[A]) -> Bool
-pub fn[A : Eq] List::is_suffix(Self[A], Self[A]) -> Bool
 #alias(iterator, deprecated)
 pub fn[A] List::iter(Self[A]) -> Iter[A]
 #alias(iterator2, deprecated)
diff --git a/list/quickcheck_test.mbt b/list/quickcheck_test.mbt
index afbc147c78..94019794e7 100644
--- a/list/quickcheck_test.mbt
+++ b/list/quickcheck_test.mbt
@@ -245,6 +245,26 @@ test "quickcheck: intersperse matches an Array reference" {
   )
 }
 
+///|
+test "quickcheck: has_suffix agrees with the Array reference" {
+  @quickcheck.check(
+    (input : (@list.List[Int], @list.List[Int], Int)) => {
+      let (xs, ys, k) = input
+      let a = xs.to_array()
+      let b = ys.to_array()
+      let expected = a.length() >= b.length() &&
+        a[a.length() - b.length():].to_owned() == b
+      guard xs.has_suffix(ys) == expected else { return false }
+      // Every drop of a list is one of its suffixes, both as the physically
+      // shared tail and as a structurally equal unshared copy.
+      let len = xs.length()
+      let clamped = if k < 0 { 0 } else if k > len { len } else { k }
+      xs.has_suffix(xs.drop(clamped)) && xs.has_suffix(List(a[clamped:]))
+    },
+    count=300,
+  )
+}
+
 ///|
 test "quickcheck: element queries agree with the Array reference" {
   @quickcheck.check(
diff --git a/math/algebraic_double_nonjs.mbt b/math/algebraic_double_nonjs.mbt
index 6f678dc713..dbdf85eb78 100644
--- a/math/algebraic_double_nonjs.mbt
+++ b/math/algebraic_double_nonjs.mbt
@@ -55,7 +55,7 @@ pub fn cbrt(x : Double) -> Double {
   let g = 3.57142857142857150787e-01 // 5/14      = 0x3FD6DB6D, 0xB6DB6DB7
   // mask the sign off, else a negative normal reads as subnormal below
   let hx = get_high_word(x).reinterpret_as_int() & 0x7fffffff
-  let sign = if x < 0.0 { true } else { false }
+  let sign = x < 0.0
   let x = abs(x)
   let t = if hx < 0x00100000 {
     let t : UInt64 = 0x43500000_00000000
diff --git a/math/log.mbt b/math/log.mbt
index 93ef3ac44f..a6cf10b994 100644
--- a/math/log.mbt
+++ b/math/log.mbt
@@ -68,7 +68,7 @@ let logf_data : LogfData = {
 ///
 /// Parameters:
 ///
-/// * `value` : The floating-point number to calculate the natural logarithm of.
+/// * `x` : The floating-point number to calculate the natural logarithm of.
 ///
 /// Returns the natural logarithm of the input value. Special cases are handled
 /// as follows:
diff --git a/math/prime.mbt b/math/prime.mbt
index 76eb51f229..b61cbe5b93 100644
--- a/math/prime.mbt
+++ b/math/prime.mbt
@@ -184,8 +184,7 @@ let small_primes : ReadOnlyArray[@bigint.BigInt] = [
 /// Parameters:
 ///
 /// * `number` : The `BigInt` value to be tested for primality.
-/// * `rand` : A random number generator implementing the `Rand` trait, used for
-/// generating test values.
+/// * `rand` : A random number generator used for generating test values.
 /// * `iters` : The number of iterations for the Miller-Rabin test (default:
 /// 64). Higher values provide greater certainty but take longer to compute.
 ///
@@ -233,9 +232,8 @@ pub fn is_probable_prime(
 ///
 /// Parameters:
 ///
-/// * `rand` : A random number generator implementing the `Rand` trait, used for
-/// generating random values.
 /// * `bits` : The desired bit length of the prime number to be generated.
+/// * `rand` : A random number generator used for generating random values.
 ///
 /// Returns a `BigInt` that is probably prime (with probability at least 1 -
 /// 2^(-128)).
diff --git a/math/scalbn.mbt b/math/scalbn.mbt
index 2771793523..f4bf1b5c0a 100644
--- a/math/scalbn.mbt
+++ b/math/scalbn.mbt
@@ -60,7 +60,7 @@ pub fn scalbn(x : Double, exp : Int) -> Double {
 }
 
 ///|
-/// Calculcates x * 2 **n where x is a single-precision floating number and n is an integer.
+/// Calculates y * 2^exp where y is a single-precision floating number and exp is an integer.
 ///
 /// Parameters:
 ///
diff --git a/math/trig.mbt b/math/trig.mbt
index 425fdfc3c3..7485859a68 100644
--- a/math/trig.mbt
+++ b/math/trig.mbt
@@ -405,10 +405,10 @@ pub fn acosf(x : Float) -> Float {
 ///
 /// Returns the arctangent of the number `x`.
 ///
-/// Example:
-///
 /// * Returns NaN if the input is NaN.
 ///
+/// Example:
+///
 /// ```mbt check
 /// test {
 ///   inspect(@math.atanf(0), content="0")
diff --git a/math/trig_double_js.mbt b/math/trig_double_js.mbt
index bb8705315d..667fdba6ed 100644
--- a/math/trig_double_js.mbt
+++ b/math/trig_double_js.mbt
@@ -153,10 +153,10 @@ pub fn acos(x : Double) -> Double = "Math" "acos"
 ///
 /// Returns the arctangent of the number `x`.
 ///
-/// Example:
-///
 /// * Returns NaN if the input is NaN.
 ///
+/// Example:
+///
 /// ```mbt check
 /// test {
 ///   inspect(@math.atan(0.0), content="0")
diff --git a/math/trig_double_nonjs.mbt b/math/trig_double_nonjs.mbt
index ad0c942903..bda1274f64 100644
--- a/math/trig_double_nonjs.mbt
+++ b/math/trig_double_nonjs.mbt
@@ -223,10 +223,10 @@ pub fn acos(x : Double) -> Double {
 ///
 /// Returns the arctangent of the number `x`.
 ///
-/// Example:
-///
 /// * Returns NaN if the input is NaN.
 ///
+/// Example:
+///
 /// ```mbt check
 /// test {
 ///   inspect(@math.atan(0.0), content="0")
diff --git a/option/README.mbt.md b/option/README.mbt.md
index 77599d040d..73ecde6a54 100644
--- a/option/README.mbt.md
+++ b/option/README.mbt.md
@@ -49,7 +49,7 @@ test {
 }
 ```
 
-A safer alternative to `unwrap` is the `or` method, which returns the value if it is `Some`, otherwise, it returns the default value.
+A safer alternative to `unwrap` is the `unwrap_or` method, which returns the value if it is `Some`, otherwise, it returns the default value.
 
 ```mbt check
 ///|
@@ -60,7 +60,7 @@ test {
 }
 ```
 
-There is also the `or_else` method, which returns the value if it is `Some`, otherwise, it returns the result of the provided function.
+There is also the `unwrap_or_else` method, which returns the value if it is `Some`, otherwise, it returns the result of the provided function.
 
 ```mbt check
 ///|
@@ -110,7 +110,7 @@ test {
 }
 ```
 
-Sometimes we want to reduce the nested `Option` values into a single `Option`, you can use the `flatten` method to achieve this. It transforms `Some(Some(value))` into `Some(value)`, and `None` otherwise.
+Sometimes we want to reduce the nested `Option` values into a single `Option`, you can use `bind(x => x)` to achieve this. It transforms `Some(Some(value))` into `Some(value)`, and `None` otherwise.
 
 ```mbt check
 ///|
diff --git a/option/option_test.mbt b/option/option_test.mbt
index c81cbf8b51..0a8033cb63 100644
--- a/option/option_test.mbt
+++ b/option/option_test.mbt
@@ -186,12 +186,7 @@ test "compare" {
 test "iter" {
   let x = Option::Some(42)
   let exb = StringBuilder(size_hint=0)
-  x
-  .iter()
-  .each(x => {
-    exb.write_string(x.to_string())
-    exb.write_char('\n')
-  })
+  x.iter().each(x => exb <+ "\{x}\n")
   inspect(
     exb,
     content=(
@@ -201,12 +196,7 @@ test "iter" {
   )
   exb.reset()
   let y : Int? = None
-  y
-  .iter()
-  .each(x => {
-    exb.write_string(x.to_string())
-    exb.write_char('\n')
-  })
+  y.iter().each(x => exb <+ "\{x}\n")
   inspect(exb, content="")
 }
 
diff --git a/prelude/README.mbt.md b/prelude/README.mbt.md
index 0ec8c1f771..ca45025762 100644
--- a/prelude/README.mbt.md
+++ b/prelude/README.mbt.md
@@ -42,7 +42,7 @@ test "Set constructor from prelude" {
 ## Re-exported Functions
 
 - `println`, `abort`, `panic`, `fail` — output and error handling
-- `inspect`, `debug_inspect`, `debug`, `to_repr` — debugging
+- `inspect`, `debug_inspect`, `debug` — debugging
 - `@test.assert_eq`, `@test.assert_not_eq`, `assert_true`, `assert_false`, `debug_assert` — assertions
 - `ignore`, `physical_equal` — utilities
 - `json_inspect` — JSON-based snapshot testing
diff --git a/priority_queue/README.mbt.md b/priority_queue/README.mbt.md
index 2e4f5d4a33..bf0516315d 100644
--- a/priority_queue/README.mbt.md
+++ b/priority_queue/README.mbt.md
@@ -6,7 +6,7 @@ A priority queue is a data structure capable of maintaining maximum/minimum valu
 
 ## Create
 
-You can use `PriorityQueue([])` or `of()` to create a priority queue.
+You can use `PriorityQueue([])` or `from_array()` to create a priority queue.
 
 ```mbt check
 ///|
@@ -126,7 +126,7 @@ test {
 ```mbt check
 ///|
 test {
-  let pq = @priority_queue.from_iter([3, 1, 2].iter())
+  let pq = @priority_queue.from_iter([|3, 1, 2|])
   @test.assert_eq(pq.peek(), Some(3))
 }
 ```
@@ -147,7 +147,7 @@ test {
 
 ## Copy
 
-`copy()` creates a shallow clone.
+`copy()` duplicates the heap structure: the copy gets its own nodes, so pushing to or popping from one queue does not affect the other. The stored elements themselves are not copied — both queues refer to the same values, so if the element type is mutable, mutating an element is observable through both queues.
 
 ```mbt check
 ///|
diff --git a/priority_queue/priority_queue.mbt b/priority_queue/priority_queue.mbt
index 5f48735723..6e58835b20 100644
--- a/priority_queue/priority_queue.mbt
+++ b/priority_queue/priority_queue.mbt
@@ -14,7 +14,7 @@
 
 ///|
 fn[A] new_priority_queue() -> PriorityQueue[A] {
-  { len: 0, top: None }
+  { len: 0, top: None, }
 }
 
 ///|
@@ -36,11 +36,12 @@ pub fn[A : Compare] PriorityQueue::PriorityQueue(
 ) -> PriorityQueue[A] {
   guard arr is [a0, ..] else { return new_priority_queue() }
   let len = arr.length()
-  for i = 1, acc = { content: a0, sibling: None, child: None } {
+  for i = 1, acc = { content: a0, sibling: None, child: None, } {
     if i < len {
-      continue i + 1, meld(acc, { content: arr[i], sibling: None, child: None })
+      continue i + 1,
+        meld(acc, { content: arr[i], sibling: None, child: None, })
     } else {
-      break { len, top: Some(acc) }
+      break { len, top: Some(acc), }
     }
   }
 }
@@ -99,7 +100,7 @@ fn[A] copy_node(x : Node[A]?) -> Node[A]? {
 /// ```
 #alias(clone, deprecated)
 pub fn[A] PriorityQueue::copy(self : PriorityQueue[A]) -> PriorityQueue[A] {
-  { len: self.len, top: copy_node(self.top) }
+  { len: self.len, top: copy_node(self.top), }
 }
 
 ///|
@@ -107,12 +108,12 @@ pub fn[A] PriorityQueue::copy(self : PriorityQueue[A]) -> PriorityQueue[A] {
 pub fn[A : Compare] PriorityQueue::to_array(
   self : PriorityQueue[A],
 ) -> Array[A] {
-  let arr = Array::new(capacity=self.len)
+  let arr = Array(capacity=self.len)
   let stack : Array[Node[A]?] = [self.top]
   while stack.pop() is Some(node) {
     match node {
       None => ()
-      Some({ content, sibling, child }) => {
+      Some({ content, sibling, child, }) => {
         arr.push(content)
         stack.push(sibling)
         stack.push(child)
@@ -258,7 +259,7 @@ pub fn[A : Compare] PriorityQueue::push(
   self : PriorityQueue[A],
   value : A,
 ) -> Unit {
-  let x = { content: value, sibling: None, child: None }
+  let x = { content: value, sibling: None, child: None, }
   self.top = match self.top {
     None => Some(x)
     Some(top) => Some(meld(top, x))
diff --git a/priority_queue/priority_queue_test.mbt b/priority_queue/priority_queue_test.mbt
index a049a0f1a8..000d8ad42e 100644
--- a/priority_queue/priority_queue_test.mbt
+++ b/priority_queue/priority_queue_test.mbt
@@ -67,12 +67,12 @@ test "iter" {
   let buf = StringBuilder(size_hint=20)
   let v = @priority_queue.from_array([1, 2, 3])
   for e in v {
-    buf.write_string("[\{e}]")
+    buf <+ "[\{e}]"
   }
   inspect(buf, content="[3][2][1]")
   buf.reset()
   for e in v.iter().take(2) {
-    buf.write_string("[\{e}]")
+    buf <+ "[\{e}]"
   }
   inspect(buf, content="[3][2]")
 }
@@ -112,7 +112,7 @@ test "pop" {
   let pq_ = @priority_queue.from_array([1])
   pq_.pop() |> ignore
   inspect(pq_.length(), content="0")
-  let p = @priority_queue.from_iter([0, 1, 4, 5, 6, 2, 3].iter())
+  let p = @priority_queue.from_iter([|0, 1, 4, 5, 6, 2, 3|])
   p.pop() |> ignore
   p.pop() |> ignore
   p.pop() |> ignore
@@ -166,7 +166,7 @@ test "is_empty" {
 ///|
 test "from_iter multiple elements iter" {
   debug_inspect(
-    @priority_queue.from_iter([1, 2, 3].iter()),
+    @priority_queue.from_iter([|1, 2, 3|]),
     content=(
       #|
     ),
@@ -176,7 +176,7 @@ test "from_iter multiple elements iter" {
 ///|
 test "from_iter single element iter" {
   debug_inspect(
-    @priority_queue.from_iter([1].iter()),
+    @priority_queue.from_iter([|1|]),
     content=(
       #|
     ),
@@ -185,9 +185,7 @@ test "from_iter single element iter" {
 
 ///|
 test "from_iter empty iter" {
-  let pq : @priority_queue.PriorityQueue[Int] = @priority_queue.from_iter(
-    Iter::empty(),
-  )
+  let pq : @priority_queue.PriorityQueue[Int] = @priority_queue.from_iter([||])
   debug_inspect(
     pq,
     content=(
diff --git a/queue/README.mbt.md b/queue/README.mbt.md
index 2be32582ad..bfa3f8323a 100644
--- a/queue/README.mbt.md
+++ b/queue/README.mbt.md
@@ -5,7 +5,7 @@ Queue is a first in first out (FIFO) data structure, allowing to process their e
 # Usage
 
 ## Create and Clear
-You can create a queue manually by using the `new` or construct it using the `from_array`.
+You can create an empty queue with `Queue([])`, or construct one from an array with `from_array`.
 ```mbt check
 ///|
 test {
@@ -62,7 +62,7 @@ test {
 ```mbt check
 ///|
 test {
-  let queue = @queue.from_iter([1, 2, 3].iter())
+  let queue = @queue.from_iter([|1, 2, 3|])
   @test.assert_eq(queue.length(), 3)
 }
 ```
diff --git a/queue/pkg.generated.mbti b/queue/pkg.generated.mbti
index b29e1c8b7d..83e76dc2ab 100644
--- a/queue/pkg.generated.mbti
+++ b/queue/pkg.generated.mbti
@@ -19,9 +19,9 @@ pub fn[A] Queue::Queue(ArrayView[A]) -> Self[A]
 pub fn[A] Queue::clear(Self[A]) -> Unit
 #alias(clone, deprecated)
 pub fn[A] Queue::copy(Self[A]) -> Self[A]
-pub fn[A] Queue::each(Self[A], (A) -> Unit) -> Unit
-pub fn[A] Queue::eachi(Self[A], (Int, A) -> Unit) -> Unit
-pub fn[A, B] Queue::fold(Self[A], init~ : B, (B, A) -> B) -> B
+pub fn[A] Queue::each(Self[A], (A) -> Unit raise?) -> Unit raise?
+pub fn[A] Queue::eachi(Self[A], (Int, A) -> Unit raise?) -> Unit raise?
+pub fn[A, B] Queue::fold(Self[A], init~ : B, (B, A) -> B raise?) -> B raise?
 #alias(from_iterator, deprecated)
 #as_free_fn(from_iterator, deprecated)
 #as_free_fn
diff --git a/queue/queue.mbt b/queue/queue.mbt
index c1c59b4993..8a6ec8e1a9 100644
--- a/queue/queue.mbt
+++ b/queue/queue.mbt
@@ -14,7 +14,7 @@
 
 ///|
 fn[A] new_queue() -> Queue[A] {
-  { inner: Deque([]) }
+  { inner: Deque([]), }
 }
 
 ///|
@@ -33,7 +33,7 @@ fn[A] new_queue() -> Queue[A] {
 #alias(of, deprecated="Use from_array instead")
 #as_free_fn(of, deprecated="Use from_array instead")
 pub fn[A] Queue::Queue(arr : ArrayView[A]) -> Queue[A] {
-  { inner: Deque(arr) }
+  { inner: Deque(arr), }
 }
 
 ///|
@@ -189,7 +189,7 @@ pub fn[A] Queue::pop(self : Queue[A]) -> A? {
 /// }
 /// ```
 #locals(f)
-pub fn[A] Queue::each(self : Queue[A], f : (A) -> Unit) -> Unit {
+pub fn[A] Queue::each(self : Queue[A], f : (A) -> Unit raise?) -> Unit raise? {
   for x in self.inner {
     f(x)
   }
@@ -208,7 +208,10 @@ pub fn[A] Queue::each(self : Queue[A], f : (A) -> Unit) -> Unit {
 /// }
 /// ```
 #locals(f)
-pub fn[A] Queue::eachi(self : Queue[A], f : (Int, A) -> Unit) -> Unit {
+pub fn[A] Queue::eachi(
+  self : Queue[A],
+  f : (Int, A) -> Unit raise?,
+) -> Unit raise? {
   for i, x in self.inner {
     f(i, x)
   }
@@ -226,7 +229,11 @@ pub fn[A] Queue::eachi(self : Queue[A], f : (Int, A) -> Unit) -> Unit {
 /// }
 /// ```
 #locals(f)
-pub fn[A, B] Queue::fold(self : Queue[A], init~ : B, f : (B, A) -> B) -> B {
+pub fn[A, B] Queue::fold(
+  self : Queue[A],
+  init~ : B,
+  f : (B, A) -> B raise?,
+) -> B raise? {
   let mut acc = init
   for x in self.inner {
     acc = f(acc, x)
@@ -247,7 +254,7 @@ pub fn[A, B] Queue::fold(self : Queue[A], init~ : B, f : (B, A) -> B) -> B {
 /// ```
 #alias(clone, deprecated)
 pub fn[A] Queue::copy(self : Queue[A]) -> Queue[A] {
-  { inner: self.inner.copy() }
+  { inner: self.inner.copy(), }
 }
 
 ///|
@@ -294,7 +301,7 @@ pub fn[A] Queue::iter(self : Queue[A]) -> Iter[A] {
 /// # Example
 /// ```mbt check
 /// test {
-///   let queue : @queue.Queue[Int] = @queue.from_iter(Iter::empty())
+///   let queue : @queue.Queue[Int] = @queue.from_iter([||])
 ///   @test.assert_eq(queue.length(), 0)
 /// }
 /// ```
@@ -302,7 +309,7 @@ pub fn[A] Queue::iter(self : Queue[A]) -> Iter[A] {
 #alias(from_iterator, deprecated)
 #as_free_fn(from_iterator, deprecated)
 pub fn[A] Queue::from_iter(iter : Iter[A]) -> Queue[A] {
-  { inner: @deque.from_iter(iter) }
+  { inner: @deque.from_iter(iter), }
 }
 
 ///|
diff --git a/queue/queue_test.mbt b/queue/queue_test.mbt
index d5a43ca66d..c71c71a670 100644
--- a/queue/queue_test.mbt
+++ b/queue/queue_test.mbt
@@ -88,7 +88,7 @@ test "from_array_2" {
       #|
     ),
   )
-  let q : @queue.Queue[Int] = @queue.from_array(Array::new(capacity=10))
+  let q : @queue.Queue[Int] = @queue.from_array(Array(capacity=10))
   debug_inspect(
     q,
     content=(
@@ -239,6 +239,47 @@ test "fold" {
   )
 }
 
+///|
+fn expect_callback_failure(action : () -> Unit raise) -> Unit raise {
+  try action() catch {
+    Failure(message) => inspect(message, content="callback failed")
+    _ => fail("unexpected error")
+  } noraise {
+    _ => fail("expected error")
+  }
+}
+
+///|
+fn fail_on_two(x : Int) -> Unit raise {
+  if x == 2 {
+    raise Failure::Failure("callback failed")
+  }
+}
+
+///|
+test "each with error callback" {
+  let queue = @queue.from_array([1, 2, 3])
+  expect_callback_failure(() => queue.each(fail_on_two))
+}
+
+///|
+test "eachi with error callback" {
+  let queue = @queue.from_array([1, 2, 3])
+  expect_callback_failure(() => queue.eachi((_i, x) => fail_on_two(x)))
+}
+
+///|
+test "fold with error callback" {
+  let queue = @queue.from_array([1, 2, 3])
+  expect_callback_failure(() => {
+    queue.fold(init=0, (acc, x) => {
+      fail_on_two(x)
+      acc + x
+    })
+    |> ignore
+  })
+}
+
 ///|
 test "length" {
   let empty : @queue.Queue[Unit] = @queue.from_array([])
@@ -255,7 +296,7 @@ test "iter" {
 ///|
 test "from_iter multiple elements iter" {
   debug_inspect(
-    @queue.from_iter([1, 2, 3].iter()),
+    @queue.from_iter([|1, 2, 3|]),
     content=(
       #|
     ),
@@ -265,7 +306,7 @@ test "from_iter multiple elements iter" {
 ///|
 test "from_iter single element iter" {
   debug_inspect(
-    @queue.from_iter([1].iter()),
+    @queue.from_iter([|1|]),
     content=(
       #|
     ),
@@ -274,7 +315,7 @@ test "from_iter single element iter" {
 
 ///|
 test "from_iter empty iter" {
-  let pq : @queue.Queue[Int] = @queue.from_iter(Iter::empty())
+  let pq : @queue.Queue[Int] = @queue.from_iter([||])
   debug_inspect(
     pq,
     content=(
diff --git a/quickcheck/README.mbt.md b/quickcheck/README.mbt.md
index 9229929e85..057cc0cfa9 100644
--- a/quickcheck/README.mbt.md
+++ b/quickcheck/README.mbt.md
@@ -78,7 +78,7 @@ test "inspect a counterexample" {
     report,
     content=(
       #|Falsified(
-      #|  counterexample=0,
+      #|  counterexample=-1028290551,
       #|  tests=1,
       #|  size=0,
       #|  shrinks=0,
@@ -114,10 +114,10 @@ test "context describes the shrunk counterexample" {
       #|Falsified(
       #|  counterexample=3,
       #|  context="rendered input <3>",
-      #|  tests=5,
-      #|  size=4,
-      #|  shrinks=0,
-      #|  shrink_attempts=2,
+      #|  tests=2,
+      #|  size=1,
+      #|  shrinks=30,
+      #|  shrink_attempts=33,
       #|)
     ),
   )
@@ -176,7 +176,12 @@ test "basic generation" {
   let b : Bool = @quickcheck.gen()
   inspect(b, content="true")
   let x : Int = @quickcheck.gen()
-  inspect(x, content="0")
+  inspect(
+    x,
+    content=(
+      #|1118850684
+    ),
+  )
 
   // Generate with size parameter
   let sized : Array[Int] = @quickcheck.gen(size=5)
@@ -192,7 +197,12 @@ Generate multiple test cases using the `samples` function:
 ///|
 test "multiple samples" {
   let ints : Array[Int] = @quickcheck.samples(5)
-  debug_inspect(ints, content="[0, 0, 0, -1, -1]")
+  debug_inspect(
+    ints,
+    content=(
+      #|[1118850684, -99999, 846697896, -134217729, 67108863]
+    ),
+  )
   let strings : Array[String] = @quickcheck.samples(12)
   debug_inspect(
     strings[5:10],
@@ -222,7 +232,17 @@ test "builtin types" {
   let v : (Int, Int64, UInt, UInt64, Float, Double, BigInt) = @quickcheck.gen()
   debug_inspect(
     v,
-    content="(0, 0, 0, 0, 0.23986786603927612, 0.7917029935679342, 0)",
+    content=(
+      #|(
+      #|  -99999,
+      #|  5259998046134461054,
+      #|  228947857,
+      #|  8766027650639656979,
+      #|  0.23986786603927612,
+      #|  0.7917029935679342,
+      #|  0,
+      #|)
+    ),
   )
   // Collections
   let v : (String, Bytes, Iter[Int]) = @quickcheck.gen()
@@ -236,9 +256,23 @@ test "builtin types" {
 }
 ```
 
+Integer generation for `Int16`, `UInt16`, `Int`, `UInt`, `Int64`, and `UInt64`
+intentionally ignores the size hint. Its default distribution combines
+full-width random bit patterns, common small values, values next to powers of
+two and ten, and type extrema. This keeps the entire scalar domain reachable
+while regularly exercising overflow, bit-width, and formatting boundaries.
+`Byte` remains uniformly distributed across all 256 bit patterns so `Bytes`
+and other byte-oriented workloads retain broad payload coverage. Fixed-width
+integer shrinkers try a midpoint first, then progressively finer candidates
+approaching the original value, with zero as the final fallback. With the
+greedy shrink driver, large failures still converge without linearly walking
+the numeric range while avoiding a likely rejected zero at every level.
+
 ## Custom Types
 
-Implement `Arbitrary` trait for custom types:
+Implement the `Arbitrary` and `Shrink` traits for custom types. Both traits are
+available from the `quickcheck` facade; no separate `quickcheck/shrink` import
+is needed:
 
 ```mbt check
 ///|
@@ -248,10 +282,18 @@ priv struct Point {
 } derive(Debug)
 
 ///|
-impl Arbitrary for Point with fn arbitrary(size, r0) {
+impl @quickcheck.Arbitrary for Point with fn arbitrary(size, r0) {
   let r1 = r0.split()
   let y = @quickcheck.Arbitrary::arbitrary(size, r1)
-  { x: @quickcheck.Arbitrary::arbitrary(size, r0), y }
+  { x: @quickcheck.Arbitrary::arbitrary(size, r0), y, }
+}
+
+///|
+impl @quickcheck.Shrink for Point with fn shrink(self) {
+  @quickcheck.Shrink::shrink((self.x, self.y)).map(pair => {
+    x: pair.0,
+    y: pair.1,
+  })
 }
 
 ///|
@@ -260,7 +302,7 @@ test "custom type generation" {
   debug_inspect(
     point,
     content=(
-      #|{ x: 0, y: 0 }
+      #|{ x: -99999, y: 2 }
     ),
   )
   let points : Array[Point] = @quickcheck.samples(10)
@@ -268,7 +310,23 @@ test "custom type generation" {
     points[6:],
     content=(
       #|
+      #|  [
+      #|    { x: 46354256, y: 1201652877 },
+      #|    { x: 1, y: 2147483646 },
+      #|    { x: 108552206, y: -1 },
+      #|    { x: -1073741824, y: 2147483647 },
+      #|  ]>
+    ),
+  )
+}
+
+///|
+test "custom type shrinking" {
+  let point : Point = { x: 2, y: 1, }
+  debug_inspect(
+    @quickcheck.Shrink::shrink(point).collect(),
+    content=(
+      #|[{ x: 1, y: 1 }, { x: 0, y: 1 }, { x: 2, y: 0 }]
     ),
   )
 }
diff --git a/quickcheck/arbitrary.mbt b/quickcheck/arbitrary.mbt
index b8cbc86ca0..35e83182b3 100644
--- a/quickcheck/arbitrary.mbt
+++ b/quickcheck/arbitrary.mbt
@@ -15,9 +15,9 @@
 ///|
 /// Trait for types that can be randomly generated
 pub(open) trait Arbitrary {
-  // `arbitrary` function takes a random number generator and a number that 
-  // determines the size of the generated value as arguments, and returns a
-  // randomly generated value of certain type.
+  // `arbitrary` takes a size hint and a random number generator, and returns a
+  // generated value. Types whose complexity is independent of size may ignore
+  // the hint.
   fn arbitrary(Int, @splitmix.RandomState) -> Self
 }
 
@@ -31,24 +31,6 @@ pub impl Arbitrary for Bool with fn arbitrary(_, rs) {
   rs.next_double() < 0.5
 }
 
-///|
-pub impl Arbitrary for Int with fn arbitrary(size, rs) {
-  if size == 0 {
-    0
-  } else {
-    rs.next_int() % size
-  }
-}
-
-///|
-pub impl Arbitrary for UInt with fn arbitrary(size, rs) {
-  if size == 0 {
-    0
-  } else {
-    rs.next_uint() % size.reinterpret_as_uint()
-  }
-}
-
 ///|
 pub impl Arbitrary for Byte with fn arbitrary(_, rs) {
   rs.next_uint().to_byte()
@@ -64,24 +46,6 @@ pub impl Arbitrary for Bytes with fn arbitrary(size, rs) {
   }
 }
 
-///|
-pub impl Arbitrary for Int64 with fn arbitrary(size, rs) {
-  if size == 0 {
-    0
-  } else {
-    rs.next_int64() % size.to_int64()
-  }
-}
-
-///|
-pub impl Arbitrary for UInt64 with fn arbitrary(size, rs) {
-  if size == 0 {
-    0
-  } else {
-    rs.next_uint64() % size.to_uint64()
-  }
-}
-
 ///|
 pub impl Arbitrary for Float with fn arbitrary(_, rs) {
   rs.next_float()
@@ -109,12 +73,12 @@ pub impl Arbitrary for Char with fn arbitrary(_, rs) {
 ///|
 pub impl Arbitrary for String with fn arbitrary(size, rs) {
   let len = if size == 0 { 0 } else { rs.next_positive_int() % size }
-  for i in 0.. X::arbitrary(i, rs))
 }
 
+///|
+pub impl[X : Arbitrary] Arbitrary for ReadOnlyArray[X] with fn arbitrary(
+  size,
+  rs,
+) {
+  let values : Array[X] = Arbitrary::arbitrary(size, rs)
+  ReadOnlyArray::from_array(values)
+}
+
 ///|
 pub impl[A : Arbitrary] Arbitrary for ArrayView[A] with fn arbitrary(size, rs) {
   let arr : Array[A] = Arbitrary::arbitrary(size, rs)
@@ -265,3 +238,36 @@ pub impl[
   let (v1, v2, v3, v4, v5, v6) = Arbitrary::arbitrary(size, r1)
   (Arbitrary::arbitrary(size, r0), v1, v2, v3, v4, v5, v6)
 }
+
+///|
+pub impl[
+  A : Arbitrary,
+  B : Arbitrary,
+  C : Arbitrary,
+  D : Arbitrary,
+  E : Arbitrary,
+  F : Arbitrary,
+  G : Arbitrary,
+  H : Arbitrary,
+] Arbitrary for (A, B, C, D, E, F, G, H) with fn arbitrary(size, r0) {
+  let r1 = r0.split()
+  let (v1, v2, v3, v4, v5, v6, v7) = Arbitrary::arbitrary(size, r1)
+  (Arbitrary::arbitrary(size, r0), v1, v2, v3, v4, v5, v6, v7)
+}
+
+///|
+pub impl[
+  A : Arbitrary,
+  B : Arbitrary,
+  C : Arbitrary,
+  D : Arbitrary,
+  E : Arbitrary,
+  F : Arbitrary,
+  G : Arbitrary,
+  H : Arbitrary,
+  I : Arbitrary,
+] Arbitrary for (A, B, C, D, E, F, G, H, I) with fn arbitrary(size, r0) {
+  let r1 = r0.split()
+  let (v1, v2, v3, v4, v5, v6, v7, v8) = Arbitrary::arbitrary(size, r1)
+  (Arbitrary::arbitrary(size, r0), v1, v2, v3, v4, v5, v6, v7, v8)
+}
diff --git a/quickcheck/arbitrary_collections.mbt b/quickcheck/arbitrary_collections.mbt
index 46cfc7e6cb..592cac2fc7 100644
--- a/quickcheck/arbitrary_collections.mbt
+++ b/quickcheck/arbitrary_collections.mbt
@@ -27,6 +27,33 @@ pub impl[X : Arbitrary] Arbitrary for @queue.Queue[X] with fn arbitrary(
   @queue.Queue::from_iter(values)
 }
 
+///|
+pub impl[X : Arbitrary] Arbitrary for @deque.Deque[X] with fn arbitrary(
+  size,
+  rs,
+) {
+  let values : Array[X] = Arbitrary::arbitrary(size, rs)
+  Deque(values)
+}
+
+///|
+pub impl[K : Arbitrary + Hash + Eq, V : Arbitrary] Arbitrary for Map[K, V] with fn arbitrary(
+  size,
+  rs,
+) {
+  let values : Iter[(K, V)] = Arbitrary::arbitrary(size, rs)
+  Map::from_iter(values)
+}
+
+///|
+pub impl[X : Arbitrary + Eq + Hash] Arbitrary for @set.Set[X] with fn arbitrary(
+  size,
+  rs,
+) {
+  let values : Iter[X] = Arbitrary::arbitrary(size, rs)
+  @set.Set::from_iter(values)
+}
+
 ///|
 pub impl[K : Arbitrary + Hash + Eq, V : Arbitrary] Arbitrary for @hashmap.HashMap[
   K,
diff --git a/quickcheck/arbitrary_integer.mbt b/quickcheck/arbitrary_integer.mbt
new file mode 100644
index 0000000000..bbbc7d821a
--- /dev/null
+++ b/quickcheck/arbitrary_integer.mbt
@@ -0,0 +1,172 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+fn decimal_power_count(bits : Int, signed : Bool) -> UInt {
+  match (bits, signed) {
+    (16, _) => 5U
+    (32, _) => 10U
+    (64, true) => 19U
+    (64, false) => 20U
+    _ => abort("arbitrary integer: unsupported bit width")
+  }
+}
+
+///|
+fn canonical_integer_bits(
+  mask : UInt64,
+  signed : Bool,
+  rs : @splitmix.RandomState,
+) -> UInt64 {
+  guard signed else { rs.next_uint(limit=4U).to_uint64() }
+  match rs.next_uint(limit=5U) {
+    0U => 0UL
+    1U => 1UL
+    2U => mask
+    3U => 2UL
+    _ => mask - 1UL
+  }
+}
+
+///|
+fn apply_random_sign(
+  magnitude : UInt64,
+  signed : Bool,
+  rs : @splitmix.RandomState,
+) -> UInt64 {
+  if signed && rs.next_uint(limit=2U) == 1U {
+    0UL - magnitude
+  } else {
+    magnitude
+  }
+}
+
+///|
+fn boundary_delta(base : UInt64, rs : @splitmix.RandomState) -> UInt64 {
+  match rs.next_uint(limit=3U) {
+    0U => base - 1UL
+    1U => base
+    _ => base + 1UL
+  }
+}
+
+///|
+fn binary_boundary_bits(
+  bits : Int,
+  signed : Bool,
+  rs : @splitmix.RandomState,
+) -> UInt64 {
+  let exponent_count = if signed { bits - 1 } else { bits }
+  let exponent = rs
+    .next_uint(limit=exponent_count.reinterpret_as_uint())
+    .reinterpret_as_int()
+  let magnitude = boundary_delta(1UL << exponent, rs)
+  apply_random_sign(magnitude, signed, rs)
+}
+
+///|
+fn decimal_boundary_bits(
+  bits : Int,
+  signed : Bool,
+  rs : @splitmix.RandomState,
+) -> UInt64 {
+  let exponent = rs
+    .next_uint(limit=decimal_power_count(bits, signed))
+    .reinterpret_as_int()
+  let magnitude = boundary_delta(
+    (0 : Int).until(exponent).fold(init=1UL, (power, _) => power * 10UL),
+    rs,
+  )
+  apply_random_sign(magnitude, signed, rs)
+}
+
+///|
+fn extreme_integer_bits(
+  bits : Int,
+  mask : UInt64,
+  signed : Bool,
+  rs : @splitmix.RandomState,
+) -> UInt64 {
+  let choice = rs.next_uint(limit=4U)
+  if signed {
+    let sign_bit = 1UL << (bits - 1)
+    match choice {
+      0U => sign_bit
+      1U => sign_bit + 1UL
+      2U => sign_bit - 2UL
+      _ => sign_bit - 1UL
+    }
+  } else {
+    match choice {
+      0U => mask
+      1U => mask - 1UL
+      2U => mask - 2UL
+      _ => 1UL << (bits - 1)
+    }
+  }
+}
+
+///|
+/// 40% full-width, 20% canonical, 20% binary boundaries,
+/// 10% decimal boundaries, and 10% type extrema.
+fn gen_integer_bits(
+  bits : Int,
+  signed : Bool,
+  rs : @splitmix.RandomState,
+) -> UInt64 {
+  let mask = if bits == 64 {
+    0xffff_ffff_ffff_ffffUL
+  } else {
+    (1UL << bits) - 1UL
+  }
+
+  let value = match rs.next_uint(limit=100U) {
+    0..<40 => rs.next_uint64()
+    40..<60 => canonical_integer_bits(mask, signed, rs)
+    60..<80 => binary_boundary_bits(bits, signed, rs)
+    80..<90 => decimal_boundary_bits(bits, signed, rs)
+    _ => extreme_integer_bits(bits, mask, signed, rs)
+  }
+  value & mask
+}
+
+///|
+pub impl Arbitrary for Int16 with fn arbitrary(_, rs) {
+  Int16::reinterpret_from_uint16(gen_integer_bits(16, true, rs).to_uint16())
+}
+
+///|
+pub impl Arbitrary for UInt16 with fn arbitrary(_, rs) {
+  gen_integer_bits(16, false, rs).to_uint16()
+}
+
+///|
+pub impl Arbitrary for Int with fn arbitrary(_, rs) {
+  gen_integer_bits(32, true, rs).to_uint().reinterpret_as_int()
+}
+
+///|
+pub impl Arbitrary for UInt with fn arbitrary(_, rs) {
+  gen_integer_bits(32, false, rs).to_uint()
+}
+
+///|
+pub impl Arbitrary for Int64 with fn arbitrary(_, rs) {
+  gen_integer_bits(64, true, rs).reinterpret_as_int64()
+}
+
+///|
+pub impl Arbitrary for UInt64 with fn arbitrary(_, rs) {
+  gen_integer_bits(64, false, rs)
+}
diff --git a/strconv/string_view.mbt b/quickcheck/arbitrary_string_bench_test.mbt
similarity index 55%
rename from strconv/string_view.mbt
rename to quickcheck/arbitrary_string_bench_test.mbt
index 7b8571252d..e23bb95506 100644
--- a/strconv/string_view.mbt
+++ b/quickcheck/arbitrary_string_bench_test.mbt
@@ -13,24 +13,14 @@
 // limitations under the License.
 
 ///|
-/// Returns the accumulated value, the slice left, and the number of digits consumed.
-/// It ignores underscore and stops when a non-digit character is found.
-fn[T] StringView::fold_digits(
-  self : Self,
-  init : T,
-  f : (Int, T) -> T,
-) -> (Self, T, Int) {
-  let mut ret = init
-  let mut len = 0
-  let mut str = self
-  while str is [ch, .. rest] {
-    if ch is ('0'..='9') {
-      len += 1
-      ret = f(ch.to_int() - '0', ret)
-    } else if ch != '_' {
-      break
+test "bench Arbitrary String size=10000 count=100" (it : @bench.T) {
+  it.bench(fn() {
+    let state = @splitmix.new()
+    let mut total = 0
+    for _ in 0..<100 {
+      let value : String = @quickcheck.Arbitrary::arbitrary(10000, state)
+      total += value.length()
     }
-    str = rest
-  }
-  (str, ret, len)
+    it.keep(total)
+  })
 }
diff --git a/quickcheck/arbitrary_test.mbt b/quickcheck/arbitrary_test.mbt
index 5ae970f92d..942c8ca58b 100644
--- a/quickcheck/arbitrary_test.mbt
+++ b/quickcheck/arbitrary_test.mbt
@@ -18,6 +18,61 @@ priv struct H {
   y : Int
 } derive(@quickcheck.Arbitrary, Debug)
 
+///|
+fn[T : @quickcheck.Arbitrary] scalar_samples_at_zero(
+  seeds : Array[UInt64],
+) -> Array[T] {
+  seeds.map(seed => @quickcheck.Arbitrary::arbitrary(0, @splitmix.new(seed~)))
+}
+
+///|
+test "fixed-width integer Arbitrary exercises its bug-finding portfolio at size zero" {
+  // Seeds select, in order: full-width, canonical, binary boundary,
+  // decimal boundary, and type-extreme generation.
+  let seeds = [1UL, 0UL, 22UL, 6UL, 7UL]
+  let int16s : Array[Int16] = scalar_samples_at_zero(seeds)
+  let uint16s : Array[UInt16] = scalar_samples_at_zero(seeds)
+  let ints : Array[Int] = scalar_samples_at_zero(seeds)
+  let uints : Array[UInt] = scalar_samples_at_zero(seeds)
+  let int64s : Array[Int64] = scalar_samples_at_zero(seeds)
+  let uint64s : Array[UInt64] = scalar_samples_at_zero(seeds)
+  debug_inspect(int16s, content="[-3191, -1, 2047, -10000, 32766]")
+  debug_inspect(uint16s, content="[62345, 2, 2047, 10000, 65533]")
+  debug_inspect(ints, content="[167113609, -1, 134217727, -10, 2147483646]")
+  debug_inspect(uints, content="[167113609, 2, 134217727, 10, 4294967293]")
+  debug_inspect(
+    int64s,
+    content=(
+      #|[
+      #|  -8964471365135109239,
+      #|  -1,
+      #|  134217727,
+      #|  -100000000000000000,
+      #|  9223372036854775806,
+      #|]
+    ),
+  )
+  debug_inspect(
+    uint64s,
+    content=(
+      #|[
+      #|  9482272708574442377,
+      #|  2,
+      #|  134217727,
+      #|  100000000000000000,
+      #|  18446744073709551613,
+      #|]
+    ),
+  )
+}
+
+///|
+test "Byte Arbitrary draws raw random bits" {
+  let seeds = [1UL, 0UL, 22UL, 6UL, 7UL]
+  let bytes : Array[Byte] = scalar_samples_at_zero(seeds)
+  debug_inspect(bytes, content="[0x89, 0x36, 0x3c, 0x56, 0x5f]")
+}
+
 ///|
 test {
   let state = (Default::default() : @splitmix.RandomState)
@@ -26,7 +81,7 @@ test {
   debug_inspect(
     v,
     content=(
-      #|{ x: 6, y: 4 }
+      #|{ x: 1118850684, y: -99999 }
     ),
   )
   let state = state.split()
@@ -34,7 +89,7 @@ test {
   debug_inspect(
     v,
     content=(
-      #|{ x: 1, y: -6 }
+      #|{ x: -1001, y: -115158677 }
     ),
   )
   let state = state.split()
@@ -42,7 +97,7 @@ test {
   debug_inspect(
     v,
     content=(
-      #|{ x: -9, y: 1 }
+      #|{ x: 10000, y: 515456824 }
     ),
   )
 }
@@ -67,13 +122,24 @@ test "gen with default parameters" {
 ///|
 test "iter arbitrary" {
   let samples : Array[Iter[Int]] = @quickcheck.samples(20)
-  inspect("[0, 0, 0, 1, 2, -2]", content="[0, 0, 0, 1, 2, -2]")
+  debug_inspect(
+    samples[9].to_array(),
+    content=(
+      #|[67108863, 16777217, -365961592, 128, -65535]
+    ),
+  )
   debug_inspect(
     samples[1:5].map(iter => iter.to_array()),
-    content="[[], [], [0], [0]]",
+    content=(
+      #|[[], [], [-2], [311628954]]
+    ),
+  )
+  debug_inspect(
+    samples[10].to_array(),
+    content=(
+      #|[-999999999, -10000000]
+    ),
   )
-  // inspect(samples[9], content="[0, 0, 0, 1, 2, -2]") (Cause infinite loop?)
-  debug_inspect(samples[10].to_array(), content="[0, 0]")
 }
 
 ///|
diff --git a/quickcheck/arbitrary_type_test.mbt b/quickcheck/arbitrary_type_test.mbt
index 0482bf7b90..7a1cac3b09 100644
--- a/quickcheck/arbitrary_type_test.mbt
+++ b/quickcheck/arbitrary_type_test.mbt
@@ -14,95 +14,155 @@
 
 ///|
 test "Arbitrary for BigInt" {
-  let rs = @splitmix.new(seed=1)
-  let _ : @bigint.BigInt = @quickcheck.Arbitrary::arbitrary(1, rs)
+  let samples : Array[@bigint.BigInt] = @quickcheck.samples(10)
+  debug_inspect(
+    samples,
+    content=(
+      #|[
+      #|  0,
+      #|  2236702871533800590,
+      #|  4899806414470401660,
+      #|  -5076203457455444144,
+      #|  -3206590268553584789,
+      #|  -8738668918601119554,
+      #|  -5822442221117384418,
+      #|  4293417177517987002,
+      #|  -174397816515637542,
+      #|  -7574984457386592187,
+      #|]
+    ),
+  )
+}
+
+///|
+test "Arbitrary for builtin Map" {
+  let samples : Array[Map[Int, Bool]] = @quickcheck.samples(20)
+  debug_inspect(
+    [samples[0], samples[10], samples[16], samples[19]],
+    content=(
+      #|[
+      #|  {},
+      #|  { -1465955831: false },
+      #|  {
+      #|    1771074214: false,
+      #|    -576241283: false,
+      #|    2: true,
+      #|    1826945462: false,
+      #|    803533007: false,
+      #|    65535: false,
+      #|    99999999: true,
+      #|    -32769: false,
+      #|    539581892: true,
+      #|    2147483647: false,
+      #|  },
+      #|  {
+      #|    1996268401: true,
+      #|    1996008401: true,
+      #|    -999999: true,
+      #|    0: true,
+      #|    -2: true,
+      #|    1822781582: false,
+      #|  },
+      #|]
+    ),
+  )
+}
+
+///|
+test "Arbitrary for linked Set" {
+  let samples : Array[@set.Set[Int]] = @quickcheck.samples(20)
+  debug_inspect(
+    [samples[0], samples[9], samples[12], samples[17]],
+    content=(
+      #|[, , , ]
+    ),
+  )
+}
+
+///|
+test "Arbitrary for Deque" {
+  let samples : Array[@deque.Deque[Int]] = @quickcheck.samples(20)
+  debug_inspect(
+    [samples[0], samples[9], samples[12], samples[17]],
+    content=(
+      #|[, , , ]
+    ),
+  )
 }
 
 ///|
-fn[K : Hash + Eq, V : Eq] verify_hashmap_content(
-  map : @hashmap.HashMap[K, V],
-  expected : Array[(K, V)],
-) -> Unit raise {
-  for entry in expected {
-    let (k, v) = entry
-    assert_true(map.contains_kv(k, v))
-  }
-  assert_true(map.length() == expected.length())
+test "Arbitrary for ReadOnlyArray" {
+  let samples : Array[ReadOnlyArray[Int]] = @quickcheck.samples(20)
+  debug_inspect(
+    [samples[0], samples[9], samples[12], samples[17]],
+    content=(
+      #|[
+      #|  ,
+      #|  ,
+      #|  ,
+      #|  ,
+      #|]
+    ),
+  )
+}
+
+///|
+test "Arbitrary for 16-bit integers" {
+  let signed_samples : Array[Int16] = @quickcheck.samples(10)
+  let unsigned_samples : Array[UInt16] = @quickcheck.samples(10)
+  debug_inspect(
+    (signed_samples, unsigned_samples),
+    content=(
+      #|(
+      #|  [20092, 1001, -1, 255, -2049, 1023, 257, -8568, 128, 0],
+      #|  [20092, 1001, 5306, 65534, 62607, 38312, 32769, 45376, 65535, 257],
+      #|)
+    ),
+  )
 }
 
 ///|
 test "Arbitrary for HashMap" {
   let samples : Array[@hashmap.HashMap[String, Int]] = @quickcheck.samples(20)
-  let data : ReadOnlyArray[Array[(String, Int)]] = [
-    [],
-    [],
-    [],
-    [("", 0)],
-    [("", 0)],
-    [],
-    [],
-    [("", 0)],
-    [("", 0)],
-    [("", 0)],
-    [("򹽐f)\u{02}", 3), ("", 0), ("󠃕", -2)],
-    [("", 0)],
-    [("񌗣j#", 1), ("2", 4), ("", 0), ("&G", 1), (" {
+    let entries = sample.to_array()
+    entries.sort()
+    entries
+  })
+  debug_inspect(
+    selected,
+    content=(
+      #|[
+      #|  [],
+      #|  [("", -2147483648), ("󠃕", 1345101380), ("򹽐f)\u{02}", -16777217)],
+      #|  [
+      #|    ("", 2147483646),
+      #|    ("\u{1e}", 0),
+      #|    ("D", 691939105),
+      #|    ("}", -1),
+      #|    ("\u{0b},", 2048),
+      #|    ("󾥭nV", -1),
+      #|    ("B\u{1b}䝷\u{00}j", -1001),
+      #|    ("l\tX\u{16}񨩶", -928655867),
+      #|    ("⧺]/\u{0e}rDHH\u{15}", -2147483648),
+      #|    ("󨶏𣷵C\u{13}Vdg񽔊", 555102954),
+      #|  ],
+      #|  [
+      #|    ("", 0),
+      #|    ("U", -31),
+      #|    ("8\u{0e}", 954766200),
+      #|    ("EP", -2147483647),
+      #|    ("Mb", -906462046),
+      #|    ("񣙝x6", 130600888),
+      #|    ("(򃵍\u{10}`", 1),
+      #|    ("𰜫=\u{0f}\u{1d}", 2048),
+      #|    ("\u{1d}CY+򏏻񶘶", 0),
+      #|    ("􉞓\u{15}Q\u{07}P?Y", -268435457),
+      #|    ("4/!\u{1e}\u{10}Rd󠘟V", 2147483646),
+      #|  ],
+      #|]
+    ),
+  )
 }
 
 ///|
@@ -111,13 +171,31 @@ test "Arbitrary for Queue" {
   debug_inspect(
     samples[1:5],
     content=(
-      #|, , , ]>
+      #|,
+      #|    ,
+      #|    ,
+      #|    ,
+      #|  ]>
     ),
   )
   debug_inspect(
     samples[15],
     content=(
-      #|
+      #|
     ),
   )
 }
@@ -137,10 +215,19 @@ test "Arbitrary for SortedMap" {
       #|,
+      #|    ,
+      #|    ,
       #|    ,
-      #|    ,
-      #|    ,
-      #|    ,
+      #|    ,
       #|  ]>
     ),
   )
@@ -149,10 +236,46 @@ test "Arbitrary for SortedMap" {
     content=(
       #|,
-      #|    ,
-      #|    ,
-      #|    ,
+      #|    ,
+      #|    ,
+      #|    ,
+      #|    ,
       #|  ]>
     ),
   )
@@ -164,14 +287,26 @@ test "Arbitrary for tuples" {
   debug_inspect(
     t,
     content=(
-      #|[(0, ""), (0, ""), (0, ""), (-2, "l>"), (2, "C")]
+      #|[
+      #|  (-99999, ""),
+      #|  (-134217729, ""),
+      #|  (16777217, ""),
+      #|  (128, "e򴜞"),
+      #|  (33554431, ""),
+      #|]
     ),
   )
   let t : Array[(Int, String, UInt)] = @quickcheck.samples(5)
   debug_inspect(
     t,
     content=(
-      #|[(0, "", 0), (0, "", 0), (0, "", 1), (-2, ">s", 0), (2, "\u{11}", 0)]
+      #|[
+      #|  (-99999, "", 2047),
+      #|  (-134217729, "", 1023),
+      #|  (16777217, "@", 16383),
+      #|  (128, "򴜞", 1023),
+      #|  (33554431, "\u{05}", 3709967457),
+      #|]
     ),
   )
   let t : Array[(Int, String, UInt, Byte)] = @quickcheck.samples(5)
@@ -179,11 +314,11 @@ test "Arbitrary for tuples" {
     t,
     content=(
       #|[
-      #|  (0, "", 0, 0x2a),
-      #|  (0, "", 0, 0x77),
-      #|  (0, "", 1, 0x0d),
-      #|  (-2, ">s", 1, 0x4a),
-      #|  (2, "\u{11}", 0, 0xe5),
+      #|  (-99999, "", 228947857, 0x2a),
+      #|  (-134217729, "", 101, 0xe5),
+      #|  (16777217, "@", 10000001, 0x5a),
+      #|  (128, "򴜞", 1532968982, 0x1c),
+      #|  (33554431, "\u{05}", 807828953, 0x3d),
       #|]
     ),
   )
@@ -192,11 +327,11 @@ test "Arbitrary for tuples" {
     t,
     content=(
       #|[
-      #|  (0, "", 0, 0x89, 'b'),
-      #|  (0, "", 0, 0x3b, '\u{1e}'),
-      #|  (0, "", 1, 0x08, '\u{10}'),
-      #|  (-2, ">s", 1, 0x23, '\r'),
-      #|  (2, "\u{11}", 0, 0xb7, '`'),
+      #|  (-99999, "", 228947857, 0x89, 'b'),
+      #|  (-134217729, "", 101, 0xb7, '`'),
+      #|  (16777217, "@", 10000001, 0xd8, '\u{1a}'),
+      #|  (128, "򴜞", 1532968982, 0x14, '󠚵'),
+      #|  (33554431, "\u{05}", 807828953, 0xfc, '"'),
       #|]
     ),
   )
@@ -205,11 +340,11 @@ test "Arbitrary for tuples" {
     t,
     content=(
       #|[
-      #|  (0, "", 0, 0x89, '񁌝', true),
-      #|  (0, "", 0, 0x3b, '\n', true),
-      #|  (0, "", 1, 0x08, 'A', true),
-      #|  (-2, ">s", 1, 0x23, ';', false),
-      #|  (2, "\u{11}", 0, 0xb7, 'D', true),
+      #|  (-99999, "", 228947857, 0x89, '񁌝', true),
+      #|  (-134217729, "", 101, 0xb7, 'D', true),
+      #|  (16777217, "@", 10000001, 0xd8, '\\', false),
+      #|  (128, "򴜞", 1532968982, 0x14, 'q', true),
+      #|  (33554431, "\u{05}", 807828953, 0xfc, '$', false),
       #|]
     ),
   )
@@ -220,11 +355,49 @@ test "Arbitrary for tuples" {
     t,
     content=(
       #|[
-      #|  (0, "", 0, 0x89, '񁌝', false, ()),
-      #|  (0, "", 0, 0x3b, '\n', true, ()),
-      #|  (0, "", 1, 0x08, 'A', true, ()),
-      #|  (-2, ">s", 1, 0x23, ';', true, ()),
-      #|  (2, "\u{11}", 0, 0xb7, 'D', true, ()),
+      #|  (-99999, "", 228947857, 0x89, '񁌝', false, ()),
+      #|  (-134217729, "", 101, 0xb7, 'D', true, ()),
+      #|  (16777217, "@", 10000001, 0xd8, '\\', false, ()),
+      #|  (128, "򴜞", 1532968982, 0x14, 'q', false, ()),
+      #|  (33554431, "\u{05}", 807828953, 0xfc, '$', true, ()),
+      #|]
+    ),
+  )
+}
+
+///|
+test "Arbitrary for 8-tuple" {
+  let samples : Array[(Int, String, UInt, Byte, Char, Bool, Unit, Int16)] = @quickcheck.samples(
+    5,
+  )
+  debug_inspect(
+    samples,
+    content=(
+      #|[
+      #|  (-99999, "", 228947857, 0x89, '񁌝', false, (), -1),
+      #|  (-134217729, "", 101, 0xb7, 'D', true, (), 10),
+      #|  (16777217, "@", 10000001, 0xd8, '\\', false, (), -10415),
+      #|  (128, "򴜞", 1532968982, 0x14, 'q', false, (), -400),
+      #|  (33554431, "\u{05}", 807828953, 0xfc, '$', true, (), 2),
+      #|]
+    ),
+  )
+}
+
+///|
+test "Arbitrary for 9-tuple" {
+  let samples : Array[(Int, String, UInt, Byte, Char, Bool, Unit, Int16, Byte)] = @quickcheck.samples(
+    5,
+  )
+  debug_inspect(
+    samples,
+    content=(
+      #|[
+      #|  (-99999, "", 228947857, 0x89, '񁌝', false, (), 511, 0x6c),
+      #|  (-134217729, "", 101, 0xb7, 'D', true, (), 1000, 0xc0),
+      #|  (16777217, "@", 10000001, 0xd8, '\\', false, (), 32766, 0x33),
+      #|  (128, "򴜞", 1532968982, 0x14, 'q', false, (), 1024, 0x38),
+      #|  (33554431, "\u{05}", 807828953, 0xfc, '$', true, (), -1, 0xa5),
       #|]
     ),
   )
@@ -232,8 +405,19 @@ test "Arbitrary for tuples" {
 
 ///|
 test "Arbitrary for Ref" {
-  let samples = (@quickcheck.samples(3) : Array[@ref.Ref[Int]])
-  inspect(samples.length(), content="3")
+  let samples = (@quickcheck.samples(5) : Array[@ref.Ref[Int]])
+  debug_inspect(
+    samples,
+    content=(
+      #|[
+      #|  ,
+      #|  ,
+      #|  ,
+      #|  ,
+      #|  ,
+      #|]
+    ),
+  )
 }
 
 ///|
@@ -248,9 +432,9 @@ test "Arbitrary for PriorityQueue" {
       #|  [
       #|    ,
       #|    ,
-      #|    ,
-      #|    ,
-      #|    ,
+      #|    ,
+      #|    ,
+      #|    ,
       #|  ]>
     ),
   )
@@ -259,10 +443,34 @@ test "Arbitrary for PriorityQueue" {
     content=(
       #|,
-      #|    ,
-      #|    ,
-      #|    ,
+      #|    ,
+      #|    ,
+      #|    ,
+      #|    ,
       #|  ]>
     ),
   )
@@ -270,32 +478,23 @@ test "Arbitrary for PriorityQueue" {
 
 ///|
 test "Arbitrary for HashSet" {
-  let samples : Array[@hashset.HashSet[Int]] = @quickcheck.samples(20)
-  let cases : ReadOnlyArray[@hashset.HashSet[Int]] = [
-    @hashset.from_array([]),
-    @hashset.from_array([]),
-    @hashset.from_array([]),
-    @hashset.from_array([0]),
-    @hashset.from_array([0]),
-    @hashset.from_array([]),
-    @hashset.from_array([]),
-    @hashset.from_array([0]),
-    @hashset.from_array([0]),
-    @hashset.from_array([0, 3, 1, 2]),
-    @hashset.from_array([0, 1, -2]),
-    @hashset.from_array([-2, 0, -1]),
-    @hashset.from_array([-5, 0, 8, 4, -2]),
-    @hashset.from_array([0, 2, -1]),
-    @hashset.from_array([0]),
-    @hashset.from_array([]),
-    @hashset.from_array([-2, 0, -3, -1]),
-    @hashset.from_array([-1, 0, 3, 1, -6, 2]),
-    @hashset.from_array([0]),
-    @hashset.from_array([-5, -1, 6, 0, 2, -2]),
-  ]
-  for i in 0..<20 {
-    assert_true(samples[i].symmetric_difference(cases[i]).is_empty())
-  }
+  let samples : ArrayView[@hashset.HashSet[Int]] = @quickcheck.samples(20)[8:12]
+  let selected = samples.map(sample => {
+    let values = sample.to_array()
+    values.sort()
+    values
+  })
+  debug_inspect(
+    selected,
+    content=(
+      #|[
+      #|  [-999999999, -65535, -2, 128, 311628954, 1144392696],
+      #|  [],
+      #|  [-1609132827, -613468993, 322263955],
+      #|  [-1025403238, 0, 1],
+      #|]
+    ),
+  )
 }
 
 ///|
@@ -307,14 +506,14 @@ test "Option arbitrary" {
       #|[
       #|  None,
       #|  None,
+      #|  Some(1073741825),
+      #|  Some(-1721961329),
       #|  Some(-1),
-      #|  Some(0),
+      #|  Some(1677256795),
+      #|  Some(-1497190080),
       #|  None,
-      #|  Some(0),
-      #|  Some(-5),
-      #|  Some(2),
       #|  None,
-      #|  Some(4),
+      #|  Some(16777217),
       #|]
     ),
   )
diff --git a/quickcheck/driver_test.mbt b/quickcheck/driver_test.mbt
index bb59fd1b97..5c83a47cb5 100644
--- a/quickcheck/driver_test.mbt
+++ b/quickcheck/driver_test.mbt
@@ -34,9 +34,9 @@ impl @quickcheck.Arbitrary for ShrinkInput with fn arbitrary(_, _) {
 ///|
 impl @shrink.Shrink for ShrinkInput with fn shrink(input) {
   if input.0 <= 0 {
-    Iter::empty()
+    [||]
   } else {
-    [ShrinkInput(input.0 - 1), ShrinkInput(0)].iter()
+    [|ShrinkInput(input.0 - 1), ShrinkInput(0)|]
   }
 }
 
@@ -59,9 +59,9 @@ impl @quickcheck.Arbitrary for DriftInput with fn arbitrary(_, _) {
 ///|
 impl @shrink.Shrink for DriftInput with fn shrink(input) {
   if input.0 == 10 {
-    [DriftInput(9), DriftInput(8)].iter()
+    [|DriftInput(9), DriftInput(8)|]
   } else {
-    Iter::empty()
+    [||]
   }
 }
 
@@ -76,9 +76,9 @@ impl @quickcheck.Arbitrary for FilteredShrinkInput with fn arbitrary(_, _) {
 ///|
 impl @shrink.Shrink for FilteredShrinkInput with fn shrink(input) {
   match input.0 {
-    10 => [FilteredShrinkInput(9), FilteredShrinkInput(7)].iter()
-    9 => [FilteredShrinkInput(8)].iter()
-    _ => Iter::empty()
+    10 => [|FilteredShrinkInput(9), FilteredShrinkInput(7)|]
+    9 => [|FilteredShrinkInput(8)|]
+    _ => [||]
   }
 }
 
@@ -154,7 +154,7 @@ test "report returns a structured falsification" {
     report,
     content=(
       #|Falsified(
-      #|  counterexample=0,
+      #|  counterexample=-1028290551,
       #|  tests=1,
       #|  size=0,
       #|  shrinks=0,
@@ -177,7 +177,7 @@ test "report returns a structured raised error" {
     report,
     content=(
       #|Raised(
-      #|  counterexample=0,
+      #|  counterexample=-1028290551,
       #|  error=InitialRaised,
       #|  tests=1,
       #|  size=0,
@@ -294,7 +294,7 @@ test "report bounds a repeating shrink stream by attempts" {
     report,
     content=(
       #|Falsified(
-      #|  counterexample=RepeatingInput(0),
+      #|  counterexample=RepeatingInput(2),
       #|  tests=1,
       #|  size=0,
       #|  shrinks=10,
@@ -421,7 +421,7 @@ test "report stops at the first failing case" {
     result,
     content=(
       #|Falsified(
-      #|  counterexample=13,
+      #|  counterexample=0,
       #|  tests=4,
       #|  size=33,
       #|  shrinks=0,
@@ -501,10 +501,10 @@ test "context renders the shrunk counterexample" {
       #|Falsified(
       #|  counterexample=3,
       #|  context="rendered input <3>",
-      #|  tests=5,
-      #|  size=4,
-      #|  shrinks=0,
-      #|  shrink_attempts=2,
+      #|  tests=2,
+      #|  size=1,
+      #|  shrinks=30,
+      #|  shrink_attempts=33,
       #|)
     ),
   )
@@ -526,10 +526,10 @@ test "context is attached to raised failures too" {
       #|  counterexample=3,
       #|  context="rendered input <3>",
       #|  error=Failure("quickcheck/driver_test.mbt:516:30-516:42@moonbitlang/core FAILED: boom"),
-      #|  tests=5,
-      #|  size=4,
-      #|  shrinks=0,
-      #|  shrink_attempts=2,
+      #|  tests=2,
+      #|  size=1,
+      #|  shrinks=30,
+      #|  shrink_attempts=33,
       #|)
     ),
   )
diff --git a/quickcheck/facade.mbt b/quickcheck/facade.mbt
new file mode 100644
index 0000000000..9d317ad846
--- /dev/null
+++ b/quickcheck/facade.mbt
@@ -0,0 +1,20 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+/// The shrinking trait required by `check` and `report`.
+///
+/// Re-exported by the QuickCheck facade so custom input types can implement
+/// both `Arbitrary` and `Shrink` through a single package import.
+pub using @shrink {trait Shrink}
diff --git a/quickcheck/generator.mbt b/quickcheck/generator.mbt
index bd64dcc637..715b40c921 100644
--- a/quickcheck/generator.mbt
+++ b/quickcheck/generator.mbt
@@ -162,6 +162,9 @@ pub fn[T] Generator::resize(self : Generator[T], size : Int) -> Generator[T] {
 
 ///|
 /// Generates an integer in the half-open interval `[lower, upper)`.
+///
+/// The degenerate range where `lower == upper` always yields `lower`. Aborts
+/// if `lower` is greater than `upper`.
 pub fn int_range(lower : Int, upper : Int) -> Generator[Int] {
   guard lower < upper else {
     if lower == upper {
diff --git a/quickcheck/generator_test.mbt b/quickcheck/generator_test.mbt
index de3475fece..7437472867 100644
--- a/quickcheck/generator_test.mbt
+++ b/quickcheck/generator_test.mbt
@@ -182,7 +182,7 @@ test "spawn composes with generator combinators" {
   debug_inspect(
     entries,
     content=(
-      #|[("", 3), ("Z\b", 3), ("", 0)]
+      #|[("", 2), ("Z\b", 1000), ("", 2097152)]
     ),
   )
 }
diff --git a/quickcheck/moon.pkg b/quickcheck/moon.pkg
index 4ce442763e..a6bb92913d 100644
--- a/quickcheck/moon.pkg
+++ b/quickcheck/moon.pkg
@@ -1,6 +1,7 @@
 import {
   "moonbitlang/core/debug",
   "moonbitlang/core/int",
+  "moonbitlang/core/int16",
   "moonbitlang/core/quickcheck/shrink",
   "moonbitlang/core/quickcheck/splitmix",
   "moonbitlang/core/builtin",
@@ -10,6 +11,8 @@ import {
   "moonbitlang/core/ref",
   "moonbitlang/core/list",
   "moonbitlang/core/queue",
+  "moonbitlang/core/deque",
+  "moonbitlang/core/set",
   "moonbitlang/core/priority_queue",
   "moonbitlang/core/hashmap",
   "moonbitlang/core/hashset",
@@ -24,6 +27,7 @@ import {
 }
 
 import {
+  "moonbitlang/core/bench",
   "moonbitlang/core/float",
   "moonbitlang/core/double",
 } for "test"
diff --git a/quickcheck/observation.mbt b/quickcheck/observation.mbt
index 4504e1768f..e59fa0252f 100644
--- a/quickcheck/observation.mbt
+++ b/quickcheck/observation.mbt
@@ -87,7 +87,7 @@ pub fn[T : @debug.Debug] collect(value : T) -> Observation {
 
 ///|
 fn ObservationSummary::empty() -> ObservationSummary {
-  { labels: {}, classes: {} }
+  { labels: {}, classes: {}, }
 }
 
 ///|
@@ -172,10 +172,10 @@ fn ObservationSummary::report(
   guard !self.is_empty() else { return "" }
   let label_rows = [
     for labels, count in self.labels => {
-      { count, name: labels.iter().join(", ") }
+      { count, name: labels.iter().join(", "), }
     }
   ]
-  let class_rows = [ for name, count in self.classes => { count, name } ]
+  let class_rows = [ for name, count in self.classes => { count, name, } ]
   let builder = StringBuilder()
   builder <+ "\{prefix}observations:"
   let count_width = "\{total}".length()
diff --git a/quickcheck/pkg.generated.mbti b/quickcheck/pkg.generated.mbti
index dd074bebbe..0e315b79fb 100644
--- a/quickcheck/pkg.generated.mbti
+++ b/quickcheck/pkg.generated.mbti
@@ -4,12 +4,14 @@ package "moonbitlang/core/quickcheck"
 import {
   "moonbitlang/core/bigint",
   "moonbitlang/core/debug",
+  "moonbitlang/core/deque",
   "moonbitlang/core/immut/vector",
   "moonbitlang/core/list",
   "moonbitlang/core/queue",
   "moonbitlang/core/quickcheck/shrink",
   "moonbitlang/core/quickcheck/splitmix",
   "moonbitlang/core/ref",
+  "moonbitlang/core/set",
 }
 
 // Values
@@ -71,6 +73,8 @@ pub impl[A : @debug.Debug] @debug.Debug for QuickCheckReport[A]
 // Type aliases
 pub using @splitmix {type RandomState}
 
+pub using @shrink {trait Shrink}
+
 // Traits
 pub(open) trait Arbitrary {
   fn arbitrary(Int, @splitmix.RandomState) -> Self
@@ -80,8 +84,10 @@ pub impl Arbitrary for Bool
 pub impl Arbitrary for Byte
 pub impl Arbitrary for Char
 pub impl Arbitrary for Int
+pub impl Arbitrary for Int16
 pub impl Arbitrary for Int64
 pub impl Arbitrary for UInt
+pub impl Arbitrary for UInt16
 pub impl Arbitrary for UInt64
 pub impl Arbitrary for Float
 pub impl Arbitrary for Double
@@ -89,11 +95,14 @@ pub impl Arbitrary for String
 pub impl[X : Arbitrary] Arbitrary for X?
 pub impl[T : Arbitrary, E : Arbitrary] Arbitrary for Result[T, E]
 pub impl[X : Arbitrary] Arbitrary for FixedArray[X]
+pub impl[X : Arbitrary] Arbitrary for ReadOnlyArray[X]
 pub impl Arbitrary for Bytes
 pub impl Arbitrary for @bigint.BigInt
 pub impl[X : Arbitrary] Arbitrary for Array[X]
 pub impl[A : Arbitrary] Arbitrary for ArrayView[A]
 pub impl[X : Arbitrary] Arbitrary for Iter[X]
+pub impl[K : Arbitrary + Hash + Eq, V : Arbitrary] Arbitrary for Map[K, V]
+pub impl[X : Arbitrary] Arbitrary for @deque.Deque[X]
 pub impl[K : Arbitrary + Hash + Eq, V : Arbitrary] Arbitrary for @moonbitlang/core/hashmap.HashMap[K, V]
 pub impl[X : Arbitrary + Eq + Hash] Arbitrary for @moonbitlang/core/hashset.HashSet[X]
 pub impl[K : Eq + Hash + Arbitrary, V : Arbitrary] Arbitrary for @moonbitlang/core/immut/hashmap.HashMap[K, V]
@@ -106,6 +115,7 @@ pub impl[X : Arbitrary] Arbitrary for @list.List[X]
 pub impl[X : Arbitrary + Compare] Arbitrary for @moonbitlang/core/priority_queue.PriorityQueue[X]
 pub impl[X : Arbitrary] Arbitrary for @queue.Queue[X]
 pub impl[X : Arbitrary] Arbitrary for @ref.Ref[X]
+pub impl[X : Arbitrary + Eq + Hash] Arbitrary for @set.Set[X]
 pub impl[K : Arbitrary + Compare, V : Arbitrary] Arbitrary for @moonbitlang/core/sorted_map.SortedMap[K, V]
 pub impl[X : Arbitrary + Compare] Arbitrary for @moonbitlang/core/sorted_set.SortedSet[X]
 pub impl[A : Arbitrary, B : Arbitrary] Arbitrary for (A, B)
@@ -114,3 +124,5 @@ pub impl[A : Arbitrary, B : Arbitrary, C : Arbitrary, D : Arbitrary] Arbitrary f
 pub impl[A : Arbitrary, B : Arbitrary, C : Arbitrary, D : Arbitrary, E : Arbitrary] Arbitrary for (A, B, C, D, E)
 pub impl[A : Arbitrary, B : Arbitrary, C : Arbitrary, D : Arbitrary, E : Arbitrary, F : Arbitrary] Arbitrary for (A, B, C, D, E, F)
 pub impl[A : Arbitrary, B : Arbitrary, C : Arbitrary, D : Arbitrary, E : Arbitrary, F : Arbitrary, G : Arbitrary] Arbitrary for (A, B, C, D, E, F, G)
+pub impl[A : Arbitrary, B : Arbitrary, C : Arbitrary, D : Arbitrary, E : Arbitrary, F : Arbitrary, G : Arbitrary, H : Arbitrary] Arbitrary for (A, B, C, D, E, F, G, H)
+pub impl[A : Arbitrary, B : Arbitrary, C : Arbitrary, D : Arbitrary, E : Arbitrary, F : Arbitrary, G : Arbitrary, H : Arbitrary, I : Arbitrary] Arbitrary for (A, B, C, D, E, F, G, H, I)
diff --git a/quickcheck/shrink/collection.mbt b/quickcheck/shrink/collection.mbt
index 80d9a44f25..9196cc15cc 100644
--- a/quickcheck/shrink/collection.mbt
+++ b/quickcheck/shrink/collection.mbt
@@ -17,7 +17,7 @@ pub impl[T : Shrink] Shrink for @list.List[T] with fn shrink(xs) {
   let n = xs.length()
   fn shr_sub_terms(lst : @list.List[T]) {
     match lst {
-      Empty => Iter::empty()
+      Empty => [||]
       More(x, tail=xs) =>
         Shrink::shrink(x)
         .map(x_ => xs.add(x_))
@@ -38,6 +38,11 @@ pub impl[T : Shrink] Shrink for @queue.Queue[T] with fn shrink(xs) {
   Shrink::shrink(xs.iter().collect()).map(candidate => Queue(candidate))
 }
 
+///|
+pub impl[T : Shrink] Shrink for @deque.Deque[T] with fn shrink(xs) {
+  Shrink::shrink(xs.to_array()).map(candidate => Deque(candidate))
+}
+
 ///|
 pub impl[T : Shrink] Shrink for @immut_vector.Vector[T] with fn shrink(xs) {
   Shrink::shrink(xs.to_array()).map(candidate => Vector(candidate))
@@ -71,6 +76,33 @@ pub impl[T : Shrink + Compare] Shrink for @immut_sorted_set.SortedSet[T] with fn
   Shrink::shrink(xs.to_array()).map(candidate => SortedSet(candidate))
 }
 
+///|
+pub impl[T : Shrink + Eq + Hash] Shrink for @set.Set[T] with fn shrink(xs) {
+  Shrink::shrink(xs.to_array()).map(candidate => Set(candidate))
+}
+
+///|
+pub impl[K : Shrink + Hash + Eq, V : Shrink] Shrink for Map[K, V] with fn shrink(
+  xs,
+) {
+  let entries = xs.to_array()
+  Shrink::shrink(entries).map(candidate => Map(candidate))
+}
+
+///|
+pub impl[T : Shrink + Compare] Shrink for @priority_queue.PriorityQueue[T] with fn shrink(
+  xs,
+) {
+  Shrink::shrink(xs.to_array()).map(candidate => PriorityQueue(candidate))
+}
+
+///|
+pub impl[T : Shrink + Compare] Shrink for @immut_priority_queue.PriorityQueue[T] with fn shrink(
+  xs,
+) {
+  Shrink::shrink(xs.to_array()).map(candidate => PriorityQueue(candidate))
+}
+
 ///|
 pub impl[K : Shrink + Hash + Eq, V : Shrink] Shrink for @hashmap.HashMap[K, V] with fn shrink(
   xs,
diff --git a/quickcheck/shrink/collection_test.mbt b/quickcheck/shrink/collection_test.mbt
index 0ffcd70684..e610069c67 100644
--- a/quickcheck/shrink/collection_test.mbt
+++ b/quickcheck/shrink/collection_test.mbt
@@ -55,14 +55,14 @@ test "shrink int list" {
       #|  ,
       #|  ,
       #|  ,
-      #|  ,
       #|  ,
+      #|  ,
       #|  ,
-      #|  ,
       #|  ,
+      #|  ,
       #|  ,
-      #|  ,
       #|  ,
+      #|  ,
       #|  ,
       #|]
     ),
@@ -99,8 +99,8 @@ test "shrink immutable vector" {
       #|  ,
       #|  ,
       #|  ,
-      #|  ,
       #|  ,
+      #|  ,
       #|  ,
       #|]
     ),
@@ -118,12 +118,12 @@ test "shrink mutable hash set" {
       #|  ,
       #|  ,
       #|  ,
-      #|  ,
-      #|  ,
       #|  ,
+      #|  ,
+      #|  ,
       #|  ,
-      #|  ,
       #|  ,
+      #|  ,
       #|  ,
       #|]
     ),
@@ -142,8 +142,8 @@ test "shrink mutable sorted set" {
       #|  ,
       #|  ,
       #|  ,
-      #|  ,
       #|  ,
+      #|  ,
       #|  ,
       #|]
     ),
@@ -163,8 +163,8 @@ test "shrink immutable hash set" {
       #|  ,
       #|  ,
       #|  ,
-      #|  ,
       #|  ,
+      #|  ,
       #|  ,
       #|]
     ),
@@ -183,14 +183,93 @@ test "shrink immutable sorted set" {
       #|  ,
       #|  ,
       #|  ,
-      #|  ,
       #|  ,
+      #|  ,
       #|  ,
       #|]
     ),
   )
 }
 
+///|
+test "shrink builtin map" {
+  let input : Map[StableInt, Bool] = Map([
+    (StableInt(2), true),
+    (StableInt(1), false),
+  ])
+  let candidates = @shrink.Shrink::shrink(input).map(Map::to_array).collect()
+  debug_inspect(
+    candidates,
+    content=(
+      #|[
+      #|  [],
+      #|  [(1, false)],
+      #|  [(2, true)],
+      #|  [(1, false)],
+      #|  [(0, true), (1, false)],
+      #|  [(2, false), (1, false)],
+      #|  [(2, true), (0, false)],
+      #|]
+    ),
+  )
+}
+
+///|
+test "shrink linked set" {
+  let input = @set.Set([StableInt(2), StableInt(1)])
+  let candidates = @shrink.Shrink::shrink(input)
+    .map(@set.Set::to_array)
+    .collect()
+  debug_inspect(
+    candidates,
+    content=(
+      #|[[], [1], [2], [1], [0, 1], [2, 0]]
+    ),
+  )
+}
+
+///|
+test "shrink deque" {
+  let input = @deque.Deque([2, 1])
+  let candidates = @shrink.Shrink::shrink(input)
+    .map(@deque.Deque::to_array)
+    .collect()
+  debug_inspect(
+    candidates,
+    content=(
+      #|[[], [1], [2], [1, 1], [0, 1], [2, 0]]
+    ),
+  )
+}
+
+///|
+test "shrink mutable priority queue" {
+  let input = @priority_queue.PriorityQueue([2, 4])
+  let candidates = @shrink.Shrink::shrink(input)
+    .map(@priority_queue.PriorityQueue::to_array)
+    .collect()
+  debug_inspect(
+    candidates,
+    content=(
+      #|[[], [2], [4], [2, 2], [3, 2], [2, 0], [4, 1], [4, 0]]
+    ),
+  )
+}
+
+///|
+test "shrink immutable priority queue" {
+  let input = @immut_priority_queue.PriorityQueue([2, 4])
+  let candidates = @shrink.Shrink::shrink(input)
+    .map(@immut_priority_queue.PriorityQueue::to_array)
+    .collect()
+  debug_inspect(
+    candidates,
+    content=(
+      #|[[], [2], [4], [2, 2], [3, 2], [2, 0], [4, 1], [4, 0]]
+    ),
+  )
+}
+
 ///|
 test "shrink mutable hash map" {
   let input = @hashmap.HashMap([(StableInt(2), true), (StableInt(1), false)])
@@ -243,11 +322,11 @@ test "shrink immutable hash map" {
       #|  ,
       #|  ,
       #|  ,
-      #|  ,
       #|  ,
+      #|  ,
       #|  ,
-      #|  ,
       #|  ,
+      #|  ,
       #|  ,
       #|  ,
       #|  ,
diff --git a/quickcheck/shrink/composite.mbt b/quickcheck/shrink/composite.mbt
index 542e637670..7e0de29849 100644
--- a/quickcheck/shrink/composite.mbt
+++ b/quickcheck/shrink/composite.mbt
@@ -15,9 +15,8 @@
 ///|
 pub impl[T : Shrink] Shrink for T? with fn shrink(x) {
   match x {
-    None => Iter::empty()
-    Some(v) =>
-      Shrink::shrink(v).map(v1 => Some(v1)).concat(Iter::singleton(None))
+    None => [||]
+    Some(v) => Shrink::shrink(v).map(v1 => Some(v1)).concat([|None|])
   }
 }
 
@@ -35,7 +34,7 @@ pub impl[X : Shrink] Shrink for Array[X] with fn shrink(xs) {
   let n = view.length()
   fn shr_sub_terms(arr : ArrayView[X]) {
     match arr {
-      [] => Iter::empty()
+      [] => [||]
       [x, .. xs] =>
         X::shrink(x)
         .map(x_ => [x_, ..xs])
@@ -58,12 +57,24 @@ pub impl[X : Shrink] Shrink for FixedArray[X] with fn shrink(xs) {
   })
 }
 
+///|
+pub impl[X : Shrink] Shrink for ReadOnlyArray[X] with fn shrink(xs) {
+  Shrink::shrink(xs[:].to_owned()).map(candidate => {
+    ReadOnlyArray::from_array(candidate)
+  })
+}
+
 ///|
 pub impl[X : Shrink] Shrink for ArrayView[X] with fn shrink(xs) {
   let candidates : Iter[Array[X]] = Shrink::shrink(xs.to_owned())
   candidates.map(candidate => candidate)
 }
 
+///|
+pub impl[X : Shrink] Shrink for @ref.Ref[X] with fn shrink(x) {
+  Shrink::shrink(x.val).map(value => Ref(value))
+}
+
 ///|
 pub impl[A : Shrink, B : Shrink] Shrink for (A, B) with fn shrink(x) {
   let (a, b) = x
diff --git a/quickcheck/shrink/composite_test.mbt b/quickcheck/shrink/composite_test.mbt
index 188e17010f..0a97dd6421 100644
--- a/quickcheck/shrink/composite_test.mbt
+++ b/quickcheck/shrink/composite_test.mbt
@@ -24,15 +24,15 @@ test "shrink option" {
     @shrink.Shrink::shrink(Some(1000)).collect(),
     content=(
       #|[
-      #|  Some(999),
-      #|  Some(997),
-      #|  Some(993),
-      #|  Some(985),
-      #|  Some(969),
-      #|  Some(938),
-      #|  Some(875),
-      #|  Some(750),
       #|  Some(500),
+      #|  Some(750),
+      #|  Some(875),
+      #|  Some(938),
+      #|  Some(969),
+      #|  Some(985),
+      #|  Some(993),
+      #|  Some(997),
+      #|  Some(999),
       #|  Some(0),
       #|  None,
       #|]
@@ -47,7 +47,7 @@ test "shrink result" {
   debug_inspect(
     @shrink.Shrink::shrink(b).collect(),
     content=(
-      #|[Err(99), Err(97), Err(94), Err(88), Err(75), Err(50), Err(0)]
+      #|[Err(50), Err(75), Err(88), Err(94), Err(97), Err(99), Err(0)]
     ),
   )
   debug_inspect(
@@ -79,14 +79,14 @@ test "shrink array" {
       #|  [1, 0, 3, 4, 5, 6],
       #|  [1, 2, 2, 4, 5, 6],
       #|  [1, 2, 0, 4, 5, 6],
-      #|  [1, 2, 3, 3, 5, 6],
       #|  [1, 2, 3, 2, 5, 6],
+      #|  [1, 2, 3, 3, 5, 6],
       #|  [1, 2, 3, 0, 5, 6],
-      #|  [1, 2, 3, 4, 4, 6],
       #|  [1, 2, 3, 4, 3, 6],
+      #|  [1, 2, 3, 4, 4, 6],
       #|  [1, 2, 3, 4, 0, 6],
-      #|  [1, 2, 3, 4, 5, 5],
       #|  [1, 2, 3, 4, 5, 3],
+      #|  [1, 2, 3, 4, 5, 5],
       #|  [1, 2, 3, 4, 5, 0],
       #|]
     ),
@@ -130,6 +130,39 @@ test "shrink array view" {
   )
 }
 
+///|
+test "shrink readonly array" {
+  let input : ReadOnlyArray[Int] = ReadOnlyArray::from_array([2, 1])
+  let candidates = @shrink.Shrink::shrink(input)
+    .map(candidate => candidate[:].to_owned())
+    .collect()
+  debug_inspect(
+    candidates,
+    content=(
+      #|[[], [1], [2], [1, 1], [0, 1], [2, 0]]
+    ),
+  )
+}
+
+///|
+test "shrink ref into fresh refs" {
+  let input = @ref.Ref(4)
+  let candidates = @shrink.Shrink::shrink(input).collect()
+  debug_inspect(
+    candidates.map(candidate => candidate.val),
+    content=(
+      #|[2, 3, 0]
+    ),
+  )
+  candidates[0].val = 99
+  debug_inspect(
+    (input.val, candidates.map(candidate => candidate.val)),
+    content=(
+      #|(4, [99, 3, 0])
+    ),
+  )
+}
+
 ///|
 test "shrink tuple" {
   let x = (120, true)
@@ -137,12 +170,12 @@ test "shrink tuple" {
     @shrink.Shrink::shrink(x).collect(),
     content=(
       #|[
-      #|  (119, true),
-      #|  (117, true),
-      #|  (113, true),
-      #|  (105, true),
-      #|  (90, true),
       #|  (60, true),
+      #|  (90, true),
+      #|  (105, true),
+      #|  (113, true),
+      #|  (117, true),
+      #|  (119, true),
       #|  (0, true),
       #|  (120, false),
       #|]
@@ -157,18 +190,18 @@ test "shrink 6-tuple" {
     @shrink.Shrink::shrink(x).collect(),
     content=(
       #|[
-      #|  (19, 'A', 30, true, true, true),
-      #|  (18, 'A', 30, true, true, true),
-      #|  (15, 'A', 30, true, true, true),
       #|  (10, 'A', 30, true, true, true),
+      #|  (15, 'A', 30, true, true, true),
+      #|  (18, 'A', 30, true, true, true),
+      #|  (19, 'A', 30, true, true, true),
       #|  (0, 'A', 30, true, true, true),
       #|  (20, 'a', 30, true, true, true),
       #|  (20, 'b', 30, true, true, true),
       #|  (20, 'c', 30, true, true, true),
-      #|  (20, 'A', 29, true, true, true),
-      #|  (20, 'A', 27, true, true, true),
-      #|  (20, 'A', 23, true, true, true),
       #|  (20, 'A', 15, true, true, true),
+      #|  (20, 'A', 23, true, true, true),
+      #|  (20, 'A', 27, true, true, true),
+      #|  (20, 'A', 29, true, true, true),
       #|  (20, 'A', 0, true, true, true),
       #|  (20, 'A', 30, false, true, true),
       #|  (20, 'A', 30, true, false, true),
diff --git a/quickcheck/shrink/moon.pkg b/quickcheck/shrink/moon.pkg
index 858dd86403..af4ca379b7 100644
--- a/quickcheck/shrink/moon.pkg
+++ b/quickcheck/shrink/moon.pkg
@@ -1,14 +1,22 @@
 import {
   "moonbitlang/core/builtin",
   "moonbitlang/core/array",
+  "moonbitlang/core/bigint",
   "moonbitlang/core/float",
+  "moonbitlang/core/int16",
+  "moonbitlang/core/uint16",
+  "moonbitlang/core/ref",
   "moonbitlang/core/list",
   "moonbitlang/core/queue",
+  "moonbitlang/core/deque",
+  "moonbitlang/core/set",
+  "moonbitlang/core/priority_queue",
   "moonbitlang/core/hashmap",
   "moonbitlang/core/hashset",
   "moonbitlang/core/sorted_map",
   "moonbitlang/core/sorted_set",
   "moonbitlang/core/immut/vector" @immut_vector,
+  "moonbitlang/core/immut/priority_queue" @immut_priority_queue,
   "moonbitlang/core/immut/hashmap" @immut_hashmap,
   "moonbitlang/core/immut/hashset" @immut_hashset,
   "moonbitlang/core/immut/sorted_map" @immut_sorted_map,
diff --git a/quickcheck/shrink/pkg.generated.mbti b/quickcheck/shrink/pkg.generated.mbti
index 16f190b4dc..5eedf62d6b 100644
--- a/quickcheck/shrink/pkg.generated.mbti
+++ b/quickcheck/shrink/pkg.generated.mbti
@@ -2,9 +2,13 @@
 package "moonbitlang/core/quickcheck/shrink"
 
 import {
+  "moonbitlang/core/bigint",
+  "moonbitlang/core/deque",
   "moonbitlang/core/immut/vector",
   "moonbitlang/core/list",
   "moonbitlang/core/queue",
+  "moonbitlang/core/ref",
+  "moonbitlang/core/set",
 }
 
 // Values
@@ -24,8 +28,10 @@ pub impl Shrink for Bool
 pub impl Shrink for Byte
 pub impl Shrink for Char
 pub impl Shrink for Int
+pub impl Shrink for Int16
 pub impl Shrink for Int64
 pub impl Shrink for UInt
+pub impl Shrink for UInt16
 pub impl Shrink for UInt64
 pub impl Shrink for Float
 pub impl Shrink for Double
@@ -33,18 +39,26 @@ pub impl Shrink for String
 pub impl[T : Shrink] Shrink for T?
 pub impl[T : Shrink, E : Shrink] Shrink for Result[T, E]
 pub impl[X : Shrink] Shrink for FixedArray[X]
+pub impl[X : Shrink] Shrink for ReadOnlyArray[X]
 pub impl Shrink for Bytes
+pub impl Shrink for @bigint.BigInt
 pub impl[X : Shrink] Shrink for Array[X]
 pub impl[X : Shrink] Shrink for ArrayView[X]
+pub impl[K : Shrink + Hash + Eq, V : Shrink] Shrink for Map[K, V]
+pub impl[T : Shrink] Shrink for @deque.Deque[T]
 pub impl[K : Shrink + Hash + Eq, V : Shrink] Shrink for @moonbitlang/core/hashmap.HashMap[K, V]
 pub impl[T : Shrink + Eq + Hash] Shrink for @moonbitlang/core/hashset.HashSet[T]
 pub impl[K : Shrink + Eq + Hash, V : Shrink] Shrink for @moonbitlang/core/immut/hashmap.HashMap[K, V]
 pub impl[T : Shrink + Eq + Hash] Shrink for @moonbitlang/core/immut/hashset.HashSet[T]
+pub impl[T : Shrink + Compare] Shrink for @moonbitlang/core/immut/priority_queue.PriorityQueue[T]
 pub impl[K : Shrink + Compare, V : Shrink] Shrink for @moonbitlang/core/immut/sorted_map.SortedMap[K, V]
 pub impl[T : Shrink + Compare] Shrink for @moonbitlang/core/immut/sorted_set.SortedSet[T]
 pub impl[T : Shrink] Shrink for @vector.Vector[T]
 pub impl[T : Shrink] Shrink for @list.List[T]
+pub impl[T : Shrink + Compare] Shrink for @moonbitlang/core/priority_queue.PriorityQueue[T]
 pub impl[T : Shrink] Shrink for @queue.Queue[T]
+pub impl[X : Shrink] Shrink for @ref.Ref[X]
+pub impl[T : Shrink + Eq + Hash] Shrink for @set.Set[T]
 pub impl[K : Shrink + Compare, V : Shrink] Shrink for @moonbitlang/core/sorted_map.SortedMap[K, V]
 pub impl[T : Shrink + Compare] Shrink for @moonbitlang/core/sorted_set.SortedSet[T]
 pub impl[A : Shrink, B : Shrink] Shrink for (A, B)
diff --git a/quickcheck/shrink/shrink.mbt b/quickcheck/shrink/shrink.mbt
index 73aa87acee..1e4cc527a8 100644
--- a/quickcheck/shrink/shrink.mbt
+++ b/quickcheck/shrink/shrink.mbt
@@ -41,55 +41,71 @@ pub(open) trait Shrink {
 
 ///|
 impl Shrink with fn shrink(_a) {
-  Iter::empty()
+  [||]
 }
 
 ///|
 pub impl Shrink for Int with fn shrink(x) {
-  [|
-    ..[ for z = x / 2; (x - z).abs() < x.abs(); z = z / 2 => x - z ].rev_iter(),
-    ..if x != 0 {
-      [0]
-    },
-  |]
+  [|..[ for z = x / 2; z != 0; z = z / 2 => x - z ], ..if x != 0 { [0] }|]
 }
 
 ///|
 pub impl Shrink for Int64 with fn shrink(x) {
-  [|
-    ..[ for z = x / 2; (x - z).abs() < x.abs(); z = z / 2 => x - z ].rev_iter(),
-    ..if x != 0 {
-      [(0 : Int64)]
+  [|..[ for z = x / 2; z != 0; z = z / 2 => x - z ], ..if x != 0 { [0L] }|]
+}
+
+///|
+pub impl Shrink for Int16 with fn shrink(x) {
+  Shrink::shrink(x.to_int()).map(Int16::from_int)
+}
+
+///|
+pub impl Shrink for UInt16 with fn shrink(x) {
+  Shrink::shrink(x.to_uint()).map(UInt::to_uint16)
+}
+
+///|
+pub impl Shrink for @bigint.BigInt with fn shrink(x) {
+  let zero = @bigint.BigInt::from_int(0)
+  guard x != zero else { return Iter::empty() }
+  let magnitude = if x < zero { -x } else { x }
+  let bit_length = magnitude.bit_length()
+  let negative = x < zero
+  let mut shift = bit_length - 1
+  let mut emit_zero = true
+  Iter::new(
+    () => {
+      if shift > 0 {
+        let delta = magnitude >> shift
+        shift -= 1
+        Some(if negative { x + delta } else { x - delta })
+      } else if emit_zero {
+        emit_zero = false
+        Some(zero)
+      } else {
+        None
+      }
     },
-  |]
+    size_hint=bit_length,
+  )
 }
 
 ///|
 pub impl Shrink for UInt with fn shrink(x) {
-  [|
-    ..[ for z = x / 2; z > 0; z = z / 2 => x - z ].rev_iter(),
-    ..if x != 0 {
-      [(0 : UInt)]
-    },
-  |]
+  [|..[ for z = x / 2; z > 0; z = z / 2 => x - z ], ..if x != 0 { [0U] }|]
 }
 
 ///|
 pub impl Shrink for UInt64 with fn shrink(x) {
-  [|
-    ..[ for z = x / 2; z > 0; z = z / 2 => x - z ].rev_iter(),
-    ..if x != 0 {
-      [(0 : UInt64)]
-    },
-  |]
+  [|..[ for z = x / 2; z > 0; z = z / 2 => x - z ], ..if x != 0 { [0UL] }|]
 }
 
 ///|
 pub impl Shrink for Bool with fn shrink(b) {
   if !b {
-    Iter::empty()
+    [||]
   } else {
-    Iter::singleton(false)
+    [|false|]
   }
 }
 
@@ -97,9 +113,11 @@ pub impl Shrink for Bool with fn shrink(b) {
 pub impl Shrink for Byte with fn shrink(x) {
   let xi = x.to_int()
   [|
-    ..[ for z = xi / 2; z > 0; z = z / 2 => (xi - z).to_byte() ].rev_iter(),
+    ..[
+      for z = xi / 2; z > 0; z = z / 2 => (xi - z).to_byte()
+    ],
     ..if xi != 0 {
-      [Int::to_byte(0)]
+      [Byte(0)]
     },
   |]
 }
diff --git a/quickcheck/shrink/shrink_test.mbt b/quickcheck/shrink/shrink_test.mbt
index 44e516cd7a..12f030bd44 100644
--- a/quickcheck/shrink/shrink_test.mbt
+++ b/quickcheck/shrink/shrink_test.mbt
@@ -17,7 +17,7 @@ test "shrink int" {
   debug_inspect(
     @shrink.Shrink::shrink(100).collect(),
     content=(
-      #|[99, 97, 94, 88, 75, 50, 0]
+      #|[50, 75, 88, 94, 97, 99, 0]
     ),
   )
   debug_inspect(
@@ -28,52 +28,178 @@ test "shrink int" {
   )
 }
 
+///|
+test "shrink minimum int without overflowing" {
+  let minimum = -2147483647 - 1
+  let candidates = @shrink.Shrink::shrink(minimum).collect()
+  let tail = candidates.length() - 3
+  debug_inspect(
+    (candidates.length(), candidates[:3], candidates[tail:]),
+    content=(
+      #|(
+      #|  32,
+      #|  ,
+      #|  ,
+      #|)
+    ),
+  )
+}
+
 ///|
 test "shrink int64" {
   debug_inspect(
     @shrink.Shrink::shrink(10000L).collect(),
     content=(
       #|[
-      #|  9999,
-      #|  9998,
-      #|  9996,
-      #|  9991,
-      #|  9981,
-      #|  9961,
-      #|  9922,
-      #|  9844,
-      #|  9688,
-      #|  9375,
-      #|  8750,
-      #|  7500,
       #|  5000,
+      #|  7500,
+      #|  8750,
+      #|  9375,
+      #|  9688,
+      #|  9844,
+      #|  9922,
+      #|  9961,
+      #|  9981,
+      #|  9991,
+      #|  9996,
+      #|  9998,
+      #|  9999,
       #|  0,
       #|]
     ),
   )
 }
 
+///|
+test "shrink minimum int64 without overflowing" {
+  let minimum = -9223372036854775807L - 1L
+  let candidates = @shrink.Shrink::shrink(minimum).collect()
+  let tail = candidates.length() - 3
+  debug_inspect(
+    (candidates.length(), candidates[:3], candidates[tail:]),
+    content=(
+      #|(
+      #|  64,
+      #|  ,
+      #|  ,
+      #|)
+    ),
+  )
+}
+
+///|
+test "shrink int16" {
+  debug_inspect(
+    @shrink.Shrink::shrink((100 : Int16)).collect(),
+    content=(
+      #|[50, 75, 88, 94, 97, 99, 0]
+    ),
+  )
+}
+
+///|
+test "shrink uint16" {
+  debug_inspect(
+    @shrink.Shrink::shrink((100 : UInt16)).collect(),
+    content=(
+      #|[50, 75, 88, 94, 97, 99, 0]
+    ),
+  )
+}
+
+///|
+test "shrink arbitrary-precision bigint" {
+  let input = @bigint.BigInt::from_string("100000000000000000000")
+  let zero = @bigint.BigInt::from_int(0)
+  let candidates = @shrink.Shrink::shrink(input).collect()
+  let tail = candidates.length() - 3
+  debug_inspect(
+    (candidates.length(), candidates[:4], candidates[tail:]),
+    content=(
+      #|(
+      #|  67,
+      #|  ,
+      #|  ,
+      #|)
+    ),
+  )
+  assert_true(
+    candidates.all(candidate => candidate >= zero && candidate < input),
+  )
+}
+
+///|
+test "shrink negative arbitrary-precision bigint" {
+  let input = @bigint.BigInt::from_string("-100000000000000000000")
+  let zero = @bigint.BigInt::from_int(0)
+  let candidates = @shrink.Shrink::shrink(input).collect()
+  let tail = candidates.length() - 3
+  debug_inspect(
+    (candidates.length(), candidates[:4], candidates[tail:]),
+    content=(
+      #|(
+      #|  67,
+      #|  ,
+      #|  ,
+      #|)
+    ),
+  )
+  assert_true(
+    candidates.all(candidate => candidate > input && candidate <= zero),
+  )
+}
+
+///|
+test "shrink bigint boundary values" {
+  let negative_one = @bigint.BigInt::from_int(-1)
+  let zero = @bigint.BigInt::from_int(0)
+  let one = @bigint.BigInt::from_int(1)
+  debug_inspect(
+    (
+      @shrink.Shrink::shrink(negative_one).collect(),
+      @shrink.Shrink::shrink(zero).collect(),
+      @shrink.Shrink::shrink(one).collect(),
+    ),
+    content=(
+      #|([0], [], [0])
+    ),
+  )
+}
+
 ///|
 test "shrink uint" {
   debug_inspect(
     @shrink.Shrink::shrink(37000U).collect(),
     content=(
       #|[
-      #|  36999,
-      #|  36998,
-      #|  36996,
-      #|  36991,
-      #|  36982,
-      #|  36964,
-      #|  36928,
-      #|  36856,
-      #|  36711,
-      #|  36422,
-      #|  35844,
-      #|  34688,
-      #|  32375,
-      #|  27750,
       #|  18500,
+      #|  27750,
+      #|  32375,
+      #|  34688,
+      #|  35844,
+      #|  36422,
+      #|  36711,
+      #|  36856,
+      #|  36928,
+      #|  36964,
+      #|  36982,
+      #|  36991,
+      #|  36996,
+      #|  36998,
+      #|  36999,
       #|  0,
       #|]
     ),
@@ -86,21 +212,21 @@ test "shrink uint64" {
     @shrink.Shrink::shrink((42000 : UInt64)).collect(),
     content=(
       #|[
-      #|  41999,
-      #|  41998,
-      #|  41995,
-      #|  41990,
-      #|  41980,
-      #|  41959,
-      #|  41918,
-      #|  41836,
-      #|  41672,
-      #|  41344,
-      #|  40688,
-      #|  39375,
-      #|  36750,
-      #|  31500,
       #|  21000,
+      #|  31500,
+      #|  36750,
+      #|  39375,
+      #|  40688,
+      #|  41344,
+      #|  41672,
+      #|  41836,
+      #|  41918,
+      #|  41959,
+      #|  41980,
+      #|  41990,
+      #|  41995,
+      #|  41998,
+      #|  41999,
       #|  0,
       #|]
     ),
@@ -113,7 +239,7 @@ test "shrink byte" {
   debug_inspect(
     s,
     content=(
-      #|[0xc7, 0xc5, 0xc2, 0xbc, 0xaf, 0x96, 0x64, 0x00]
+      #|[0x64, 0x96, 0xaf, 0xbc, 0xc2, 0xc5, 0xc7, 0x00]
     ),
   )
   debug_inspect(
@@ -195,18 +321,18 @@ test "shrink bytes through byte array" {
       #|  ,
       #|  ,
       #|  ,
-      #|  ,
-      #|  ,
-      #|  ,
-      #|  ,
-      #|  ,
       #|  ,
+      #|  ,
+      #|  ,
+      #|  ,
+      #|  ,
+      #|  ,
       #|  ,
-      #|  ,
-      #|  ,
-      #|  ,
-      #|  ,
       #|  ,
+      #|  ,
+      #|  ,
+      #|  ,
+      #|  ,
       #|  ,
       #|]
     ),
@@ -236,7 +362,7 @@ test "shrink double: exact output for 3.5" {
   debug_inspect(
     @shrink.Shrink::shrink(3.5).collect(),
     content=(
-      #|[3, 2, 0, 3.4, 3.3, 3.1, 2.7, 1.8, 0]
+      #|[2, 3, 0, 1.8, 2.7, 3.1, 3.3, 3.4, 0]
     ),
   )
 }
@@ -256,7 +382,7 @@ test "shrink double: negative" {
   debug_inspect(
     @shrink.Shrink::shrink(-5.5).collect(),
     content=(
-      #|[5.5, -5, -3, 0, -5.4, -5.2, -4.9, -4.2, -2.8, 0]
+      #|[5.5, -3, -5, 0, -2.8, -4.2, -4.9, -5.2, -5.4, 0]
     ),
   )
 }
diff --git a/quickcheck/shrink/utils.mbt b/quickcheck/shrink/utils.mbt
index e0b61c773b..f9082c9f5d 100644
--- a/quickcheck/shrink/utils.mbt
+++ b/quickcheck/shrink/utils.mbt
@@ -20,7 +20,7 @@ fn[T] removes_array(k : Int, n : Int, xs : Array[T]) -> Iter[Array[T]] {
   if xs1.is_empty() {
     [|[]|]
   } else {
-    [xs1].iter().add(removes_array(k, n - k, xs1).map(x => xs2 + x))
+    [|xs1|].add(removes_array(k, n - k, xs1).map(x => xs2 + x))
   }
 }
 
@@ -40,16 +40,14 @@ fn[T] removes_list(k : Int, n : Int, xs : @list.List[T]) -> Iter[@list.List[T]]
 fn shrink_decimal(x : Double) -> Iter[Double] {
   guard !x.is_nan() else { [|0.0, 1.0, -1.0, 2.0|] }
   guard !x.is_inf() else { [|0.0, 1.0, -1.0, 1000.0, -1000.0|] }
-  guard x >= 0.0 else {
-    Iter::singleton(-x).concat(shrink_decimal(-x).map(Double::neg))
-  }
-  guard x != 0.0 else { Iter::empty() }
+  guard x >= 0.0 else { [|-x|].concat(shrink_decimal(-x).map(Double::neg)) }
+  guard x != 0.0 else { [||] }
   [|1.0, 10.0, 100.0, 1000.0, 10000.0, 100000.0|].flat_map(p => {
     let m = (x * p + 0.5).floor().to_int64()
     if p != 1.0 && m % 10L == 0L {
-      return Iter::empty()
+      return [||]
     }
-    Iter::singleton(m)
+    [|m|]
     .concat(Shrink::shrink(m))
     .map(n => n.to_double() / p)
     .filter(y => y >= 0.0 && y < x)
diff --git a/quickcheck/splitmix/README.mbt.md b/quickcheck/splitmix/README.mbt.md
index cc51f8bb48..f6e87335b1 100644
--- a/quickcheck/splitmix/README.mbt.md
+++ b/quickcheck/splitmix/README.mbt.md
@@ -11,15 +11,30 @@ Create and initialize random number generators:
 test "random state creation" {
   // Create with default seed
   let rng1 = @splitmix.new()
-  inspect(rng1.to_string().length() > 0, content="true")
+  debug_inspect(
+    rng1,
+    content=(
+      #|{ seed: 6185074585042305769, gamma: 16934044424796929712 }
+    ),
+  )
 
   // Create with specific seed
   let rng2 = @splitmix.new(seed=12345UL)
-  inspect(rng2.to_string().length() > 0, content="true")
+  debug_inspect(
+    rng2,
+    content=(
+      #|{ seed: 1716623506685013753, gamma: 14663218685290508263 }
+    ),
+  )
 
   // Clone existing state
   let rng3 = rng2.clone()
-  inspect(rng3.to_string().length() > 0, content="true")
+  debug_inspect(
+    rng3,
+    content=(
+      #|{ seed: 1716623506685013753, gamma: 14663218685290508263 }
+    ),
+  )
 }
 ```
 
@@ -34,7 +49,12 @@ test "random number generation" {
 
   // Generate random integers
   let int_val = rng.next_int()
-  inspect(int_val.to_string().length() > 0, content="true")
+  debug_inspect(
+    int_val,
+    content=(
+      #|-1716621765
+    ),
+  )
 
   // Generate positive integers only
   let pos_int = rng.next_positive_int()
@@ -42,15 +62,54 @@ test "random number generation" {
 
   // Generate UInt values
   let uint_val = rng.next_uint()
-  inspect(uint_val.to_string().length() > 0, content="true")
+  debug_inspect(
+    uint_val,
+    content=(
+      #|40636561
+    ),
+  )
 
   // Generate Int64 values
   let int64_val = rng.next_int64()
-  inspect(int64_val.to_string().length() > 0, content="true")
+  debug_inspect(
+    int64_val,
+    content=(
+      #|640680877524568329
+    ),
+  )
 
   // Generate UInt64 values
   let uint64_val = rng.next_uint64()
-  inspect(uint64_val.to_string().length() > 0, content="true")
+  debug_inspect(
+    uint64_val,
+    content=(
+      #|11629490981681548516
+    ),
+  )
+}
+```
+
+## Bounded Unsigned Integers
+
+Pass `limit` to `next_uint` or `next_uint64` for unbiased values below an
+exclusive upper bound. Unlike `% limit`, these methods reject excess source
+words so every result in the requested interval has the same probability.
+Omitting `limit` generates across the complete unsigned range.
+
+```mbt check
+///|
+test "bounded unsigned generation" {
+  let rng = @splitmix.new(seed=42UL)
+  debug_inspect(
+    (
+      rng.next_uint(limit=10U),
+      rng.next_uint64(limit=3UL),
+      rng.next_uint(limit=16U),
+    ),
+    content=(
+      #|(0, 1, 9)
+    ),
+  )
 }
 ```
 
@@ -93,8 +152,18 @@ test "advanced operations" {
 
   // Generate two UInt values at once
   let (uint1, uint2) = rng.next_two_uint()
-  inspect(uint1.to_string().length() > 0, content="true")
-  inspect(uint2.to_string().length() > 0, content="true")
+  debug_inspect(
+    uint1,
+    content=(
+      #|3306273023
+    ),
+  )
+  debug_inspect(
+    uint2,
+    content=(
+      #|472035372
+    ),
+  )
 
   // Split the generator (for parallel use)
   let split_rng = rng.split()
@@ -102,8 +171,18 @@ test "advanced operations" {
   // Both generators should work independently
   let original_val = rng.next_int()
   let split_val = split_rng.next_int()
-  inspect(original_val.to_string().length() > 0, content="true")
-  inspect(split_val.to_string().length() > 0, content="true")
+  debug_inspect(
+    original_val,
+    content=(
+      #|2115132817
+    ),
+  )
+  debug_inspect(
+    split_val,
+    content=(
+      #|400628363
+    ),
+  )
 }
 ```
 
@@ -121,7 +200,12 @@ test "state management" {
 
   // Generate value after advancing
   let after_advance = rng.next_int()
-  inspect(after_advance.to_string().length() > 0, content="true")
+  debug_inspect(
+    after_advance,
+    content=(
+      #|817660368
+    ),
+  )
 
   // Create independent copy
   let independent = rng.clone()
@@ -198,7 +282,7 @@ SplitMix provides:
 ## Performance Characteristics
 
 - **Generation speed**: Very fast (few CPU cycles per number)
-- **Memory usage**: Minimal state (single 64-bit value)
+- **Memory usage**: Minimal state (two 64-bit values: `seed` and `gamma`)
 - **Quality**: Good statistical properties for testing
 - **Splitting**: O(1) to create independent generators
 
diff --git a/quickcheck/splitmix/extends.mbt b/quickcheck/splitmix/extends.mbt
index 379a6f3805..c634de8f3b 100644
--- a/quickcheck/splitmix/extends.mbt
+++ b/quickcheck/splitmix/extends.mbt
@@ -12,11 +12,6 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-// --- promoted: kept as regular methods ---
-
-///|
-pub extend RandomState with Show::{to_string}
-
 // --- deprecated: hidden from the generated interface ---
 
 ///|
@@ -28,8 +23,3 @@ pub extend RandomState with @debug.Debug::{to_repr}
 #deprecated("Use `Default::default` instead", skip_current_package=true)
 #doc(hidden)
 pub extend RandomState with Default::{default}
-
-///|
-#deprecated("Use `Show::output` via the trait or `to_string` instead", skip_current_package=true)
-#doc(hidden)
-pub extend RandomState with Show::{output}
diff --git a/quickcheck/splitmix/pkg.generated.mbti b/quickcheck/splitmix/pkg.generated.mbti
index 9028037ea5..f1ed2671d2 100644
--- a/quickcheck/splitmix/pkg.generated.mbti
+++ b/quickcheck/splitmix/pkg.generated.mbti
@@ -11,7 +11,7 @@ pub fn new(seed? : UInt64) -> RandomState
 // Errors
 
 // Types and methods
-type RandomState derive(Show, @debug.Debug)
+type RandomState derive(@debug.Debug)
 pub fn RandomState::clone(Self) -> Self
 #deprecated
 pub fn RandomState::new(seed? : UInt64) -> Self
@@ -22,10 +22,9 @@ pub fn RandomState::next_int(Self) -> Int
 pub fn RandomState::next_int64(Self) -> Int64
 pub fn RandomState::next_positive_int(Self) -> Int
 pub fn RandomState::next_two_uint(Self) -> (UInt, UInt)
-pub fn RandomState::next_uint(Self) -> UInt
-pub fn RandomState::next_uint64(Self) -> UInt64
+pub fn RandomState::next_uint(Self, limit? : UInt) -> UInt
+pub fn RandomState::next_uint64(Self, limit? : UInt64) -> UInt64
 pub fn RandomState::split(Self) -> Self
-pub fn RandomState::to_string(Self) -> String
 pub impl Default for RandomState
 
 // Type aliases
diff --git a/quickcheck/splitmix/random.mbt b/quickcheck/splitmix/random.mbt
index 1c629c290a..a760a1588e 100644
--- a/quickcheck/splitmix/random.mbt
+++ b/quickcheck/splitmix/random.mbt
@@ -13,11 +13,10 @@
 // limitations under the License.
 
 ///|
-#warnings("-deprecated_syntax")
 struct RandomState {
   mut seed : UInt64
   gamma : UInt64
-} derive(Show, @debug.Debug)
+} derive(@debug.Debug)
 
 ///|
 let golden_gamma : UInt64 = 0x9e3779b97f4a7c15
@@ -31,13 +30,13 @@ let float_ulp : Float = 1.0F / Float::from_int64(1L << 24)
 ///|
 /// Create a new RandomState from an optional seed.
 pub fn new(seed? : UInt64 = 37) -> RandomState {
-  { seed: mix64(seed), gamma: mix_gamma(seed + golden_gamma) }
+  { seed: mix64(seed), gamma: mix_gamma(seed + golden_gamma), }
 }
 
 ///|
 /// Clone a RandomState.
 pub fn RandomState::clone(self : RandomState) -> RandomState {
-  { ..self }
+  { ..self, }
 }
 
 ///|
@@ -47,17 +46,57 @@ pub fn RandomState::next(self : RandomState) -> Unit {
 }
 
 ///|
-/// Get the next random number as a 64-bit unsigned integer.
-pub fn RandomState::next_uint64(self : RandomState) -> UInt64 {
-  let { seed, gamma } = self
+fn RandomState::next_uint64_raw(self : RandomState) -> UInt64 {
+  let { seed, gamma, } = self
   self.seed = seed + gamma
   mix64(self.seed)
 }
 
 ///|
-/// Get the next random number as a 32-bit unsigned integer.
-pub fn RandomState::next_uint(self : RandomState) -> UInt {
-  self.next_uint64().to_uint()
+/// Returns a random `UInt64`.
+///
+/// With no `limit`, values span the complete `UInt64` range. With `limit`,
+/// this method uses bitmask rejection to return an unbiased value in the
+/// half-open interval `[0, limit)`. Rejected words consume additional values
+/// from this random state.
+///
+/// # Panics
+///
+/// Panics if `limit` is explicitly set to zero.
+pub fn RandomState::next_uint64(self : RandomState, limit? : UInt64) -> UInt64 {
+  match limit {
+    None => self.next_uint64_raw()
+    Some(0UL) => abort("RandomState::next_uint64: limit must be positive")
+    Some(limit) => {
+      let mask = if limit == 1UL {
+        0UL
+      } else {
+        0xffff_ffff_ffff_ffffUL >> (limit - 1UL).clz()
+      }
+      for candidate = self.next_uint64_raw() & mask; candidate >= limit; {
+        continue self.next_uint64_raw() & mask
+      } nobreak {
+        candidate
+      }
+    }
+  }
+}
+
+///|
+/// Returns a random `UInt`.
+///
+/// With no `limit`, values span the complete `UInt` range. With `limit`, this
+/// method returns an unbiased value in the half-open interval `[0, limit)`.
+///
+/// # Panics
+///
+/// Panics if `limit` is explicitly set to zero.
+pub fn RandomState::next_uint(self : RandomState, limit? : UInt) -> UInt {
+  match limit {
+    None => self.next_uint64_raw().to_uint()
+    Some(0U) => abort("RandomState::next_uint: limit must be positive")
+    Some(limit) => self.next_uint64(limit=limit.to_uint64()).to_uint()
+  }
 }
 
 ///|
@@ -67,7 +106,7 @@ pub fn RandomState::next_int64(self : RandomState) -> Int64 {
 }
 
 ///|
-/// Get the next two random number as 32-bit signed integers.
+/// Get the next two random numbers as 32-bit unsigned integers.
 pub fn RandomState::next_two_uint(self : RandomState) -> (UInt, UInt) {
   let g = self.next_uint64()
   ((g >> 32).to_uint(), g.to_uint())
@@ -92,14 +131,14 @@ pub fn RandomState::next_positive_int(self : RandomState) -> Int {
 }
 
 ///|
-/// Get the next random number as a float in [0, 1]
+/// Get the next random number as a float in [0, 1)
 pub fn RandomState::next_float(self : RandomState) -> Float {
   let u = self.next_uint64()
   Float::from_uint64(u >> 40) * float_ulp
 }
 
 ///|
-/// Get the next random number as a double in [0, 1]
+/// Get the next random number as a double in [0, 1)
 pub fn RandomState::next_double(self : RandomState) -> Double {
   let u = self.next_uint64()
   (u >> 11).to_double() * double_ulp
@@ -110,7 +149,7 @@ pub fn RandomState::next_double(self : RandomState) -> Double {
 pub fn RandomState::split(self : RandomState) -> RandomState {
   let seed1 = self.seed + self.gamma
   self.seed = seed1 + self.gamma
-  { seed: mix64(seed1), gamma: mix_gamma(self.seed) }
+  { seed: mix64(seed1), gamma: mix_gamma(self.seed), }
 }
 
 ///|
diff --git a/quickcheck/splitmix/random_test.mbt b/quickcheck/splitmix/random_test.mbt
index c8a881fb61..3d02904101 100644
--- a/quickcheck/splitmix/random_test.mbt
+++ b/quickcheck/splitmix/random_test.mbt
@@ -52,3 +52,41 @@ test "mix_gamma else branch seed" {
   let state = @splitmix.new(seed=930507UL)
   inspect(state.next_uint(), content="3773570134")
 }
+
+///|
+test "next_uint64 with a limit skips masked values outside the bound" {
+  let state = @splitmix.new(seed=7UL)
+  let value = state.next_uint64(limit=3UL)
+  debug_inspect((value, state.next_uint64()), content="(2, 622236291405445593)")
+}
+
+///|
+test "next_uint with a limit skips masked values outside the bound" {
+  let state = @splitmix.new(seed=7UL)
+  let value = state.next_uint(limit=3U)
+  debug_inspect((value, state.next_uint64()), content="(2, 622236291405445593)")
+}
+
+///|
+test "next_uint64 handles unit and power-of-two limits" {
+  let unit = @splitmix.new(seed=1UL)
+  let unit_value = unit.next_uint64(limit=1UL)
+  let power = @splitmix.new(seed=1UL)
+  let power_value = power.next_uint64(limit=8UL)
+  debug_inspect(
+    (unit_value, unit.next_uint64(), power_value, power.next_uint64()),
+    content="(0, 9482272708574442377, 1, 9482272708574442377)",
+  )
+}
+
+///|
+test "panic next_uint64 rejects a zero limit" {
+  let state = @splitmix.new(seed=1UL)
+  ignore(state.next_uint64(limit=0UL))
+}
+
+///|
+test "panic next_uint rejects a zero limit" {
+  let state = @splitmix.new(seed=1UL)
+  ignore(state.next_uint(limit=0U))
+}
diff --git a/random/README.mbt.md b/random/README.mbt.md
index 27a6645b74..9dd2d436c4 100644
--- a/random/README.mbt.md
+++ b/random/README.mbt.md
@@ -156,7 +156,7 @@ impl @random.Source for MySource with fn next(self) -> UInt64 {
 
 ///|
 test {
-  let gen : MySource = { value: 42 }
+  let gen : MySource = { value: 42, }
   let r = @random.Rand::new(generator=gen as &@random.Source)
   let _ = r.uint64()
 }
diff --git a/random/internal/random_source/random_source_chacha.mbt b/random/internal/random_source/random_source_chacha.mbt
index 33f3c87da9..4f1adbd3dc 100644
--- a/random/internal/random_source/random_source_chacha.mbt
+++ b/random/internal/random_source/random_source_chacha.mbt
@@ -83,7 +83,7 @@ pub fn ChaCha8::ChaCha8(seed : BytesView) -> ChaCha8 {
   })
   let buffer = FixedArray::make(BUFFER_CHUNK_NUM * 2, 0U)
   chacha_block(seed, buffer, 0)
-  { seed, buffer, counter: 0, i: 0, n: BUFFER_CHUNK_NUM.reinterpret_as_uint() }
+  { seed, buffer, counter: 0, i: 0, n: BUFFER_CHUNK_NUM.reinterpret_as_uint(), }
 }
 
 ///|
diff --git a/random/random.mbt b/random/random.mbt
index 24e1695194..d44cc49e58 100644
--- a/random/random.mbt
+++ b/random/random.mbt
@@ -46,7 +46,7 @@ pub fn Rand::chacha8(
 let fixed_test_seed : Bytes = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ123456"
 
 ///|
-/// Create a new random number generator with a given [Gen] source.
+/// Create a new random number generator with a given [Source] generator.
 pub fn Rand::new(generator? : &Source) -> Rand {
   match generator {
     None =>
@@ -405,11 +405,11 @@ pub fn Rand::boolean(self : Rand) -> Bool {
 }
 
 ///|
-/// Generates a random positive `BigInt` with a specified number of bits.
+/// Generates a random non-negative `BigInt` with a specified number of bits.
 ///
 /// Parameters:
 ///
-/// * `rand` : A random number generator that implements the `Rand` trait.
+/// * `self` : The random number generator to draw the bits from.
 /// * `bits` : The desired number of bits in the generated number.
 ///
 /// Example:
@@ -470,7 +470,7 @@ fn umul128(a : UInt64, b : UInt64) -> UInt128 {
   let y = aHi * bLo + (x >> 32)
   let z = aLo * bHi + (y & 0xffffffff)
   let w = aHi * bHi + (y >> 32) + (z >> 32)
-  { hi: w, lo: a * b }
+  { hi: w, lo: a * b, }
 }
 
 ///|
diff --git a/random/random_test.mbt b/random/random_test.mbt
index fa8862ee05..b9b5e5aa70 100644
--- a/random/random_test.mbt
+++ b/random/random_test.mbt
@@ -27,11 +27,11 @@ impl @random.Source for ArithmeticSource with fn next(self) -> UInt64 {
 
 ///|
 test "Rand::double and Rand::float accept interval bounds" {
-  let double_source : ArithmeticSource = { value: 0, step: 0 }
+  let double_source : ArithmeticSource = { value: 0, step: 0, }
   let double_rand = @random.Rand::new(
     generator=double_source as &@random.Source,
   )
-  let float_source : ArithmeticSource = { value: 0, step: 0 }
+  let float_source : ArithmeticSource = { value: 0, step: 0, }
   let float_rand = @random.Rand::new(generator=float_source as &@random.Source)
   inspect(double_rand.double(min=1.0, max=2.0), content="1.9999999999999998")
   inspect(float_rand.float(min=1.0F, max=2.0F), content="1.9999998807907104")
@@ -67,7 +67,7 @@ fn histogram(
 
 ///|
 test "Rand::double samples every gamma section exactly once" {
-  let source : ArithmeticSource = { value: 0, step: 1 }
+  let source : ArithmeticSource = { value: 0, step: 1, }
   let rand = @random.Rand::new(generator=source as &@random.Source)
   let min = 0x3ff0000000000000UL.reinterpret_as_double()
   let max = 0x3ff0000000000004UL.reinterpret_as_double()
@@ -81,7 +81,7 @@ test "Rand::double samples every gamma section exactly once" {
 
 ///|
 test "Rand::double includes the short boundary gap across an exponent" {
-  let source : ArithmeticSource = { value: 0, step: 1 }
+  let source : ArithmeticSource = { value: 0, step: 1, }
   let rand = @random.Rand::new(generator=source as &@random.Source)
   let min = 0x3fffffffffffffffUL.reinterpret_as_double()
   let max = 0x4000000000000001UL.reinterpret_as_double()
@@ -93,7 +93,7 @@ test "Rand::double includes the short boundary gap across an exponent" {
 
 ///|
 test "Rand::double steps from the larger-magnitude lower bound" {
-  let source : ArithmeticSource = { value: 0, step: 1 }
+  let source : ArithmeticSource = { value: 0, step: 1, }
   let rand = @random.Rand::new(generator=source as &@random.Source)
   let min = 0xbff0000000000004UL.reinterpret_as_double()
   let max = 0xbff0000000000000UL.reinterpret_as_double()
@@ -107,7 +107,7 @@ test "Rand::double steps from the larger-magnitude lower bound" {
 
 ///|
 test "Rand::double preserves k above 53 bits without overflow" {
-  let source : ArithmeticSource = { value: 0x8000000000000401UL, step: 0 }
+  let source : ArithmeticSource = { value: 0x8000000000000401UL, step: 0, }
   let rand = @random.Rand::new(generator=source as &@random.Source)
   let min = 0xffefffffffffffffUL.reinterpret_as_double()
   let max = 0x7fefffffffffffffUL.reinterpret_as_double()
@@ -117,7 +117,7 @@ test "Rand::double preserves k above 53 bits without overflow" {
 
 ///|
 test "Rand::double never returns the open upper bound after a negative residual" {
-  let source : ArithmeticSource = { value: 0xfffffffffffffaabUL, step: 0 }
+  let source : ArithmeticSource = { value: 0xfffffffffffffaabUL, step: 0, }
   let rand = @random.Rand::new(generator=source as &@random.Source)
   let min = 0xffefffffffffffffUL.reinterpret_as_double()
   let max = 0x7fe0000000000000UL.reinterpret_as_double()
@@ -127,7 +127,7 @@ test "Rand::double never returns the open upper bound after a negative residual"
 
 ///|
 test "Rand::double supports the underflow-guard boundary" {
-  let source : ArithmeticSource = { value: 0, step: 0 }
+  let source : ArithmeticSource = { value: 0, step: 0, }
   let rand = @random.Rand::new(generator=source as &@random.Source)
   let max = 4.0 * @double.min_positive
   let actual = rand.double(min=0.0, max~).reinterpret_as_uint64()
@@ -136,7 +136,7 @@ test "Rand::double supports the underflow-guard boundary" {
 
 ///|
 test "Rand::float samples every gamma section exactly once" {
-  let source : ArithmeticSource = { value: 0, step: 1 }
+  let source : ArithmeticSource = { value: 0, step: 1, }
   let rand = @random.Rand::new(generator=source as &@random.Source)
   let min = Float::reinterpret_from_uint(0x3f800000U)
   let max = Float::reinterpret_from_uint(0x3f800004U)
@@ -148,7 +148,7 @@ test "Rand::float samples every gamma section exactly once" {
 
 ///|
 test "Rand::float includes the short boundary gap across an exponent" {
-  let source : ArithmeticSource = { value: 0, step: 1 }
+  let source : ArithmeticSource = { value: 0, step: 1, }
   let rand = @random.Rand::new(generator=source as &@random.Source)
   let min = Float::reinterpret_from_uint(0x3fffffffU)
   let max = Float::reinterpret_from_uint(0x40000001U)
@@ -160,7 +160,7 @@ test "Rand::float includes the short boundary gap across an exponent" {
 
 ///|
 test "Rand::float steps from the larger-magnitude lower bound" {
-  let source : ArithmeticSource = { value: 0, step: 1 }
+  let source : ArithmeticSource = { value: 0, step: 1, }
   let rand = @random.Rand::new(generator=source as &@random.Source)
   let min = Float::reinterpret_from_uint(0xbf800004U)
   let max = Float::reinterpret_from_uint(0xbf800000U)
@@ -172,7 +172,7 @@ test "Rand::float steps from the larger-magnitude lower bound" {
 
 ///|
 test "Rand::float preserves k above 24 bits without overflow" {
-  let source : ArithmeticSource = { value: 0x8000008000008001UL, step: 0 }
+  let source : ArithmeticSource = { value: 0x8000008000008001UL, step: 0, }
   let rand = @random.Rand::new(generator=source as &@random.Source)
   let min = Float::reinterpret_from_uint(0xff7fffffU)
   let max = Float::reinterpret_from_uint(0x7f7fffffU)
@@ -182,7 +182,7 @@ test "Rand::float preserves k above 24 bits without overflow" {
 
 ///|
 test "Rand::float supports the underflow-guard boundary" {
-  let source : ArithmeticSource = { value: 0, step: 0 }
+  let source : ArithmeticSource = { value: 0, step: 0, }
   let rand = @random.Rand::new(generator=source as &@random.Source)
   let max = 4.0F * @float.min_positive
   let actual = rand.float(min=0.0F, max~).reinterpret_as_uint()
@@ -194,7 +194,7 @@ test "gamma-section avoids naive Double bias and the open upper bound" {
   let min_bits = 0x3ff0000000000000UL
   let min = min_bits.reinterpret_as_double()
   let max = 0x3ff0000000000004UL.reinterpret_as_double()
-  let naive_source : ArithmeticSource = { value: 0, step: 1UL << 49 }
+  let naive_source : ArithmeticSource = { value: 0, step: 1UL << 49, }
   let naive_rand = @random.Rand::new(generator=naive_source as &@random.Source)
   let naive = histogram(
     () => (min + (max - min) * naive_rand.double()).reinterpret_as_uint64(),
@@ -202,7 +202,7 @@ test "gamma-section avoids naive Double bias and the open upper bound" {
     5,
     16,
   )
-  let gamma_source : ArithmeticSource = { value: 0, step: 1 }
+  let gamma_source : ArithmeticSource = { value: 0, step: 1, }
   let gamma_rand = @random.Rand::new(generator=gamma_source as &@random.Source)
   let gamma = histogram(
     () => gamma_rand.double(min~, max~).reinterpret_as_uint64(),
@@ -221,7 +221,7 @@ test "gamma-section avoids naive Float bias and the open upper bound" {
   let min_bits = 0x3f800000U
   let min = Float::reinterpret_from_uint(min_bits)
   let max = Float::reinterpret_from_uint(0x3f800004U)
-  let naive_source : ArithmeticSource = { value: 0, step: 1UL << 20 }
+  let naive_source : ArithmeticSource = { value: 0, step: 1UL << 20, }
   let naive_rand = @random.Rand::new(generator=naive_source as &@random.Source)
   let naive = histogram(
     () => {
@@ -231,7 +231,7 @@ test "gamma-section avoids naive Float bias and the open upper bound" {
     5,
     16,
   )
-  let gamma_source : ArithmeticSource = { value: 0, step: 1 }
+  let gamma_source : ArithmeticSource = { value: 0, step: 1, }
   let gamma_rand = @random.Rand::new(generator=gamma_source as &@random.Source)
   let gamma = histogram(
     () => gamma_rand.float(min~, max~).reinterpret_as_uint().to_uint64(),
@@ -322,7 +322,7 @@ test "prng" {
 
 ///|
 test "Rand::new with generator" {
-  let gen : ArithmeticSource = { value: 1, step: 1 }
+  let gen : ArithmeticSource = { value: 1, step: 1, }
   let rand = @random.Rand::new(generator=gen as &@random.Source)
   inspect(rand.int(limit=10) < 10, content="true")
 }
@@ -354,7 +354,7 @@ test "Rand::uint64 applies Lemire rejection for limit 3" {
 
 ///|
 test "Rand::uint64 applies Lemire rejection at the maximum limit" {
-  let source : ScriptedSource = { values: [0UL, 2UL], index: 0 }
+  let source : ScriptedSource = { values: [0UL, 2UL], index: 0, }
   let rand = @random.Rand::new(generator=source as &@random.Source)
   let result = rand.uint64(limit=0xffffffffffffffffUL)
   @debug.debug_inspect((result, source.index), content="(1, 2)")
diff --git a/range/moon.pkg b/range/moon.pkg
index cb35aa5902..8bed658dcd 100644
--- a/range/moon.pkg
+++ b/range/moon.pkg
@@ -9,4 +9,5 @@ import {
 import {
   "moonbitlang/core/int",
   "moonbitlang/core/debug",
+  "moonbitlang/core/quickcheck",
 } for "test"
diff --git a/range/quickcheck_test.mbt b/range/quickcheck_test.mbt
new file mode 100644
index 0000000000..36b93a7028
--- /dev/null
+++ b/range/quickcheck_test.mbt
@@ -0,0 +1,628 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Specification tests for `@range.iter`.
+//
+// The specification is "`from`, `from + step`, `from + 2 * step`, ...
+// while the value is on the correct side of `to`", and the whole
+// difficulty is in the words the implementation has to add to make that
+// terminate on a fixed-width type: it stops as soon as `current + step`
+// fails to make progress past `current`.
+//
+// So the oracle here computes the sequence in **exact, unbounded
+// arithmetic** (`BigInt`) and stops when the next value would leave the
+// type's range. That is a genuinely independent statement of the same
+// rule: for a two's-complement type, "the exact next value is outside
+// `[min, max]`" and "the wrapped next value fails to make progress" are
+// the same condition, but the oracle never performs the wrapping
+// arithmetic whose corner cases are under test. A reference written
+// with the same fixed-width `+` would reproduce an overflow bug rather
+// than catch it.
+//
+// Cases are generated in `BigInt` space and then narrowed to each
+// `Step` type, so all nine integral impls -- `Int`, `Int64`, `UInt`,
+// `UInt64`, `Int16`, `UInt16`, `Byte`, and `BigInt` itself -- are
+// checked against the one oracle, with generators that concentrate on
+// the ends of each type's range where the overflow stop actually
+// engages. `Float` and `Double` cannot use it (their step is not exact)
+// and are specified separately by invariant.
+
+// =====================================================================
+// The exact-arithmetic oracle.
+// =====================================================================
+
+///|
+/// How many values any single property will compare. Generated cases
+/// are shaped to stay well under this, but an anchor-derived `step` of
+/// 1 across a full-width range would otherwise enumerate billions of
+/// values; capping both sides keeps that case a *prefix* check instead
+/// of a hang, and still fails if the two disagree anywhere in the
+/// prefix or on where the sequence ends.
+const CAP : Int = 300
+
+///|
+/// The reference enumeration, in exact integer arithmetic.
+///
+/// `min` and `max` bound the element type. The loop stops when the next
+/// value would fall outside them -- the exact-arithmetic statement of
+/// the implementation's "no progress" test.
+fn model(
+  from : BigInt,
+  to : BigInt,
+  step : BigInt,
+  inclusive : Bool,
+  min : BigInt,
+  max : BigInt,
+) -> Array[BigInt] {
+  let out = []
+  let direction = if step > 0N { 1 } else if step < 0N { -1 } else { 0 }
+  let mut current = from
+  while out.length() < CAP {
+    let in_range = if direction > 0 {
+      current < to
+    } else if direction < 0 {
+      current > to
+    } else {
+      false
+    }
+    if !(in_range || (inclusive && current == to)) {
+      break
+    }
+    out.push(current)
+    // A zero step never advances, so it yields at most the single
+    // `inclusive && from == to` value.
+    if direction == 0 {
+      break
+    }
+    let next = current + step
+    if next < min || next > max {
+      break
+    }
+    current = next
+  }
+  out
+}
+
+// =====================================================================
+// Case generation.
+// =====================================================================
+
+///|
+/// Maps an arbitrary `Int` into `0.. Int {
+  let r = value % modulus
+  if r < 0 {
+    r + modulus
+  } else {
+    r
+  }
+}
+
+///|
+fn clamp(value : BigInt, min : BigInt, max : BigInt) -> BigInt {
+  if value < min {
+    min
+  } else if value > max {
+    max
+  } else {
+    value
+  }
+}
+
+///|
+/// Values worth starting or stopping at: both ends of the type's range
+/// and their neighbours (where the overflow stop engages), the
+/// midpoint, and the small values around zero.
+fn anchors(min : BigInt, max : BigInt) -> Array[BigInt] {
+  let mid = (min + max) / 2N
+  let candidates = [
+    min,
+    min + 1N,
+    min + 2N,
+    mid,
+    mid + 1N,
+    max - 2N,
+    max - 1N,
+    max,
+    0N,
+    1N,
+    -1N,
+    2N,
+  ]
+  candidates.filter(v => v >= min && v <= max)
+}
+
+///|
+/// Builds a `(from, to, step)` triple inside `[min, max]`.
+///
+/// `to` takes one of two shapes: *narrow*, within 64 of `from` — where
+/// the boundary conditions (`inclusive`, empty ranges, wrong-direction
+/// steps, the last step before overflow) live — or an arbitrary anchor,
+/// which spans the type.
+///
+/// `step` independently takes one of three: a small value including
+/// zero; a value scaled so that a few hundred additions cross the whole
+/// range, which is what drives a long run into the overflow stop; or an
+/// anchor, which is how the extreme steps (`min`, `max`, and a bare
+/// `1` across a full-width range) get covered.
+fn build_case(
+  min : BigInt,
+  max : BigInt,
+  a : Int,
+  b : Int,
+  c : Int,
+) -> (BigInt, BigInt, BigInt) {
+  let anc = anchors(min, max)
+  let from = anc[wrap_index(a, anc.length())]
+  let to = if wrap_index(b, 2) == 0 {
+    clamp(from + BigInt::from_int(wrap_index(b / 2, 129) - 64), min, max)
+  } else {
+    anc[wrap_index(b / 2, anc.length())]
+  }
+  let step = match wrap_index(c, 3) {
+    // -8 ..= 8, including the zero step
+    0 => BigInt::from_int(wrap_index(c / 3, 17) - 8)
+    // large enough to cross the whole range in a few hundred additions
+    1 => {
+      let unit = (max - min) / BigInt::from_int(CAP) + 1N
+      let scaled = unit * BigInt::from_int(1 + wrap_index(c / 3, 4))
+      if wrap_index(c / 12, 2) == 0 {
+        scaled
+      } else {
+        -scaled
+      }
+    }
+    // the extremes of the type
+    _ => anc[wrap_index(c / 3, anc.length())]
+  }
+  (from, to, clamp(step, min, max))
+}
+
+// =====================================================================
+// The integral Step impls, against the oracle.
+// =====================================================================
+
+///|
+/// `Int64` bounds, and the widest range used for `BigInt` (which has no
+/// bounds of its own, so the oracle's overflow stop must never fire).
+let i64_min : BigInt = -9223372036854775808N
+
+///|
+let i64_max : BigInt = 9223372036854775807N
+
+///|
+let unbounded : BigInt = 1N << 200
+
+///|
+test "quickcheck: Int matches the exact-arithmetic model" {
+  @quickcheck.check(
+    (input : (Int, Int, Int, Bool)) => {
+      let (a, b, c, inclusive) = input
+      let min = -2147483648N
+      let max = 2147483647N
+      let (from, to, step) = build_case(min, max, a, b, c)
+      @range.iter(
+        from=from.to_int(),
+        to=to.to_int(),
+        step=step.to_int(),
+        inclusive~,
+      )
+      .take(CAP)
+      .to_array()
+      .map(BigInt::from_int) ==
+      model(from, to, step, inclusive, min, max)
+    },
+    count=2000,
+  )
+}
+
+///|
+test "quickcheck: Int64 matches the exact-arithmetic model" {
+  @quickcheck.check(
+    (input : (Int, Int, Int, Bool)) => {
+      let (a, b, c, inclusive) = input
+      let (from, to, step) = build_case(i64_min, i64_max, a, b, c)
+      @range.iter(
+        from=from.to_int64(),
+        to=to.to_int64(),
+        step=step.to_int64(),
+        inclusive~,
+      )
+      .take(CAP)
+      .to_array()
+      .map(BigInt::from_int64) ==
+      model(from, to, step, inclusive, i64_min, i64_max)
+    },
+    count=2000,
+  )
+}
+
+///|
+test "quickcheck: UInt matches the exact-arithmetic model" {
+  @quickcheck.check(
+    (input : (Int, Int, Int, Bool)) => {
+      let (a, b, c, inclusive) = input
+      let min = 0N
+      let max = 4294967295N
+      let (from, to, step) = build_case(min, max, a, b, c)
+      @range.iter(
+        from=from.to_uint(),
+        to=to.to_uint(),
+        step=step.to_uint(),
+        inclusive~,
+      )
+      .take(CAP)
+      .to_array()
+      .map(BigInt::from_uint) ==
+      model(from, to, step, inclusive, min, max)
+    },
+    count=2000,
+  )
+}
+
+///|
+test "quickcheck: UInt64 matches the exact-arithmetic model" {
+  @quickcheck.check(
+    (input : (Int, Int, Int, Bool)) => {
+      let (a, b, c, inclusive) = input
+      let min = 0N
+      let max = 18446744073709551615N
+      let (from, to, step) = build_case(min, max, a, b, c)
+      @range.iter(
+        from=from.to_uint64(),
+        to=to.to_uint64(),
+        step=step.to_uint64(),
+        inclusive~,
+      )
+      .take(CAP)
+      .to_array()
+      .map(BigInt::from_uint64) ==
+      model(from, to, step, inclusive, min, max)
+    },
+    count=2000,
+  )
+}
+
+///|
+test "quickcheck: Int16 matches the exact-arithmetic model" {
+  @quickcheck.check(
+    (input : (Int, Int, Int, Bool)) => {
+      let (a, b, c, inclusive) = input
+      let min = -32768N
+      let max = 32767N
+      let (from, to, step) = build_case(min, max, a, b, c)
+      @range.iter(
+        from=Int16::from_int(from.to_int()),
+        to=Int16::from_int(to.to_int()),
+        step=Int16::from_int(step.to_int()),
+        inclusive~,
+      )
+      .take(CAP)
+      .to_array()
+      .map(v => BigInt::from_int(v.to_int())) ==
+      model(from, to, step, inclusive, min, max)
+    },
+    count=2000,
+  )
+}
+
+///|
+test "quickcheck: UInt16 matches the exact-arithmetic model" {
+  @quickcheck.check(
+    (input : (Int, Int, Int, Bool)) => {
+      let (a, b, c, inclusive) = input
+      let min = 0N
+      let max = 65535N
+      let (from, to, step) = build_case(min, max, a, b, c)
+      @range.iter(
+        from=from.to_int().to_uint16(),
+        to=to.to_int().to_uint16(),
+        step=step.to_int().to_uint16(),
+        inclusive~,
+      )
+      .take(CAP)
+      .to_array()
+      .map(v => BigInt::from_int(v.to_int())) ==
+      model(from, to, step, inclusive, min, max)
+    },
+    count=2000,
+  )
+}
+
+///|
+test "quickcheck: Byte matches the exact-arithmetic model" {
+  @quickcheck.check(
+    (input : (Int, Int, Int, Bool)) => {
+      let (a, b, c, inclusive) = input
+      let min = 0N
+      let max = 255N
+      let (from, to, step) = build_case(min, max, a, b, c)
+      @range.iter(
+        from=from.to_int().to_byte(),
+        to=to.to_int().to_byte(),
+        step=step.to_int().to_byte(),
+        inclusive~,
+      )
+      .take(CAP)
+      .to_array()
+      .map(v => BigInt::from_int(v.to_int())) ==
+      model(from, to, step, inclusive, min, max)
+    },
+    count=2000,
+  )
+}
+
+///|
+test "quickcheck: BigInt matches the exact-arithmetic model" {
+  // `BigInt` has no range to overflow, so the oracle's stop condition
+  // must never fire: the sequence is decided purely by `to`.
+  @quickcheck.check(
+    (input : (Int, Int, Int, Bool)) => {
+      let (a, b, c, inclusive) = input
+      let (from, to, step) = build_case(i64_min, i64_max, a, b, c)
+      @range.iter(from~, to~, step~, inclusive~).take(CAP).to_array() ==
+      model(from, to, step, inclusive, -unbounded, unbounded)
+    },
+    count=2000,
+  )
+}
+
+// =====================================================================
+// Properties that hold for every element type.
+// =====================================================================
+
+///|
+test "quickcheck: the general shape of an Int enumeration" {
+  // Restating the contract without reference to the oracle: the values
+  // are an arithmetic progression, strictly monotonic in the step's
+  // direction, each strictly on the correct side of `to` (or equal to
+  // it exactly once, at the end, when inclusive).
+  @quickcheck.check(
+    (input : (Int, Int, Int, Bool)) => {
+      let (a, b, c, inclusive) = input
+      let min = -2147483648N
+      let max = 2147483647N
+      let (from, to, step) = build_case(min, max, a, b, c)
+      let values = @range.iter(
+          from=from.to_int(),
+          to=to.to_int(),
+          step=step.to_int(),
+          inclusive~,
+        )
+        .take(CAP)
+        .to_array()
+        .map(BigInt::from_int)
+      if values.is_empty() {
+        return true
+      }
+      // it starts at `from`
+      guard values[0] == from else { return false }
+      for i in 0.. 0N {
+          values[i] > to
+        } else if step < 0N {
+          values[i] < to
+        } else {
+          false
+        }
+        guard !past_end else { return false }
+        guard values[i] != to || inclusive else { return false }
+      }
+      true
+    },
+    count=2000,
+  )
+}
+
+///|
+test "quickcheck: an inclusive range is the exclusive one plus at most its endpoint" {
+  @quickcheck.check(
+    (input : (Int, Int, Int)) => {
+      let (a, b, c) = input
+      let min = -2147483648N
+      let max = 2147483647N
+      let (from, to, step) = build_case(min, max, a, b, c)
+      let run = (inclusive : Bool) => {
+        @range.iter(
+          from=from.to_int(),
+          to=to.to_int(),
+          step=step.to_int(),
+          inclusive~,
+        )
+        .take(CAP)
+        .to_array()
+      }
+      let exclusive = run(false)
+      let inclusive = run(true)
+      // The inclusive run is the exclusive one, possibly with `to`
+      // appended -- it can never differ anywhere else.
+      if inclusive == exclusive {
+        true
+      } else {
+        inclusive.length() == exclusive.length() + 1 &&
+        inclusive[0:exclusive.length()].to_owned() == exclusive &&
+        BigInt::from_int(inclusive[exclusive.length()]) == to
+      }
+    },
+    count=2000,
+  )
+}
+
+///|
+test "quickcheck: reversing the step reverses the enumeration" {
+  // Walking `from -> to` and then walking back from the last value with
+  // the negated step must retrace exactly the same values. This ties
+  // the ascending and descending branches together without the oracle.
+  @quickcheck.check(
+    (input : (Int, Int)) => {
+      let start = wrap_index(input.0, 200) - 100
+      let step = 1 + wrap_index(input.1, 9)
+      let count = wrap_index(input.0 / 200, 30)
+      let stop = start + step * count
+      let up = @range.iter(from=start, to=stop, step~).to_array()
+      if up.is_empty() {
+        return true
+      }
+      let last = up[up.length() - 1]
+      let down = @range.iter(from=last, to=start, step=-step, inclusive=true).to_array()
+      down.rev() == up
+    },
+    count=1000,
+  )
+}
+
+// =====================================================================
+// Float and Double.
+// =====================================================================
+
+///|
+/// The floating-point impls cannot be checked against exact arithmetic
+/// (`from + k * step` is not what repeated addition computes), so they
+/// are specified by invariant instead: strictly monotonic, every value
+/// on the correct side of `to`, and -- the property that keeps a
+/// sub-precision step from silently spinning forever -- terminating.
+test "quickcheck: Double enumerations are monotonic, bounded, and finite" {
+  @quickcheck.check(
+    (input : (Int, Int, Int, Bool)) => {
+      let (a, b, c, inclusive) = input
+      let from = (wrap_index(a, 400) - 200).to_double() / 8.0
+      let to = (wrap_index(b, 400) - 200).to_double() / 8.0
+      let step = (wrap_index(c, 21) - 10).to_double() / 4.0
+      let values = @range.iter(from~, to~, step~, inclusive~)
+        .take(CAP)
+        .to_array()
+      if values.is_empty() {
+        return true
+      }
+      guard values[0] == from else { return false }
+      // strictly monotonic in the step's direction, and never past `to`
+      for i in 0.. 0 {
+          guard (if step > 0.0 {
+            values[i] > values[i - 1]
+          } else {
+            values[i] < values[i - 1]
+          }) else {
+            return false
+          }
+        }
+        let past_end = if step > 0.0 {
+          values[i] > to
+        } else if step < 0.0 {
+          values[i] < to
+        } else {
+          false
+        }
+        guard !past_end else { return false }
+        guard values[i] != to || inclusive else { return false }
+      }
+      // a zero step yields at most the single inclusive endpoint
+      guard step != 0.0 || values.length() == 1 else { return false }
+      true
+    },
+    count=2000,
+  )
+}
+
+///|
+test "quickcheck: Float agrees with Double on exactly representable values" {
+  // Eighths of small integers are exact in both widths, so the two
+  // impls must enumerate the same values -- any divergence is a bug in
+  // one of the two `Step` instances rather than a rounding artefact.
+  @quickcheck.check(
+    (input : (Int, Int, Int, Bool)) => {
+      let (a, b, c, inclusive) = input
+      let from = (wrap_index(a, 400) - 200).to_double() / 8.0
+      let to = (wrap_index(b, 400) - 200).to_double() / 8.0
+      let step = (wrap_index(c, 21) - 10).to_double() / 4.0
+      let wide = @range.iter(from~, to~, step~, inclusive~).take(CAP).to_array()
+      let narrow = @range.iter(
+          from=Float::from_double(from),
+          to=Float::from_double(to),
+          step=Float::from_double(step),
+          inclusive~,
+        )
+        .take(CAP)
+        .to_array()
+      narrow.map(Float::to_double) == wide
+    },
+    count=2000,
+  )
+}
+
+///|
+test "a sub-precision step terminates instead of spinning" {
+  // 1.0 is far below the ULP of 1e300, so `current + step == current`
+  // and the iterator has to notice that it made no progress. Without
+  // that check this would not terminate at all.
+  inspect(
+    @range.iter(from=1.0e300, to=1.0e301, step=1.0).to_array().length(),
+    content=(
+      #|1
+    ),
+  )
+  inspect(
+    @range.iter(from=(1.0e30 : Float), to=(1.0e31 : Float), step=1.0)
+    .to_array()
+    .length(),
+    content=(
+      #|1
+    ),
+  )
+}
+
+///|
+test "a NaN endpoint yields nothing rather than looping" {
+  let nan = (0.0 : Double) / 0.0
+  // `Compare` on Double reports 0 against NaN, so the range check fails
+  // immediately; the inclusive check uses `==`, which NaN also fails.
+  inspect(
+    @range.iter(from=0.0, to=nan).to_array().length(),
+    content=(
+      #|0
+    ),
+  )
+  inspect(
+    @range.iter(from=nan, to=10.0).to_array().length(),
+    content=(
+      #|0
+    ),
+  )
+  inspect(
+    @range.iter(from=0.0, to=nan, inclusive=true).to_array().length(),
+    content=(
+      #|0
+    ),
+  )
+  inspect(
+    @range.iter(from=0.0, to=10.0, step=nan).to_array().length(),
+    content=(
+      #|0
+    ),
+  )
+  // a NaN step with from == to still yields nothing but the endpoint,
+  // because the step makes no progress
+  inspect(
+    @range.iter(from=0.0, to=0.0, step=nan, inclusive=true).to_array().length(),
+    content=(
+      #|1
+    ),
+  )
+}
diff --git a/range/range.mbt b/range/range.mbt
index d21046f8ca..4b4d0e864e 100644
--- a/range/range.mbt
+++ b/range/range.mbt
@@ -223,11 +223,20 @@ pub impl Step for @bigint.BigInt with fn step_one() {
 ///
 /// ```mbt nocheck
 /// test {
-///   inspect(@range.iter(from=0, to=10), content="[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]")
-///   inspect(@range.iter(from=0, to=10, step=3), content="[0, 3, 6, 9]")
-///   inspect(@range.iter(from=10, to=0, step=-3), content="[10, 7, 4, 1]")
-///   inspect(
-///     @range.iter(from=0, to=5, inclusive=true),
+///   debug_inspect(
+///     @range.iter(from=0, to=10).to_array(),
+///     content="[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]",
+///   )
+///   debug_inspect(
+///     @range.iter(from=0, to=10, step=3).to_array(),
+///     content="[0, 3, 6, 9]",
+///   )
+///   debug_inspect(
+///     @range.iter(from=10, to=0, step=-3).to_array(),
+///     content="[10, 7, 4, 1]",
+///   )
+///   debug_inspect(
+///     @range.iter(from=0, to=5, inclusive=true).to_array(),
 ///     content="[0, 1, 2, 3, 4, 5]",
 ///   )
 /// }
diff --git a/ref/ref.mbt b/ref/ref.mbt
index 1fa553891c..5616197dd1 100644
--- a/ref/ref.mbt
+++ b/ref/ref.mbt
@@ -42,7 +42,7 @@ pub impl[X : Show] Show for Ref[X] with fn output(self, logger) {
 #alias(new, deprecated)
 #owned(x)
 pub fn[T] Ref::Ref(x : T) -> Ref[T] {
-  { val: x }
+  { val: x, }
 }
 
 ///|
@@ -59,7 +59,7 @@ test "to_string" {
 /// Same as the `Ref` constructor.
 #owned(x)
 pub fn[T] new(x : T) -> Ref[T] {
-  { val: x }
+  { val: x, }
 }
 
 ///|
@@ -73,7 +73,7 @@ pub fn[T] new(x : T) -> Ref[T] {
 /// }
 /// ```
 pub fn[T, R] Ref::map(self : Ref[T], f : (T) -> R raise?) -> Ref[R] raise? {
-  { val: f(self.val) }
+  { val: f(self.val), }
 }
 
 ///|
@@ -102,17 +102,10 @@ pub fn[T, R] Ref::map(self : Ref[T], f : (T) -> R raise?) -> Ref[R] raise? {
 pub fn[T, R] Ref::protect(self : Ref[T], a : T, f : () -> R raise?) -> R raise? {
   let old = self.val
   self.val = a
-  try f() catch {
-    err => {
-      self.val = old
-      raise err
-    }
-  } noraise {
-    r => {
-      self.val = old
-      r
-    }
+  defer {
+    self.val = old
   }
+  f()
 }
 
 ///|
diff --git a/result/README.mbt.md b/result/README.mbt.md
index 6d3b6c4ff7..c8235eebf7 100644
--- a/result/README.mbt.md
+++ b/result/README.mbt.md
@@ -16,7 +16,7 @@ test {
 }
 ```
 
-Or use the `ok` and `err` functions to create a `Result` value.
+The `Ok` and `Err` constructors work for any combination of value and error types.
 ```mbt check
 ///|
 test {
@@ -26,7 +26,7 @@ test {
 ```
 
 ### Querying variant
-You can check the variant of a `Result` using the `is_ok` and `is_err` methods.
+You can check the variant of a `Result` using the `is Ok(_)` and `is Err(_)` patterns.
 ```mbt check
 ///|
 test {
diff --git a/set/README.mbt.md b/set/README.mbt.md
index 2fb443b0a3..7e9edb0fcd 100644
--- a/set/README.mbt.md
+++ b/set/README.mbt.md
@@ -22,12 +22,12 @@ test "creating sets" {
   let from_array = @set.Set([1, 2, 3, 2, 1]) // Duplicates are removed
   inspect(from_array.length(), content="3")
 
-  // From fixed array
+  // From an array literal
   let from_fixed = @set.Set([10, 20, 30])
   inspect(from_fixed.length(), content="3")
 
   // From iterator
-  let from_iter = @set.Set::from_iter([1, 2, 3, 4, 5].iter())
+  let from_iter = @set.Set::from_iter([|1, 2, 3, 4, 5|])
   inspect(from_iter.length(), content="5")
 }
 ```
@@ -245,8 +245,8 @@ test "different types" {
   let string_set = @set.Set(["hello", "world", "moonbit"])
   inspect(string_set.contains("world"), content="true")
 
-  // Note: Char and Bool types don't implement Hash in this version
-  // So we use Int codes for demonstration
+  // Char and Bool implement Hash too, so they work as element types as well
+  // Here we use Int codes just for demonstration
   let char_codes = @set.Set([97, 98, 99]) // ASCII codes for 'a', 'b', 'c'
   inspect(char_codes.contains(98), content="true") // 'b' = 98
 
diff --git a/set/extends.mbt b/set/extends.mbt
index 45d2f2358d..bb4c1cba60 100644
--- a/set/extends.mbt
+++ b/set/extends.mbt
@@ -46,11 +46,6 @@ pub extend Set with Default::{default}
 #doc(hidden)
 pub extend Set with Eq::{not_equal}
 
-///|
-#deprecated("Use `@debug.Debug` instead of `Show` for collections", skip_current_package=true)
-#doc(hidden)
-pub extend Set with Show::{output, to_string}
-
 ///|
 #deprecated("Use `@json.to_json` instead", skip_current_package=true)
 #doc(hidden)
diff --git a/set/linked_hash_set.mbt b/set/linked_hash_set.mbt
index 8cf06c8e54..920c31d141 100644
--- a/set/linked_hash_set.mbt
+++ b/set/linked_hash_set.mbt
@@ -50,6 +50,25 @@ struct Set[K] {
 
 // Implementations
 
+// SAFETY NOTE for the unchecked probe accesses in this file.
+//
+// `capacity` is always a power of two and at least 1, `capacity_mask` is
+// `capacity - 1`, and `entries.length()` is `capacity`. A probe index is
+// therefore always in bounds: it starts as `hash & capacity_mask` and each
+// step re-masks with `(idx + 1) & capacity_mask`. `grow` installs the new
+// array, capacity and mask together before any rehash probe runs.
+//
+// Only those probe indices use `unsafe_get` / `unsafe_set`. `Set` also
+// stores slot indices in the list itself -- `prev` on each entry and `tail`
+// on the set -- and `copy` walks the whole list through them, from
+// `entries[self.tail]` back along each `entries[prev]`. Every access through
+// a stored index keeps the checked form. They are in bounds too, being
+// former probe indices in a table that never shrinks, but that argument
+// depends on the list being maintained correctly rather than on arithmetic
+// alone, so it is not one to build unchecked access on. `shift_back` is
+// unchecked, but on a local caller-based proof rather than on that
+// invariant: both of its callers pass masked probe indices.
+
 ///|
 let default_init_capacity = 8
 
@@ -136,8 +155,9 @@ fn[K : Eq] Set::add_with_hash(self : Set[K], key : K, hash : Int) -> Unit {
   if self.size >= self.grow_at {
     self.grow()
   }
+  // SAFETY: masked probe index; see the note at the top of this file.
   let (idx, psl) = for psl = 0, idx = hash & self.capacity_mask {
-    match self.entries[idx] {
+    match self.entries.unsafe_get(idx) {
       None => break (idx, psl)
       Some(curr_entry) => {
         if curr_entry.hash == hash && curr_entry.key == key {
@@ -151,15 +171,16 @@ fn[K : Eq] Set::add_with_hash(self : Set[K], key : K, hash : Int) -> Unit {
       }
     }
   }
-  let entry = { prev: self.tail, next: None, psl, key, hash }
+  let entry = { prev: self.tail, next: None, psl, key, hash, }
   self.add_entry_to_tail(idx, entry)
 }
 
 ///|
 #owned(entry)
 fn[K] Set::push_away(self : Set[K], idx : Int, entry : Entry[K]) -> Unit {
+  // SAFETY: masked probe index; see the note at the top of this file.
   for psl = entry.psl + 1, idx = (idx + 1) & self.capacity_mask, entry = entry {
-    match self.entries[idx] {
+    match self.entries.unsafe_get(idx) {
       None => {
         entry.psl = psl
         self.set_entry(entry, idx)
@@ -189,7 +210,7 @@ fn[K] Set::set_entry(self : Set[K], entry : Entry[K], new_idx : Int) -> Unit {
     None => self.tail = new_idx
     Some(next) => next.prev = new_idx
   }
-  self.entries[new_idx] = Some(entry)
+  self.entries.unsafe_set(new_idx, Some(entry))
 }
 
 ///|
@@ -218,8 +239,9 @@ pub fn[K : Hash + Eq] Set::add_and_check(self : Set[K], key : K) -> Bool {
     self.grow()
   }
   let hash = Hash::hash(key)
+  // SAFETY: masked probe index; see the note at the top of this file.
   let (idx, psl, added) = for psl = 0, idx = hash & self.capacity_mask {
-    match self.entries[idx] {
+    match self.entries.unsafe_get(idx) {
       None => break (idx, psl, true)
       Some(curr_entry) => {
         if curr_entry.hash == hash && curr_entry.key == key {
@@ -234,7 +256,7 @@ pub fn[K : Hash + Eq] Set::add_and_check(self : Set[K], key : K) -> Bool {
     }
   }
   if added {
-    let entry = { prev: self.tail, next: None, psl, key, hash }
+    let entry = { prev: self.tail, next: None, psl, key, hash, }
     self.add_entry_to_tail(idx, entry)
   }
   added
@@ -245,8 +267,9 @@ pub fn[K : Hash + Eq] Set::add_and_check(self : Set[K], key : K) -> Bool {
 pub fn[K : Hash + Eq] Set::contains(self : Set[K], key : K) -> Bool {
   // inline lookup to avoid unnecessary allocations
   let hash = Hash::hash(key)
+  // SAFETY: masked probe index; see the note at the top of this file.
   for i = 0, idx = hash & self.capacity_mask {
-    guard self.entries[idx] is Some(entry) else { break false }
+    guard self.entries.unsafe_get(idx) is Some(entry) else { break false }
     if entry.hash == hash && entry.key == key {
       break true
     }
@@ -280,8 +303,9 @@ pub fn[K : Hash + Eq] Set::contains(self : Set[K], key : K) -> Bool {
 /// ```
 pub fn[K : Hash + Eq] Set::remove(self : Set[K], key : K) -> Unit {
   let hash = Hash::hash(key)
+  // SAFETY: masked probe index; see the note at the top of this file.
   for i = 0, idx = hash & self.capacity_mask {
-    guard self.entries[idx] is Some(entry) else { break }
+    guard self.entries.unsafe_get(idx) is Some(entry) else { break }
     if entry.hash == hash && entry.key == key {
       self.remove_entry(entry)
       self.shift_back(idx)
@@ -318,8 +342,9 @@ pub fn[K : Hash + Eq] Set::remove(self : Set[K], key : K) -> Unit {
 /// ```
 pub fn[K : Hash + Eq] Set::remove_and_check(self : Set[K], key : K) -> Bool {
   let hash = Hash::hash(key)
+  // SAFETY: masked probe index; see the note at the top of this file.
   for i = 0, idx = hash & self.capacity_mask {
-    guard self.entries[idx] is Some(entry) else { break false }
+    guard self.entries.unsafe_get(idx) is Some(entry) else { break false }
     if entry.hash == hash && entry.key == key {
       self.remove_entry(entry)
       self.shift_back(idx)
@@ -345,7 +370,7 @@ fn[K] Set::add_entry_to_tail(
     tail => self.entries[tail].unwrap().next = Some(entry)
   }
   self.tail = idx
-  self.entries[idx] = Some(entry)
+  self.entries.unsafe_set(idx, Some(entry))
   self.size += 1
 }
 
@@ -363,11 +388,16 @@ fn[K] Set::remove_entry(self : Set[K], entry : Entry[K]) -> Unit {
 
 ///|
 fn[K] Set::shift_back(self : Set[K], idx : Int) -> Unit {
+  // SAFETY: both callers -- `remove` and `remove_and_check` -- pass a masked
+  // probe index. Unlike `Map`, no route reaches here carrying a stored list
+  // index, because `Set` has no `retain`. `next` is re-masked each step and
+  // later `cur` values are previous `next` values. This is also what makes
+  // the `set_entry` call below sound.
   for cur = idx {
     let next = (cur + 1) & self.capacity_mask
-    match self.entries[next] {
+    match self.entries.unsafe_get(next) {
       None | Some({ psl: 0, .. }) => {
-        self.entries[cur] = None
+        self.entries.unsafe_set(cur, None)
         break
       }
       Some(entry) => {
@@ -407,8 +437,9 @@ fn[K] Set::grow(self : Set[K]) -> Unit {
 #owned(outer)
 fn[K] Set::rehash_place_entry(self : Set[K], outer : Entry[K]) -> Unit {
   let hash = outer.hash
+  // SAFETY: masked probe index; see the note at the top of this file.
   for psl = 0, idx = hash & self.capacity_mask {
-    match self.entries[idx] {
+    match self.entries.unsafe_get(idx) {
       None => {
         outer.psl = psl
         outer.prev = self.tail
@@ -431,27 +462,6 @@ fn[K] Set::rehash_place_entry(self : Set[K], outer : Entry[K]) -> Unit {
 
 // Utils
 
-///|
-#deprecated("Use @debug.Debug instead of Show for debugging purposes. See https://github.com/moonbitlang/core/blob/main/debug/README.mbt.md")
-pub impl[K : Show] Show for Set[K]
-
-///|
-pub impl[K : Show] Show for Set[K] with fn output(self, logger) {
-  logger.write_string("{")
-  for i = 0, curr = self.head {
-    match (i, curr) {
-      (_, None) => break logger.write_string("}")
-      (i, Some({ key, next, .. })) => {
-        if i > 0 {
-          logger.write_string(", ")
-        }
-        logger.write_object(key)
-        continue i + 1, next
-      }
-    }
-  }
-}
-
 ///|
 /// Get the number of keys in the set.
 #alias(size, deprecated)
@@ -531,7 +541,7 @@ pub fn[K] Set::iter(self : Set[K]) -> Iter[K] {
 ///|
 /// Converts the hash set to an array.
 pub fn[K] Set::to_array(self : Set[K]) -> Array[K] {
-  let arr = Array::new(capacity=self.size)
+  let arr = Array(capacity=self.size)
   for x = self.head {
     match x {
       Some({ key, next, .. }) => {
@@ -593,7 +603,7 @@ pub fn[K] Set::copy(self : Set[K]) -> Set[K] {
   for entry = last, idx = self.tail, next = (None : Entry[K]?) {
     match (entry, idx, next) {
       ({ prev, psl, hash, key, .. }, idx, next) => {
-        let new_entry = { prev, next, psl, hash, key }
+        let new_entry = { prev, next, psl, hash, key, }
         other.entries[idx] = Some(new_entry)
         if prev != -1 {
           continue self.entries[prev].unwrap(), prev, Some(new_entry)
@@ -649,7 +659,7 @@ pub fn[K : Hash + Eq] Set::intersection(
 
 ///|
 pub impl[X : ToJson] ToJson for Set[X] with fn to_json(self) {
-  let res = Array::new(capacity=self.size)
+  let res = Array(capacity=self.size)
   for v in self {
     res.push(v.to_json())
   }
diff --git a/set/linked_hash_set_bench_test.mbt b/set/linked_hash_set_bench_test.mbt
new file mode 100644
index 0000000000..45384ca4a8
--- /dev/null
+++ b/set/linked_hash_set_bench_test.mbt
@@ -0,0 +1,78 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+let set_bench_n = 50000
+
+///|
+test "bench Set::add n=50000" (it : @bench.T) {
+  it.bench(fn() {
+    let s = @set.Set([])
+    for i in 0.. buf.write_string("[\{e}]"))
+  map.iter().each(e => buf <+ "[\{e}]")
   inspect(buf, content="[a][b][c]")
   buf.reset()
-  map.iter().take(2).each(e => buf.write_string("[\{e}]"))
+  map.iter().take(2).each(e => buf <+ "[\{e}]")
   inspect(buf, content="[a][b]")
 }
 
@@ -293,7 +293,7 @@ test "clear_and_reinsert" {
 ///|
 test "from_iter multiple elements iter" {
   debug_inspect(
-    @set.Set::from_iter([1, 2, 3].iter()),
+    @set.Set::from_iter([|1, 2, 3|]),
     content=(
       #|
     ),
@@ -303,7 +303,7 @@ test "from_iter multiple elements iter" {
 ///|
 test "from_iter single element iter" {
   debug_inspect(
-    @set.Set::from_iter([1].iter()),
+    @set.Set::from_iter([|1|]),
     content=(
       #|
     ),
@@ -312,7 +312,7 @@ test "from_iter single element iter" {
 
 ///|
 test "from_iter empty iter" {
-  let map : @set.Set[Int] = @set.Set::from_iter(Iter::empty())
+  let map : @set.Set[Int] = @set.Set::from_iter([||])
   debug_inspect(
     map,
     content=(
@@ -505,15 +505,15 @@ test "trigger grow" {
 test "grow rehashes collision cluster in insertion order" {
   let set : @set.Set[Key] = Set([], capacity=2)
   for i in 0..<12 {
-    set.add({ id: i, hash_value: i % 4 })
+    set.add({ id: i, hash_value: i % 4, })
   }
   inspect(set.length(), content="12")
   for i in 0..<12 {
-    assert_true(set.contains({ id: i, hash_value: i % 4 }))
+    assert_true(set.contains({ id: i, hash_value: i % 4, }))
   }
-  set.remove({ id: 5, hash_value: 1 })
-  assert_false(set.contains({ id: 5, hash_value: 1 }))
-  set.add({ id: 12, hash_value: 0 })
+  set.remove({ id: 5, hash_value: 1, })
+  assert_false(set.contains({ id: 5, hash_value: 1, }))
+  set.add({ id: 12, hash_value: 0, })
   let mut ids = ""
   set.each(key => ids += "\{key.id},")
   inspect(ids, content="0,1,2,3,4,6,7,8,9,10,11,12,")
diff --git a/set/moon.pkg b/set/moon.pkg
index 31c855d006..38bd44fd0c 100644
--- a/set/moon.pkg
+++ b/set/moon.pkg
@@ -9,4 +9,5 @@ import {
   "moonbitlang/core/array",
   "moonbitlang/core/test",
   "moonbitlang/core/quickcheck",
+  "moonbitlang/core/bench",
 } for "test"
diff --git a/set/pkg.generated.mbti b/set/pkg.generated.mbti
index dfe934bccf..ccaa0b03ae 100644
--- a/set/pkg.generated.mbti
+++ b/set/pkg.generated.mbti
@@ -58,8 +58,6 @@ pub impl[K : Hash + Eq] BitOr for Set[K]
 pub impl[K : Hash + Eq] BitXOr for Set[K]
 pub impl[K] Default for Set[K]
 pub impl[K : Hash + Eq] Eq for Set[K]
-#deprecated
-pub impl[K : Show] Show for Set[K]
 pub impl[K : Hash + Eq] Sub for Set[K]
 pub impl[X : ToJson] ToJson for Set[X]
 pub impl[K : @debug.Debug] @debug.Debug for Set[K]
diff --git a/sorted_map/README.mbt.md b/sorted_map/README.mbt.md
index ea48592ba0..b1a257ff19 100644
--- a/sorted_map/README.mbt.md
+++ b/sorted_map/README.mbt.md
@@ -230,7 +230,7 @@ The SortedMap supports several iterator patterns. Create a map from an iterator:
 ```mbt check
 ///|
 test {
-  let pairs = [(1, "one"), (2, "two"), (3, "three")].iter()
+  let pairs = [|(1, "one"), (2, "two"), (3, "three")|]
   let map = @sorted_map.from_iter(pairs)
   @test.assert_eq(map.length(), 3)
 }
@@ -381,7 +381,7 @@ Key properties of the AVL tree implementation:
 ## Comparison with Other Collections
 
 - **@hashmap.HashMap**: Provides O(1) average case lookups but doesn't maintain order; use when order doesn't matter
-- **@indexmap.T**: Maintains insertion order but not sorted order; use when insertion order matters
+- **Map** (builtin): Maintains insertion order but not sorted order; use when insertion order matters
 - **@sorted_map.SortedMap**: Maintains keys in sorted order; use when you need keys to be sorted
 
 Choose SortedMap when you need:
diff --git a/sorted_map/invariant_wbtest.mbt b/sorted_map/invariant_wbtest.mbt
index 0f3a4e6ba9..45fcc5c3a2 100644
--- a/sorted_map/invariant_wbtest.mbt
+++ b/sorted_map/invariant_wbtest.mbt
@@ -47,7 +47,7 @@ fn check_node(
       }
       let (lh, lc) = check_node(n.left, lower, Some(n.key))
       let (rh, rc) = check_node(n.right, Some(n.key), upper)
-      let true_height = 1 + max(lh, rh)
+      let true_height = 1 + lh.max(rh)
       if n.height != true_height {
         fail(
           "stale cached height at key \{n.key}: stored \{n.height}, actual \{true_height}",
@@ -126,7 +126,7 @@ fn check_against_model(
 /// key space (lots of collisions and removes of absent keys), invariants
 /// checked after every single operation.
 fn random_ops_scenario(seed : UInt64, ops : Int, key_space : Int) -> Unit raise {
-  let rng = Rand::{ state: seed }
+  let rng = Rand::{ state: seed, }
   let map : SortedMap[Int, Int] = new_sorted_map()
   let model : Map[Int, Int] = Map([])
   for i in 0.. (rng.below(97), rng.below(1000)))
@@ -257,7 +257,7 @@ test "from_array and clear keep invariants" {
 
 ///|
 test "copy, merge and merge_in_place keep invariants" {
-  let rng = Rand::{ state: 99UL }
+  let rng = Rand::{ state: 99UL, }
   let a : SortedMap[Int, Int] = new_sorted_map()
   let b : SortedMap[Int, Int] = new_sorted_map()
   for _ in 0..<200 {
@@ -283,7 +283,7 @@ test "copy, merge and merge_in_place keep invariants" {
 
 ///|
 test "range agrees with model and leaves tree intact" {
-  let rng = Rand::{ state: 1234UL }
+  let rng = Rand::{ state: 1234UL, }
   let map : SortedMap[Int, Int] = new_sorted_map()
   for _ in 0..<300 {
     map.set(rng.below(1000), 1)
diff --git a/sorted_map/map.mbt b/sorted_map/map.mbt
index 21e9b1fbfc..fa43334bbb 100644
--- a/sorted_map/map.mbt
+++ b/sorted_map/map.mbt
@@ -26,7 +26,7 @@ pub impl[K : Eq, V : Eq] Eq for SortedMap[K, V] with fn equal(self, other) {
 
 ///|
 fn[K, V] new_sorted_map() -> SortedMap[K, V] {
-  { root: None, size: 0 }
+  { root: None, size: 0, }
 }
 
 ///|
@@ -274,7 +274,7 @@ pub fn[K, V] SortedMap::keys(self : SortedMap[K, V]) -> Iter[K] {
     fn() {
       for x = next_node {
         match x {
-          Some({ left, key, value: _, right, height: _ }) => {
+          Some({ left, key, value: _, right, height: _, }) => {
             todo_list.push((key, right))
             continue left
           }
@@ -300,7 +300,7 @@ pub fn[K, V] SortedMap::values(self : SortedMap[K, V]) -> Iter[V] {
     fn() {
       for x = next_node {
         match x {
-          Some({ left, key: _, value, right, height: _ }) => {
+          Some({ left, key: _, value, right, height: _, }) => {
             todo_list.push((value, right))
             continue left
           }
@@ -334,7 +334,7 @@ pub fn[K, V] SortedMap::iter(self : SortedMap[K, V]) -> Iter[(K, V)] {
     fn() {
       for x = next_node {
         match x {
-          Some({ left, key, value, right, height: _ }) => {
+          Some({ left, key, value, right, height: _, }) => {
             todo_list.push((key, value, right))
             continue left
           }
@@ -373,7 +373,8 @@ pub fn[K : Compare, V] SortedMap::from_iter(
 }
 
 ///|
-/// Returns a new array of key-value pairs that are within the specified range [low, high].
+/// Returns an iterator over the key-value pairs whose keys are within the
+/// specified range [low, high] (both bounds inclusive).
 pub fn[K : Compare, V] SortedMap::range(
   self : SortedMap[K, V],
   low : K,
@@ -384,7 +385,7 @@ pub fn[K : Compare, V] SortedMap::range(
   Iter2::new(fn() {
     for x = next_node {
       match x {
-        Some({ left, key, value, right, height: _ }) => {
+        Some({ left, key, value, right, height: _, }) => {
           let cmp_key_low = key.compare(low)
           let cmp_key_high = key.compare(high)
           if cmp_key_low < 0 {
@@ -417,7 +418,7 @@ pub fn[K : Compare, V] SortedMap::range(
 }
 
 ///|
-/// Creates a deep copy of the sorted map.
+/// Creates a shallow copy of the sorted map.
 ///
 /// This operation creates a new map with the same structure and contents as the
 /// original map. The copy is independent - modifications to the copy will not
@@ -448,7 +449,7 @@ pub fn[K, V] SortedMap::copy(self : SortedMap[K, V]) -> SortedMap[K, V] {
   fn copy_node(node : Node[K, V]?) -> Node[K, V]? {
     match node {
       None => None
-      Some({ key, value, left, right, height }) =>
+      Some({ key, value, left, right, height, }) =>
         Some({
           key,
           value,
@@ -459,7 +460,7 @@ pub fn[K, V] SortedMap::copy(self : SortedMap[K, V]) -> SortedMap[K, V] {
     }
   }
 
-  { root: copy_node(self.root), size: self.size }
+  { root: copy_node(self.root), size: self.size, }
 }
 
 ///|
@@ -552,7 +553,7 @@ fn[K, V] replace_root_with_min(
 
 ///|
 fn[K, V] Node::update_height(self : Node[K, V]) -> Unit {
-  self.height = 1 + max(height(self.left), height(self.right))
+  self.height = 1 + height(self.left).max(height(self.right))
 }
 
 ///|
diff --git a/sorted_map/map_test.mbt b/sorted_map/map_test.mbt
index e959b4b0c3..d4f2ad5623 100644
--- a/sorted_map/map_test.mbt
+++ b/sorted_map/map_test.mbt
@@ -111,7 +111,7 @@ test "size" {
 test "each" {
   let map = @sorted_map.from_array([(3, "c"), (2, "b"), (1, "a")])
   let buf = StringBuilder()
-  map.each((k, v) => buf.write_string("\{k}\{v}"))
+  map.each((k, v) => buf <+ "\{k}\{v}")
   inspect(buf.to_string(), content="1a2b3c")
 }
 
@@ -119,7 +119,7 @@ test "each" {
 test "eachi" {
   let map = @sorted_map.from_array([(3, "c"), (2, "b"), (1, "a")])
   let buf = StringBuilder()
-  map.eachi((i, k, v) => buf.write_string("[\{i}]\{k}\{v}"))
+  map.eachi((i, k, v) => buf <+ "[\{i}]\{k}\{v}")
   inspect(buf.to_string(), content="[0]1a[1]2b[2]3c")
 }
 
@@ -176,7 +176,7 @@ test "iter_collect" {
 ///|
 test "from_iter multiple elements iter" {
   debug_inspect(
-    @sorted_map.from_iter([(1, 1), (2, 2), (3, 3)].iter()),
+    @sorted_map.from_iter([|(1, 1), (2, 2), (3, 3)|]),
     content=(
       #|
     ),
@@ -186,7 +186,7 @@ test "from_iter multiple elements iter" {
 ///|
 test "from_iter single element iter" {
   debug_inspect(
-    @sorted_map.from_iter([(1, 1)].iter()),
+    @sorted_map.from_iter([|(1, 1)|]),
     content=(
       #|
     ),
@@ -195,9 +195,7 @@ test "from_iter single element iter" {
 
 ///|
 test "from_iter empty iter" {
-  let pq : @sorted_map.SortedMap[Int, Int] = @sorted_map.from_iter(
-    Iter::empty(),
-  )
+  let pq : @sorted_map.SortedMap[Int, Int] = @sorted_map.from_iter([||])
   debug_inspect(
     pq,
     content=(
@@ -211,7 +209,7 @@ test "iter2" {
   let map = @sorted_map.from_array([(3, "c"), (2, "b"), (1, "a")])
   let buf = StringBuilder()
   for k, v in map {
-    buf.write_string("[\{k}\{v}]")
+    buf <+ "[\{k}\{v}]"
   }
   inspect(buf, content="[1a][2b][3c]")
 }
diff --git a/sorted_map/utils.mbt b/sorted_map/utils.mbt
index 2bb2ebe8f1..9b6e8f350f 100644
--- a/sorted_map/utils.mbt
+++ b/sorted_map/utils.mbt
@@ -15,7 +15,7 @@
 ///|
 #owned(key, value)
 fn[K, V] new_node(key : K, value : V) -> Node[K, V] {
-  { key, value, left: None, right: None, height: 1 }
+  { key, value, left: None, right: None, height: 1, }
 }
 
 ///|
@@ -23,15 +23,6 @@ impl[K : Eq, V] Eq for Node[K, V] with fn equal(self, other) {
   self.key == other.key
 }
 
-///|
-fn max(x : Int, y : Int) -> Int {
-  if x > y {
-    x
-  } else {
-    y
-  }
-}
-
 ///|
 fn[K, V] height(node : Node[K, V]?) -> Int {
   match node {
diff --git a/sorted_set/README.mbt.md b/sorted_set/README.mbt.md
index d25997a90b..c8b7daebae 100644
--- a/sorted_set/README.mbt.md
+++ b/sorted_set/README.mbt.md
@@ -166,7 +166,7 @@ test {
   // to_array
   @test.assert_eq(set.to_array(), [1, 2, 3])
   // from_iter
-  let set2 = @sorted_set.from_iter([4, 5, 6].iter())
+  let set2 = @sorted_set.from_iter([|4, 5, 6|])
   @test.assert_eq(set2.to_array(), [4, 5, 6])
 }
 ```
diff --git a/sorted_set/invariant_wbtest.mbt b/sorted_set/invariant_wbtest.mbt
new file mode 100644
index 0000000000..fbe9ff73f0
--- /dev/null
+++ b/sorted_set/invariant_wbtest.mbt
@@ -0,0 +1,220 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Whitebox structural-invariant tests for the mutable AVL tree behind
+// `SortedSet`, focused on the split/join based set operations
+// (`difference`, `intersection`, `symmetric_difference`, `union`).
+// Blackbox tests can only observe symptoms (wrong membership, wrong
+// iteration order); these tests recursively verify the internal invariants
+// of every node in every operation result, so latent corruption (a stale
+// cached height, an unbalanced subtree that merely degrades performance,
+// a size counter that drifted, a node shared with an input tree) is caught
+// directly.
+//
+// Invariants checked (see `check_invariants`):
+//  1. BST order with full min/max bounds propagation (checking only the
+//     immediate children would miss violations deeper in the tree).
+//  2. AVL balance: |height(left) - height(right)| <= 1 at every node.
+//  3. Cached height: the stored `height` field equals the recomputed
+//     true height (leaf = 1).
+//  4. The set-level `size` field equals the actual node count.
+
+///|
+/// Recursively checks one node. `lower`/`upper` are exclusive bounds
+/// inherited from ancestors. Returns (recomputed_height, node_count).
+fn check_node(
+  node : Node[Int]?,
+  lower : Int?,
+  upper : Int?,
+) -> (Int, Int) raise {
+  match node {
+    None => (0, 0)
+    Some(n) => {
+      if lower is Some(lo) && n.value <= lo {
+        fail("BST order violated: value \{n.value} <= lower bound \{lo}")
+      }
+      if upper is Some(hi) && n.value >= hi {
+        fail("BST order violated: value \{n.value} >= upper bound \{hi}")
+      }
+      let (lh, lc) = check_node(n.left, lower, Some(n.value))
+      let (rh, rc) = check_node(n.right, Some(n.value), upper)
+      let true_height = 1 + lh.max(rh)
+      if n.height != true_height {
+        fail(
+          "stale cached height at value \{n.value}: stored \{n.height}, actual \{true_height}",
+        )
+      }
+      let bal = lh - rh
+      if bal > 1 || bal < -1 {
+        fail(
+          "AVL balance violated at value \{n.value}: left height \{lh}, right height \{rh}",
+        )
+      }
+      (true_height, lc + rc + 1)
+    }
+  }
+}
+
+///|
+/// Verifies every structural invariant of the whole set.
+fn check_invariants(set : SortedSet[Int]) -> Unit raise {
+  let (_, count) = check_node(set.root, None, None)
+  if count != set.size {
+    fail(
+      "size field out of sync: stored \{set.size}, actual node count \{count}",
+    )
+  }
+}
+
+///|
+/// Minimal deterministic splitmix64 PRNG so the whitebox test needs no
+/// extra package imports (importing `quickcheck` here would pull in a
+/// package that itself depends on `sorted_set`).
+priv struct Rand {
+  mut state : UInt64
+}
+
+///|
+fn Rand::next(self : Rand) -> UInt64 {
+  self.state += 0x9E3779B97F4A7C15UL
+  let mut z = self.state
+  z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9UL
+  z = (z ^ (z >> 27)) * 0x94D049BB133111EBUL
+  z ^ (z >> 31)
+}
+
+///|
+/// Uniform-ish integer in [0, limit).
+fn Rand::below(self : Rand, limit : Int) -> Int {
+  (self.next() & 0x7FFFFFFFUL).to_int() % limit
+}
+
+///|
+/// Builds two random sets over `key_space`, runs the four set operations,
+/// and checks that every result satisfies the structural invariants and has
+/// the size the array model predicts, that the inputs are left intact, and
+/// that the results stay valid under further mutation.
+fn set_ops_scenario(
+  seed : UInt64,
+  size_a : Int,
+  size_b : Int,
+  key_space : Int,
+) -> Unit raise {
+  let rng = Rand::{ state: seed, }
+  let a : SortedSet[Int] = new_sorted_set()
+  let b : SortedSet[Int] = new_sorted_set()
+  for _ in 0.. {
+    let (xs, ys) = input
+    let a = @sorted_set.from_array(xs)
+    let b = @sorted_set.from_array(ys)
+    let d = a.difference(b)
+    let expected = a.to_array().filter(x => !b.contains(x))
+    d.to_array() == expected && d.length() == expected.length()
+  })
+}
+
+///|
+test "quickcheck: intersection matches the filter model" {
+  @quickcheck.check((input : (Array[Int], Array[Int])) => {
+    let (xs, ys) = input
+    let a = @sorted_set.from_array(xs)
+    let b = @sorted_set.from_array(ys)
+    let i = a.intersection(b)
+    let expected = a.to_array().filter(x => b.contains(x))
+    i.to_array() == expected && i.length() == expected.length()
+  })
+}
+
+///|
+test "quickcheck: symmetric_difference matches the filter model" {
+  @quickcheck.check((input : (Array[Int], Array[Int])) => {
+    let (xs, ys) = input
+    let a = @sorted_set.from_array(xs)
+    let b = @sorted_set.from_array(ys)
+    let s = a.symmetric_difference(b)
+    let expected = a.to_array().filter(x => !b.contains(x))
+    expected.append(b.to_array().filter(y => !a.contains(y)))
+    expected.sort()
+    s.to_array() == expected && s.length() == expected.length()
+  })
+}
+
+///|
+test "quickcheck: strongly asymmetric sizes match the filter model" {
+  // Forces the O(n log m) probe fallback in difference/intersection
+  // (taken when one side is under 1/16 of the other) and checks it against
+  // the same filter model as the split-based path.
+  @quickcheck.check((input : (Array[Int], Array[Int])) => {
+    let (xs, ys) = input
+    let k = if xs.length() < 10 { xs.length() } else { 10 }
+    let small = @sorted_set.from_array(xs[0:k])
+    let big = @sorted_set.from_array(ys)
+    for i in 0..<600 {
+      big.add(10000 + i)
+    }
+    let d_small_big = small.difference(big)
+    let e_d_small_big = small.to_array().filter(x => !big.contains(x))
+    let d_big_small = big.difference(small)
+    let e_d_big_small = big.to_array().filter(x => !small.contains(x))
+    let i_small_big = small.intersection(big)
+    let e_i_small_big = small.to_array().filter(x => big.contains(x))
+    let i_big_small = big.intersection(small)
+    let e_i_big_small = big.to_array().filter(x => small.contains(x))
+    d_small_big.to_array() == e_d_small_big &&
+    d_small_big.length() == e_d_small_big.length() &&
+    d_big_small.to_array() == e_d_big_small &&
+    d_big_small.length() == e_d_big_small.length() &&
+    i_small_big.to_array() == e_i_small_big &&
+    i_small_big.length() == e_i_small_big.length() &&
+    i_big_small.to_array() == e_i_big_small &&
+    i_big_small.length() == e_i_big_small.length()
+  })
+}
+
+///|
+test "quickcheck: set algebra laws relate the operations" {
+  @quickcheck.check((input : (Array[Int], Array[Int])) => {
+    let (xs, ys) = input
+    let a = @sorted_set.from_array(xs)
+    let b = @sorted_set.from_array(ys)
+    let a_minus_b = a.difference(b)
+    let b_minus_a = b.difference(a)
+    let both = a.intersection(b)
+    let sym = a.symmetric_difference(b)
+    a_minus_b.length() + both.length() == a.length() &&
+    b_minus_a.length() + both.length() == b.length() &&
+    sym.length() == a_minus_b.length() + b_minus_a.length() &&
+    sym == a_minus_b.union(b_minus_a) &&
+    a.union(b) == sym.union(both) &&
+    a_minus_b.union(both) == a
+  })
+}
+
+///|
+test "quickcheck: operations with self and empty are identities" {
+  @quickcheck.check((xs : Array[Int]) => {
+    let a = @sorted_set.from_array(xs)
+    let empty : @sorted_set.SortedSet[Int] = @sorted_set.from_array([])
+    a.difference(a).is_empty() &&
+    a.intersection(a) == a &&
+    a.symmetric_difference(a).is_empty() &&
+    a.difference(empty) == a &&
+    empty.difference(a).is_empty() &&
+    a.intersection(empty).is_empty() &&
+    a.symmetric_difference(empty) == a &&
+    empty.symmetric_difference(a) == a
+  })
+}
+
+///|
+test "quickcheck: results do not share nodes with the inputs" {
+  @quickcheck.check((input : (Array[Int], Array[Int])) => {
+    let (xs, ys) = input
+    let a = @sorted_set.from_array(xs)
+    let b = @sorted_set.from_array(ys)
+    let a0 = a.to_array()
+    let b0 = b.to_array()
+    let d = a.difference(b)
+    let i = a.intersection(b)
+    let s = a.symmetric_difference(b)
+    let d0 = d.to_array()
+    let i0 = i.to_array()
+    let s0 = s.to_array()
+    // mutating the inputs must not disturb the results
+    for y in ys {
+      a.add(y)
+    }
+    for x in xs {
+      b.remove(x)
+    }
+    guard d.to_array() == d0 && i.to_array() == i0 && s.to_array() == s0 else {
+      return false
+    }
+    // mutating the results must not disturb (fresh copies of) the inputs
+    let a2 = @sorted_set.from_array(xs)
+    let b2 = @sorted_set.from_array(ys)
+    for
+      result in [
+        a2.difference(b2),
+        a2.intersection(b2),
+        a2.symmetric_difference(b2),
+      ] {
+      for y in ys {
+        result.add(y)
+      }
+      for x in xs {
+        result.remove(x)
+      }
+    }
+    a2.to_array() == a0 && b2.to_array() == b0
+  })
+}
+
 ///|
 test "quickcheck: set operations obey membership laws" {
   @quickcheck.check((input : (Array[Int], Array[Int])) => {
diff --git a/sorted_set/set.mbt b/sorted_set/set.mbt
index 1368996d52..144d83204e 100644
--- a/sorted_set/set.mbt
+++ b/sorted_set/set.mbt
@@ -16,7 +16,7 @@
 
 ///|
 fn[V] new_sorted_set() -> SortedSet[V] {
-  { root: None, size: 0 }
+  { root: None, size: 0, }
 }
 
 ///|
@@ -24,7 +24,7 @@ fn[V] new_sorted_set() -> SortedSet[V] {
 #as_free_fn
 #owned(value)
 pub fn[V] SortedSet::singleton(value : V) -> SortedSet[V] {
-  { root: Some({ value, left: None, right: None, height: 1 }), size: 1 }
+  { root: Some({ value, left: None, right: None, height: 1, }), size: 1, }
 }
 
 ///|
@@ -59,7 +59,7 @@ pub fn[V : Compare] SortedSet::SortedSet(array : ArrayView[V]) -> SortedSet[V] {
 pub fn[V] SortedSet::copy(self : SortedSet[V]) -> SortedSet[V] {
   match self.root {
     None => new_sorted_set()
-    Some(_) => { root: copy_tree(self.root), size: self.size }
+    Some(_) => { root: copy_tree(self.root), size: self.size, }
   }
 }
 
@@ -84,7 +84,7 @@ fn[V] new_node(
   right? : Node[V]? = None,
   height? : Int = 1,
 ) -> Node[V] {
-  { value, left, right, height }
+  { value, left, right, height, }
 }
 
 ///|
@@ -94,7 +94,7 @@ fn[V] new_node_update_height(
   left~ : Node[V]?,
   right~ : Node[V]?,
 ) -> Node[V] {
-  { value, left, right, height: max(height(left), height(right)) + 1 }
+  { value, left, right, height: height(left).max(height(right)) + 1, }
 }
 
 // Manipulations
@@ -151,18 +151,45 @@ pub fn[V : Compare] SortedSet::contains(self : SortedSet[V], value : V) -> Bool
   }
 }
 
+///|
+/// Returns the stored value that compares equal to `value`, if any.
+fn[V : Compare] lookup(root : Node[V]?, value : V) -> V? {
+  for node = root {
+    match node {
+      None => break None
+      Some(n) => {
+        let compare_result = value.compare(n.value)
+        if compare_result == 0 {
+          break Some(n.value)
+        } else if compare_result < 0 {
+          continue n.left
+        } else {
+          continue n.right
+        }
+      }
+    }
+  }
+}
+
 ///|
 /// Returns a new set containing all elements from both sets.
 pub fn[V : Compare] SortedSet::union(
   self : SortedSet[V],
   src : SortedSet[V],
 ) -> SortedSet[V] {
+  // An element common to both sets is dropped exactly once, at the found
+  // pivot of `split_member`; counting those drops gives the result size
+  // arithmetically instead of re-traversing the merged tree.
+  let mut dups = 0
   fn aux(a : Node[V]?, b : Node[V]?) -> Node[V]? {
     match (a, b) {
       (Some(_), None) => a
       (None, Some(_)) => b
       (Some({ value: va, left: la, right: ra, .. }), Some(_)) => {
-        let (l, r) = split(b, va)
+        let { left: l, found, right: r, } = split_member(b, va)
+        if found {
+          dups += 1
+        }
         Some(join(aux(la, l), va, aux(ra, r)))
       }
       (None, None) => None
@@ -171,41 +198,72 @@ pub fn[V : Compare] SortedSet::union(
 
   match (self.root, src.root) {
     (Some(_), Some(_)) => {
-      let t1 = copy_tree(self.root)
-      let t2 = copy_tree(src.root)
-      let t = aux(t1, t2)
-      let mut ct = 0
-      let ret = { root: t, size: 0 }
-      // TODO: optimize this. Avoid counting the size of the set.
-      ret.each(_x => ct += 1)
-      ret.size = ct
-      ret
+      let t = aux(copy_tree(self.root), copy_tree(src.root))
+      { root: t, size: self.size + src.size - dups, }
     }
-    (Some(_), None) => { root: copy_tree(self.root), size: self.size }
-    (None, Some(_)) => { root: copy_tree(src.root), size: src.size }
+    (Some(_), None) => { root: copy_tree(self.root), size: self.size, }
+    (None, Some(_)) => { root: copy_tree(src.root), size: src.size, }
     (None, None) => new_sorted_set()
   }
 }
 
 ///|
-fn[V : Compare] split(root : Node[V]?, value : V) -> (Node[V]?, Node[V]?) {
+// `#valtype` keeps the split result stack-allocated on the native target:
+// `split_member` builds and returns one per visited node on every
+// set-operation pivot path.
+#valtype
+priv struct SplitResult[V] {
+  left : Node[V]?
+  found : Bool
+  right : Node[V]?
+}
+
+///|
+/// Splits a tree by a value into the elements less than it, a flag for
+/// whether it was present, and the elements greater than it.
+fn[V : Compare] split_member(root : Node[V]?, value : V) -> SplitResult[V] {
   match root {
-    None => (None, None)
+    None => { left: None, found: false, right: None, }
     Some(node) => {
       let comp = value.compare(node.value)
       if comp == 0 {
-        (node.left, node.right)
+        { left: node.left, found: true, right: node.right, }
       } else if comp < 0 {
-        let (l, r) = split(node.left, value)
-        (l, Some(join(r, node.value, node.right)))
+        let { left, found, right, } = split_member(node.left, value)
+        { left, found, right: Some(join(right, node.value, node.right)), }
       } else {
-        let (l, r) = split(node.right, value)
-        (Some(join(node.left, node.value, l)), r)
+        let { left, found, right, } = split_member(node.right, value)
+        { left: Some(join(node.left, node.value, left)), found, right, }
       }
     }
   }
 }
 
+///|
+/// Concatenates two trees where all elements in left < all elements in right.
+fn[V] concat(left : Node[V]?, right : Node[V]?) -> Node[V]? {
+  match (left, right) {
+    (None, _) => right
+    (_, None) => left
+    (Some(_), Some(r)) => {
+      let (min_val, rest) = remove_min(r)
+      Some(join(left, min_val, rest))
+    }
+  }
+}
+
+///|
+/// Removes the minimum value from a tree, returning it and the remaining tree.
+fn[V] remove_min(node : Node[V]) -> (V, Node[V]?) {
+  match node.left {
+    None => (node.value, node.right)
+    Some(left) => {
+      let (min_val, new_left) = remove_min(left)
+      (min_val, Some(join(new_left, node.value, node.right)))
+    }
+  }
+}
+
 ///|
 #owned(left, value, right)
 fn[V] join(left : Node[V]?, value : V, right : Node[V]?) -> Node[V] {
@@ -278,9 +336,44 @@ pub fn[V : Compare] SortedSet::difference(
   self : SortedSet[V],
   src : SortedSet[V],
 ) -> SortedSet[V] {
-  let ret = new_sorted_set()
-  self.each(x => if !src.contains(x) { ret.add(x) })
-  ret
+  match (self.root, src.root) {
+    (None, _) => new_sorted_set()
+    (_, None) => { root: copy_tree(self.root), size: self.size, }
+    (Some(_), Some(_)) => {
+      // The split-based merge below copies both trees, a Theta(n + m) floor
+      // that dwarfs the O(n log m) probe loop when `self` is far smaller
+      // than `src` (the result then also has at most `self.size` elements).
+      // The 1/16 cutoff keeps the probe loop ahead of the merge even in its
+      // worst case, where every probed element is inserted into the result.
+      if self.size < src.size / 16 {
+        let ret = new_sorted_set()
+        self.each(x => if !src.contains(x) { ret.add(x) })
+        return ret
+      }
+      // `found_count` ends up as the number of elements shared by both sets:
+      // `aux` takes every node of `a` as a split pivot, except in subtrees
+      // whose matching `b` fragment is empty and therefore shares no elements.
+      let mut found_count = 0
+      fn aux(a : Node[V]?, b : Node[V]?) -> Node[V]? {
+        match (a, b) {
+          (None, _) => None
+          (_, None) => a
+          (Some({ value: va, left: la, right: ra, .. }), _) => {
+            let { left: lb, found, right: rb, } = split_member(b, va)
+            if found {
+              found_count += 1
+              concat(aux(la, lb), aux(ra, rb))
+            } else {
+              Some(join(aux(la, lb), va, aux(ra, rb)))
+            }
+          }
+        }
+      }
+
+      let t = aux(copy_tree(self.root), copy_tree(src.root))
+      { root: t, size: self.size - found_count, }
+    }
+  }
 }
 
 ///|
@@ -315,10 +408,26 @@ pub fn[V : Compare] SortedSet::symmetric_difference(
   self : SortedSet[V],
   other : SortedSet[V],
 ) -> SortedSet[V] {
-  // TODO: Optimize this function to avoid creating two intermediate sets.
-  let set1 = self.difference(other)
-  let set2 = other.difference(self)
-  set1.union(set2)
+  // See `difference` for why `found_count` counts each shared element once.
+  let mut found_count = 0
+  fn aux(a : Node[V]?, b : Node[V]?) -> Node[V]? {
+    match (a, b) {
+      (None, _) => b
+      (_, None) => a
+      (Some({ value: va, left: la, right: ra, .. }), _) => {
+        let { left: lb, found, right: rb, } = split_member(b, va)
+        if found {
+          found_count += 1
+          concat(aux(la, lb), aux(ra, rb))
+        } else {
+          Some(join(aux(la, lb), va, aux(ra, rb)))
+        }
+      }
+    }
+  }
+
+  let t = aux(copy_tree(self.root), copy_tree(other.root))
+  { root: t, size: self.size + other.size - 2 * found_count, }
 }
 
 ///|
@@ -328,9 +437,49 @@ pub fn[V : Compare] SortedSet::intersection(
   self : SortedSet[V],
   src : SortedSet[V],
 ) -> SortedSet[V] {
-  let ret = new_sorted_set()
-  self.each(x => if src.contains(x) { ret.add(x) })
-  ret
+  match (self.root, src.root) {
+    (None, _) | (_, None) => new_sorted_set()
+    (Some(_), Some(_)) => {
+      // The intersection's key set is symmetric and fits in the smaller
+      // side, so when one side is far smaller, probing with it is
+      // O(min log max) and beats the Theta(n + m) tree copies of the
+      // split-based merge (see the analogous cutoff in `difference`).
+      // The result must still carry `self`'s stored representative of each
+      // shared value (`add` replaces compare-equal values, and the split
+      // path below emits `va` from `self`), so when the probing side is
+      // `src`, each match inserts the value looked up in `self`.
+      if self.size < src.size / 16 {
+        let ret = new_sorted_set()
+        self.each(x => if src.contains(x) { ret.add(x) })
+        return ret
+      }
+      if src.size < self.size / 16 {
+        let ret = new_sorted_set()
+        src.each(x => if lookup(self.root, x) is Some(v) { ret.add(v) })
+        return ret
+      }
+      // See `difference` for why `found_count` counts each shared element
+      // once.
+      let mut found_count = 0
+      fn aux(a : Node[V]?, b : Node[V]?) -> Node[V]? {
+        match (a, b) {
+          (None, _) | (_, None) => None
+          (Some({ value: va, left: la, right: ra, .. }), _) => {
+            let { left: lb, found, right: rb, } = split_member(b, va)
+            if found {
+              found_count += 1
+              Some(join(aux(la, lb), va, aux(ra, rb)))
+            } else {
+              concat(aux(la, lb), aux(ra, rb))
+            }
+          }
+        }
+      }
+
+      let t = aux(copy_tree(self.root), copy_tree(src.root))
+      { root: t, size: found_count, }
+    }
+  }
 }
 
 ///|
@@ -412,23 +561,22 @@ pub fn[V] SortedSet::eachi(
 /// Converts the set to an array.
 pub fn[V] SortedSet::to_array(self : SortedSet[V]) -> Array[V] {
   if self.size == 0 {
-    []
-  } else {
-    let padding = self.root.unwrap().value
-    let arr = Array::make(self.size, padding)
-    let mut i = 0
-    fn dfs(root : Node[V]?) -> Unit {
-      if root is Some(root) {
-        dfs(root.left)
-        arr[i] = root.value
-        i += 1
-        dfs(root.right)
-      }
+    return []
+  }
+  let arr = Array::unsafe_make_uninit(self.size)
+  let mut n = 0
+  fn dfs(root : Node[V]?) -> Unit {
+    if root is Some(root) {
+      dfs(root.left)
+      let v = root.value
+      arr.unsafe_set(n, v)
+      n += 1
+      dfs(root.right)
     }
-
-    dfs(self.root)
-    arr
   }
+
+  dfs(self.root)
+  arr
 }
 
 ///|
@@ -441,11 +589,11 @@ pub fn[V] SortedSet::iter(self : SortedSet[V]) -> Iter[V] {
     fn() {
       for x = curr_node {
         match x {
-          Some({ left: None, value, right, height: _ }) => {
+          Some({ left: None, value, right, height: _, }) => {
             curr_node = right
             break Some(value)
           }
-          Some({ left, value, right, height: _ }) => {
+          Some({ left, value, right, height: _, }) => {
             parents.push((value, right))
             continue left
           }
@@ -498,7 +646,7 @@ impl[T : Show] Show for Node[T] with fn output(self, logger) {
     }
   }
 
-  let x = { root: Some(self), size: count(Some(self)) }
+  let x = { root: Some(self), size: count(Some(self)), }
   logger.write_iter(x.iter())
 }
 
@@ -514,7 +662,7 @@ pub fn[V : Compare] SortedSet::range(
   let iter = Iter::new(() => {
     for x = curr_node {
       match x {
-        Some({ value, left, right, height: _ }) => {
+        Some({ value, left, right, height: _, }) => {
           let cmp_key_low = value.compare(low)
           let cmp_key_high = value.compare(high)
           if cmp_key_low < 0 {
@@ -566,7 +714,7 @@ fn[V] replace_root_with_min(root : Node[V], node : Node[V]) -> Node[V]? {
 
 ///|
 fn[V] Node::update_height(self : Node[V]) -> Unit {
-  self.height = 1 + max(height(self.left), height(self.right))
+  self.height = 1 + height(self.left).max(height(self.right))
 }
 
 ///|
@@ -879,41 +1027,39 @@ test "union" {
 
 ///|
 #warnings("-deprecated")
-test "split" {
-  let (l, r) = split(from_array([7, 2, 9, 4, 5, 6, 3, 8, 1]).root, 5)
+test "split_member" {
+  let { left: l, found, right: r, } = split_member(
+    from_array([7, 2, 9, 4, 5, 6, 3, 8, 1]).root,
+    5,
+  )
+  inspect(found, content="true")
   inspect(l, content="Some([1, 2, 3, 4])")
   inspect(r, content="Some([6, 7, 8, 9])")
-  let (l, r) = split(from_array([7, 2, 9, 4, 5, 6, 3, 8, 1]).root, 0)
+  let { left: l, found, right: r, } = split_member(
+    from_array([7, 2, 9, 4, 5, 6, 3, 8, 1]).root,
+    0,
+  )
+  inspect(found, content="false")
   inspect(l, content="None")
   inspect(r, content="Some([1, 2, 3, 4, 5, 6, 7, 8, 9])")
-  let (l, r) = split(from_array([7, 2, 9, 4, 5, 6, 3, 8, 1]).root, 10)
+  let { left: l, found, right: r, } = split_member(
+    from_array([7, 2, 9, 4, 5, 6, 3, 8, 1]).root,
+    10,
+  )
+  inspect(found, content="false")
   inspect(l, content="Some([1, 2, 3, 4, 5, 6, 7, 8, 9])")
   inspect(r, content="None")
-  let (l, r) = split(from_array([7, 2, 9, 4, 5, 6, 3, 8, 1]).root, 4)
+  let { left: l, found, right: r, } = split_member(
+    from_array([7, 2, 9, 4, 5, 6, 3, 8, 1]).root,
+    4,
+  )
+  inspect(found, content="true")
   inspect(l, content="Some([1, 2, 3])")
   inspect(r, content="Some([5, 6, 7, 8, 9])")
-  let (l, r) = split(from_array([]).root, 7)
+  let { left: l, found, right: r, } = split_member(from_array([]).root, 7)
+  inspect(found, content="false")
   inspect(l, content="None")
   inspect(r, content="None")
-  let (l, r) = split(
-    from_array([
-      0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21,
-      22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40,
-      41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59,
-      60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78,
-      79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97,
-      98, 99, 100,
-    ]).root,
-    50,
-  )
-  inspect(
-    l,
-    content="Some([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49])",
-  )
-  inspect(
-    r,
-    content="Some([51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100])",
-  )
 }
 
 ///|
@@ -924,7 +1070,7 @@ test "join" {
     r : SortedSet[Int],
   ) -> Array[Int] {
     let root = join(l.root, value, r.root)
-    ({ root: Some(root), size: l.size + r.size + 1 } : SortedSet[Int]).to_array()
+    ({ root: Some(root), size: l.size + r.size + 1, } : SortedSet[Int]).to_array()
   }
 
   let l = from_array([13, 8, 17, 1, 11, 15, 25, 6])
diff --git a/sorted_set/set_test.mbt b/sorted_set/set_test.mbt
index 69a1264613..bdd35e4c27 100644
--- a/sorted_set/set_test.mbt
+++ b/sorted_set/set_test.mbt
@@ -99,7 +99,7 @@ test "each" {
 test "eachi" {
   let set = @sorted_set.from_array([7, 2, 9, 4, 5, 6, 3, 8, 1])
   let result = StringBuilder(size_hint=10)
-  set.eachi((i, x) => result.write_string("[\{i}-\{x}]"))
+  set.eachi((i, x) => result <+ "[\{i}-\{x}]")
   inspect(
     result.to_string(),
     content="[0-1][1-2][2-3][3-4][4-5][5-6][6-7][7-8][8-9]",
@@ -380,13 +380,13 @@ test "equal" {
 ///|
 test "from_iter multiple elements iter" {
   debug_inspect(
-    @sorted_set.from_iter([1, 2, 3].iter()),
+    @sorted_set.from_iter([|1, 2, 3|]),
     content=(
       #|
     ),
   )
   debug_inspect(
-    @sorted_set.from_iter([1, 1, 1].iter()),
+    @sorted_set.from_iter([|1, 1, 1|]),
     content=(
       #|
     ),
@@ -396,7 +396,7 @@ test "from_iter multiple elements iter" {
 ///|
 test "from_iter single element iter" {
   debug_inspect(
-    @sorted_set.from_iter([1].iter()),
+    @sorted_set.from_iter([|1|]),
     content=(
       #|
     ),
@@ -405,7 +405,7 @@ test "from_iter single element iter" {
 
 ///|
 test "from_iter empty iter" {
-  let pq : @sorted_set.SortedSet[Int] = @sorted_set.from_iter(Iter::empty())
+  let pq : @sorted_set.SortedSet[Int] = @sorted_set.from_iter([||])
   debug_inspect(
     pq,
     content=(
@@ -506,3 +506,76 @@ test "@sorted_set.symmetric_difference/identical" {
     ),
   )
 }
+
+///|
+/// Compare/Eq on `Keyed` look only at `key`; `payload` records which set a
+/// stored representative came from.
+priv struct Keyed {
+  key : Int
+  payload : Int
+}
+
+///|
+impl Eq for Keyed with fn equal(self, other) {
+  self.key == other.key
+}
+
+///|
+impl Compare for Keyed with fn compare(self, other) {
+  self.key.compare(other.key)
+}
+
+///|
+fn keyed_set(keys : Array[Int], payload : Int) -> @sorted_set.SortedSet[Keyed] {
+  let s : @sorted_set.SortedSet[Keyed] = SortedSet([])
+  for k in keys {
+    s.add({ key: k, payload, })
+  }
+  s
+}
+
+///|
+test "intersection and difference preserve self's stored representatives" {
+  // src far smaller (probe fallback runs over src): the result must still
+  // carry self's stored values
+  let big_self = keyed_set(Array::makei(40, i => i), 1)
+  let small_src = keyed_set([5], 2)
+  let out = big_self.intersection(small_src).to_array()
+  assert_true(out.length() == 1 && out[0].key == 5 && out[0].payload == 1)
+
+  // self far smaller (probe fallback runs over self)
+  let small_self = keyed_set([5], 1)
+  let big_src = keyed_set(Array::makei(40, i => i), 2)
+  let out2 = small_self.intersection(big_src).to_array()
+  assert_true(out2.length() == 1 && out2[0].payload == 1)
+
+  // comparable sizes (split-based merge)
+  let a = keyed_set(Array::makei(20, i => i), 1)
+  let b = keyed_set(Array::makei(20, i => i + 10), 2)
+  let shared = a.intersection(b).to_array()
+  assert_true(shared.length() == 10)
+  for v in shared {
+    assert_true(v.payload == 1)
+  }
+
+  // difference probe fallback keeps self's values
+  let lone = keyed_set([5], 1)
+  let far = keyed_set(Array::makei(40, i => i + 100), 2)
+  let out3 = lone.difference(far).to_array()
+  assert_true(out3.length() == 1 && out3[0].payload == 1)
+}
+
+///|
+test "union size is exact for overlapping, disjoint and identical sets" {
+  let a = @sorted_set.from_array([1, 2, 3, 4, 5])
+  let b = @sorted_set.from_array([4, 5, 6, 7])
+  inspect(a.union(b).length(), content="7")
+  inspect(b.union(a).length(), content="7")
+  inspect(a.union(@sorted_set.from_array([10, 11])).length(), content="7")
+  inspect(a.union(a).length(), content="5")
+  // size must agree with an actual element count
+  let u = a.union(b)
+  let mut n = 0
+  u.each(_x => n = n + 1)
+  inspect(u.length() == n, content="true")
+}
diff --git a/sorted_set/sorted_set_bench_test.mbt b/sorted_set/sorted_set_bench_test.mbt
new file mode 100644
index 0000000000..a5e9a0b60e
--- /dev/null
+++ b/sorted_set/sorted_set_bench_test.mbt
@@ -0,0 +1,35 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+let sorted_set_bench_n = 50000
+
+///|
+/// `to_array` materializes the in-order traversal. The Int and Ref variants
+/// are both kept because the element type decides whether the per-element
+/// store carries reference-counting traffic.
+test "bench SortedSet::to_array Int n=50000" (it : @bench.T) {
+  let s = @sorted_set.from_array(
+    Array::makei(sorted_set_bench_n, i => i * 1103515245),
+  )
+  it.bench(fn() { it.keep(s.to_array()) })
+}
+
+///|
+test "bench SortedSet::to_array Ref n=50000" (it : @bench.T) {
+  let s = @sorted_set.from_array(
+    Array::makei(sorted_set_bench_n, i => "value\{i}"),
+  )
+  it.bench(fn() { it.keep(s.to_array()) })
+}
diff --git a/sorted_set/types.mbt b/sorted_set/types.mbt
index 32f40a80ec..a3e2b736d1 100644
--- a/sorted_set/types.mbt
+++ b/sorted_set/types.mbt
@@ -14,7 +14,9 @@
 
 // This package implements the set data structure.
 // The types stored in set need to implement the Compare trait.
-// All operations over sets are purely applicative (no side-effects).
+// The set is mutable: `add` and `remove` update the set in place, while the set
+// operations (union, intersection, difference, symmetric_difference) return new
+// sets and leave their inputs unmodified.
 
 ///|
 struct SortedSet[V] {
diff --git a/sorted_set/utils.mbt b/sorted_set/utils.mbt
index 62e3dd023d..6414cc2b99 100644
--- a/sorted_set/utils.mbt
+++ b/sorted_set/utils.mbt
@@ -17,15 +17,6 @@ impl[V : Eq] Eq for Node[V] with fn equal(self, other) {
   self.value == other.value
 }
 
-///|
-fn max(x : Int, y : Int) -> Int {
-  if x > y {
-    x
-  } else {
-    y
-  }
-}
-
 ///|
 fn[V] height(node : Node[V]?) -> Int {
   match node {
diff --git a/strconv/README.mbt.md b/strconv/README.mbt.md
deleted file mode 100644
index 9d56a1d4dc..0000000000
--- a/strconv/README.mbt.md
+++ /dev/null
@@ -1,83 +0,0 @@
-# Strconv
-
-Deprecated compatibility package. Use the matching APIs in `@string` instead:
-`parse_bool`, `parse_int`, `parse_int64`, `parse_uint`, `parse_uint64`,
-`parse_double`, and `from_str`.
-
-## Parsing Integers
-
-Parse integers in various bases:
-
-```mbt check
-///|
-#warnings("-deprecated")
-test "parse_int" {
-  inspect(@strconv.parse_int("42"), content="42")
-  inspect(@strconv.parse_int("101", base=2), content="5")
-  inspect(@strconv.parse_int("ff", base=16), content="255")
-}
-```
-
-Parse 64-bit integers and unsigned integers:
-
-```mbt check
-///|
-#warnings("-deprecated")
-test "parse_int64_uint" {
-  inspect(
-    @strconv.parse_int64("9223372036854775807"),
-    content="9223372036854775807",
-  )
-  inspect(@strconv.parse_uint("42"), content="42")
-  inspect(
-    @strconv.parse_uint64("18446744073709551615"),
-    content="18446744073709551615",
-  )
-}
-```
-
-## Parsing Other Types
-
-```mbt check
-///|
-#warnings("-deprecated")
-test "parse_other" {
-  inspect(@strconv.parse_bool("true"), content="true")
-  inspect(@strconv.parse_double("3.14"), content="3.14")
-}
-```
-
-## FromStr Trait
-
-Types implementing `FromStr` can be parsed using `from_str`.
-Use `@string.from_str` in new code.
-
-```mbt check
-///|
-#warnings("-deprecated")
-test "from_str" {
-  let i : Int = @strconv.from_str("123")
-  inspect(i, content="123")
-  let b : Bool = @strconv.from_str("false")
-  inspect(b, content="false")
-  let d : Double = @strconv.from_str("2.718")
-  inspect(d, content="2.718")
-}
-```
-
-`FromStr` is implemented for `Bool`, `Int`, `Int64`, `UInt`, `UInt64`, and `Double`.
-Prefer `@string.FromStr` in new code.
-
-## Error Handling
-
-Parse functions raise `StrConvError` on invalid input.
-Use the `@string` versions in new code.
-
-```mbt check
-///|
-#warnings("-deprecated")
-test "error_handling" {
-  let result : Result[Int, _] = try? @strconv.parse_int("abc")
-  inspect(result is Err(_), content="true")
-}
-```
diff --git a/strconv/additional_coverage_test.mbt b/strconv/additional_coverage_test.mbt
deleted file mode 100644
index 915df48d9f..0000000000
--- a/strconv/additional_coverage_test.mbt
+++ /dev/null
@@ -1,82 +0,0 @@
-// Copyright 2026 International Digital Economy Academy
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-///|
-#warnings("-deprecated")
-test "parse_uint64 overflow check" {
-  let largest_uint64 = "18446744073709551615" // Maximum UInt64 value
-  let result = @strconv.parse_uint64(largest_uint64)
-  inspect(result, content="18446744073709551615")
-
-  // Test overflow with very large value
-  try {
-    let overflow_val = "18446744073709551616" // One more than max UInt64
-    let _ = @strconv.parse_uint64(overflow_val)
-    fail("Expected range error for overflow value")
-  } catch {
-    @strconv.StrConvError(err) => inspect(err, content="value out of range")
-    _ => fail("Expected StrConvError but got different error")
-  }
-
-  // Test overflow with large base 16 value
-  try {
-    let overflow_hex = "ffffffffffffffff1" // Larger than max UInt64 in hex
-    let _ = @strconv.parse_uint64(overflow_hex, base=16)
-    fail("Expected range error for overflow hex value")
-  } catch {
-    @strconv.StrConvError(err) => inspect(err, content="value out of range")
-    _ => fail("Expected StrConvError but got different error")
-  }
-}
-
-///|
-#warnings("-deprecated")
-test "from_string forwarding" {
-  let value : Int = @strconv.FromStr::from_str("42")
-  inspect(value, content="42")
-}
-
-///|
-#warnings("-deprecated")
-test "from_string deprecated bridge" {
-  let value : Int = @strconv.FromStr::from_string("7")
-  inspect(value, content="7")
-}
-
-///|
-#warnings("-deprecated")
-test "parse_double slow path with plus and underscores" {
-  let value = @strconv.parse_double("+0_0000_0000_0000_0000_0000_12345")
-  assert_eq(value, 12345.0)
-}
-
-///|
-#warnings("-deprecated")
-test "parse_double many digits with leading zero" {
-  let value = @strconv.parse_double("0.00000000000000000000012345")
-  assert_true(value > 0.0)
-  assert_true(value < 1.0e-15)
-}
-
-///|
-#warnings("-deprecated")
-test "decimal shift truncation path" {
-  let prefix = "1" + String::make(299, '0')
-  let suffix = String::make(521, '9')
-  let s = prefix + "." + suffix
-  let decimal = @strconv.parse_decimal(s)
-  decimal.shift(59)
-  let result : Result[Double, Error] = try? decimal.to_double()
-  assert_true(result is Err(_))
-}
diff --git a/strconv/bool.mbt b/strconv/bool.mbt
deleted file mode 100644
index c3d117ae02..0000000000
--- a/strconv/bool.mbt
+++ /dev/null
@@ -1,50 +0,0 @@
-// Copyright 2026 International Digital Economy Academy
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-///|
-/// Parse a string and return the represented boolean value or an error.
-#deprecated("use `@string.parse_bool` instead", skip_current_package=true)
-pub fn parse_bool(str : StringView) -> Bool raise StrConvError {
-  lexmatch str with longest {
-    re"^(true|TRUE|True|t|T|1)$" => true
-    re"^(false|FALSE|False|f|F|0)$" => false
-    _ => syntax_err()
-  }
-}
-
-///|
-test "parse_bool" {
-  let tests : Array[(String, Result[Bool, String])] = [
-    ("", Err(syntax_err_str)),
-    ("zutomayo", Err(syntax_err_str)),
-    ("0", Ok(false)),
-    ("f", Ok(false)),
-    ("F", Ok(false)),
-    ("FALSE", Ok(false)),
-    ("false", Ok(false)),
-    ("False", Ok(false)),
-    ("1", Ok(true)),
-    ("t", Ok(true)),
-    ("T", Ok(true)),
-    ("TRUE", Ok(true)),
-    ("true", Ok(true)),
-    ("True", Ok(true)),
-  ]
-  for t in tests {
-    assert_true(
-      (Result::Ok(parse_bool(t.0)) catch { StrConvError(err) => Err(err) }) ==
-      t.1,
-    )
-  }
-}
diff --git a/strconv/decimal.mbt b/strconv/decimal.mbt
deleted file mode 100644
index 13f6f471fc..0000000000
--- a/strconv/decimal.mbt
+++ /dev/null
@@ -1,760 +0,0 @@
-// Copyright 2026 International Digital Economy Academy
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-///|
-/// Maximum shift that we can do in one pass without overflow.
-/// We have to be able to accommodate 9 << max_shift.
-let max_shift = 59
-
-///|
-/// Decimal power of ten to binary power of two.
-/// The Ith entry (starting at I = 0) is the largest power of 2 less than (10 ** I)
-let powtab : ReadOnlyArray[Int] = [
-  1, 3, 6, 9, 13, 16, 19, 23, 26, 29, 33, 36, 39, 43, 46, 49, 53, 56, 59,
-]
-
-///|
-fn Decimal::new_priv() -> Decimal {
-  {
-    digits: FixedArray::make(800, b'\x00'),
-    digits_num: 0,
-    decimal_point: 0,
-    negative: false,
-    truncated: false,
-    overflowed: 0,
-  }
-}
-
-///|
-fn Decimal::from_int64_priv(v : Int64) -> Decimal {
-  let d = Decimal::new_priv()
-  d.assign(v)
-  d
-}
-
-///|
-fn parse_decimal_priv(str : StringView) -> Decimal raise StrConvError {
-  parse_decimal_from_view(str)
-}
-
-///|
-fn parse_decimal_from_view(str : StringView) -> Decimal raise StrConvError {
-  let d = Decimal::new_priv()
-  let mut has_dp = false
-  let mut has_digits = false
-  // read sign
-  let rest = match str {
-    ['-', .. rest] => {
-      d.negative = true
-      rest
-    }
-    ['+', .. rest] => rest
-    _ => str
-  }
-  // read digits
-  let rest = for s = rest {
-    match s {
-      ['_', .. rest] => continue rest
-      ['.', .. rest] => {
-        guard !has_dp else { syntax_err() }
-        has_dp = true
-        d.decimal_point = d.digits_num
-        continue rest
-      }
-      ['0'..='9' as digit, .. rest] => {
-        has_digits = true
-        if digit == '0' && d.digits_num == 0 {
-          // ignore leading zeros
-          d.decimal_point -= 1
-          continue rest
-        }
-        if d.digits_num < d.digits.length() {
-          d.digits[d.digits_num] = (digit.to_int() - '0').to_byte()
-          d.digits_num += 1
-        } else {
-          if !has_dp {
-            d.overflowed += 1
-          }
-          if digit != '0' {
-            d.truncated = true
-          }
-        }
-        continue rest
-      }
-      rest => break rest
-    }
-  }
-  guard has_digits else { syntax_err() }
-  if !has_dp {
-    d.decimal_point = d.digits_num
-  }
-  // read exponent part
-  let rest = match rest {
-    ['e' | 'E', .. rest] => {
-      let mut exp_sign = 1
-      let rest = match rest {
-        ['+', .. rest] => rest
-        ['-', .. rest] => {
-          exp_sign = -1
-          rest
-        }
-        rest => rest
-      }
-      guard rest is ['0'..='9', ..] else { syntax_err() }
-      // Clamp the exponent accumulator so huge exponents cannot overflow `Int`.
-      // The clamp lands `decimal_point` exactly at the boundary that
-      // `to_double_priv` already treats as overflow / underflow:
-      //   - `decimal_point > 310`  -> range_err (Double max ~1.8e+308, so
-      //     anything past 1e+310 is unreachable). Hence the `+311` ceiling.
-      //   - `decimal_point < -330` -> underflow to 0 (Double min subnormal
-      //     ~5e-324, so anything past 1e-331 underflows). Hence the `-331`
-      //     floor, expressed as `decimal_point + 331` for the negative-exp arm.
-      // Once `exp` reaches `exp_limit`, additional digits cannot change the
-      // result, so we stop accumulating before `Int` can wrap.
-      let effective_dp = d.decimal_point + d.overflowed
-      let exp_limit = if exp_sign > 0 {
-        if effective_dp < 311 {
-          311 - effective_dp
-        } else {
-          0
-        }
-      } else if effective_dp > -331 {
-        effective_dp + 331
-      } else {
-        0
-      }
-      let mut exp = 0
-      let rest = for s = rest {
-        match s {
-          ['_', .. rest] => continue rest
-          ['0'..='9' as digit, .. rest] => {
-            if exp < exp_limit {
-              exp = exp * 10 + (digit.to_int() - '0')
-              if exp > exp_limit {
-                exp = exp_limit
-              }
-            }
-            continue rest
-          }
-          rest => break rest
-        }
-      }
-      d.decimal_point += exp_sign * exp
-      rest
-    }
-    rest => rest
-  }
-  // finish
-  guard rest is [] else { syntax_err() }
-  d.trim()
-  d
-}
-
-///|
-fn Decimal::to_double_priv(self : Decimal) -> Double raise StrConvError {
-  let mut exponent = 0
-  let mut mantissa = 0L
-  // check the underflow and overflow
-  // Double: 1.79769e+308 (10^308) - 2.22507e-308 (10^-308)
-  let effective_dp = self.decimal_point + self.overflowed
-
-  if self.digits_num == 0 || effective_dp < -330 {
-    // zero
-    mantissa = 0
-    exponent = double_info.bias
-    let bits = assemble_bits(mantissa, exponent, self.negative)
-    return bits.reinterpret_as_double()
-  }
-  if self.decimal_point > 310 {
-    // overflow
-    range_err()
-  }
-
-  // Incorporate overflowed digits into decimal_point for correct shift normalization.
-  // The overflowed digits are part of the coefficient's magnitude even though they
-  // were discarded from the internal buffer. This must be done before the shift
-  // loops so that the decimal point position reflects the true value.
-  self.decimal_point += self.overflowed
-
-  // scale by powers of 2 until in range [0.5 .. 1]
-  // right shift
-  while self.decimal_point > 0 {
-    let mut n = 0
-    if self.decimal_point >= powtab.length() {
-      n = 60
-    } else {
-      n = powtab[self.decimal_point]
-    }
-    self.shift_priv(-n)
-    exponent += n
-  }
-  // left shift
-  while self.decimal_point < 0 ||
-        (self.decimal_point == 0 && self.digits[0].to_int() < 5) {
-    let mut n = 0
-    if -self.decimal_point >= powtab.length() {
-      n = 60
-    } else {
-      n = powtab[-self.decimal_point]
-    }
-    self.shift_priv(n)
-    exponent -= n
-  }
-
-  // normalized floating point range is [1, 2), current [0.5, 1)
-  // should decrease the exponent by 1
-  exponent -= 1
-
-  // minimum representable exponent is bias + 1
-  // if the exponent is smaller, move it up and shift decimal accordingly
-  if exponent < double_info.bias + 1 {
-    let n = double_info.bias + 1 - exponent
-    self.shift_priv(-n)
-    exponent += n
-  }
-  if exponent - double_info.bias >= (1 << double_info.exponent_bits) - 1 {
-    // overflow
-    range_err()
-  }
-
-  // multiply by (2 ** precision) and round to get mantissa
-  // extract mantissa_bits + 1 bits
-  self.shift_priv(double_info.mantissa_bits + 1)
-  mantissa = self.rounded_integer()
-
-  // rounding might have added a bit, shift down.
-  if mantissa == 2L << double_info.mantissa_bits {
-    mantissa = mantissa >> 1
-    exponent += 1
-    if exponent - double_info.bias >= (1 << double_info.exponent_bits) - 1 {
-      // overflow
-      range_err()
-    }
-  }
-
-  // denormalized
-  if (mantissa & (1L << double_info.mantissa_bits)) == 0L {
-    exponent = double_info.bias
-  }
-
-  // combining the 52 mantissa bits with the 11 exponent bits and 1 sign bit
-  let bits = assemble_bits(mantissa, exponent, self.negative)
-  bits.reinterpret_as_double()
-}
-
-///|
-fn Decimal::shift_priv(self : Decimal, s : Int) -> Unit {
-  if self.digits_num == 0 {
-    return
-  }
-  let mut s = s
-  if s > 0 {
-    while s > max_shift {
-      self.left_shift(max_shift)
-      s -= max_shift
-    }
-    self.left_shift(s)
-  }
-  if s < 0 {
-    while s < -max_shift {
-      self.right_shift(max_shift)
-      s += max_shift
-    }
-    self.right_shift(-s)
-  }
-}
-
-///|
-fn assemble_bits(mantissa : Int64, exponent : Int, negative : Bool) -> Int64 {
-  let biased_exp = exponent - double_info.bias
-  // set the mantissa bits
-  let mut bits = mantissa & ((1L << double_info.mantissa_bits) - 1L)
-  // set the exponent bits
-  let exp_bits = (biased_exp & ((1 << double_info.exponent_bits) - 1)).to_int64()
-  bits = bits | (exp_bits << double_info.mantissa_bits)
-  // set the sign bit
-  if negative {
-    bits = bits | (1L << double_info.mantissa_bits << double_info.exponent_bits)
-  }
-  bits
-}
-
-///|
-/// Extract a rounded 64bit integer
-fn Decimal::rounded_integer(self : Decimal) -> Int64 {
-  if self.decimal_point > 20 {
-    return 0xFFFFFFFFFFFFFFFFL
-  }
-  let (n, i) = for n = 0L, i = 0; i < self.decimal_point && i < self.digits_num; {
-    continue n * 10L + self.digits[i].to_int64(), i + 1
-  } nobreak {
-    (n, i)
-  }
-  let n = for n = n, i = i; i < self.decimal_point; {
-    continue n * 10L, i + 1
-  } nobreak {
-    n
-  }
-  if self.should_round_up(self.decimal_point) {
-    n + 1L
-  } else {
-    n
-  }
-}
-
-///|
-/// Check if truncate at d digits should round up.
-/// Typically, when rounding a decimal fraction to an integer, 7.3 rounds down to 7 and 7.6 rounds up to 8. 
-/// Rounding numbers like 7.5, half-way between two integers, will round to even.
-fn Decimal::should_round_up(self : Decimal, d : Int) -> Bool {
-  if d < 0 || d >= self.digits_num {
-    return false
-  }
-  if self.digits[d].to_int() == 5 && d + 1 == self.digits_num {
-    // half-way between two integers
-    // if truncated, the real value is higher than stored value, round up.
-    if self.truncated {
-      return true
-    }
-    // round to even
-    return d > 0 && self.digits[d - 1].to_int() % 2 != 0
-  }
-  // normal case
-  self.digits[d].to_int() >= 5
-}
-
-///|
-/// Assign a Int64 value to decimal.
-fn Decimal::assign(self : Decimal, v : Int64) -> Unit {
-  let buf = FixedArray::make(24, b'\x00')
-
-  // write value to buf
-  let n = for n = 0, v = v; v > 0; {
-    let v1 = v / 10
-    buf[n] = (v - v1 * 10).to_byte()
-    continue n + 1, v1
-  } nobreak {
-    n
-  }
-
-  // reverse the buf
-  self.digits_num = 0
-  for i in n>..0 {
-    self.digits[self.digits_num] = buf[i]
-    self.digits_num += 1
-  }
-  self.decimal_point = self.digits_num
-  self.trim()
-}
-
-///|
-/// Binary shift right by s bits.
-fn Decimal::right_shift(self : Decimal, s : Int) -> Unit {
-  let mut read_index = 0
-  let mut write_index = 0
-
-  // read enough leading digits to start a shift
-  let mut acc = 0UL
-  while acc >> s == 0 {
-    if read_index >= self.digits_num {
-      while acc >> s == 0 {
-        acc *= 10
-        read_index += 1
-      }
-      break
-    }
-    let d = self.digits[read_index]
-    acc = acc * 10 + d.to_int64().reinterpret_as_uint64()
-    read_index += 1
-  }
-  self.decimal_point -= read_index - 1
-
-  // read a digit and output a shifted digit
-  let mask = (1UL << s) - 1
-  while read_index < self.digits_num {
-    // output (acc >> s)
-    let out = acc >> s
-    self.digits[write_index] = out.to_byte()
-    write_index += 1
-    // contract
-    acc = acc & mask
-    // expand
-    let d = self.digits[read_index]
-    acc = acc * 10 + d.to_int64().reinterpret_as_uint64()
-    read_index += 1
-  }
-
-  // output extra digits
-  while acc > 0 {
-    let out = acc >> s
-    if write_index < self.digits.length() {
-      self.digits[write_index] = out.to_byte()
-      write_index += 1
-    } else if out > 0 {
-      self.truncated = true
-    }
-    acc = acc & mask
-    acc *= 10
-  }
-
-  // update and trim
-  self.digits_num = write_index
-  self.trim()
-}
-
-///|
-/// Cheat sheet for left shift: table indexed by shift count giving
-/// number of new digits that will be introduced by that shift.
-/// left_shift_cheats[s] = (new digits num, (5 ** s))
-let left_shift_cheats : ReadOnlyArray[(Int, String)] = [
-  (0, ""),
-  (1, "5"), // * 2
-  (1, "25"), // * 4
-  (1, "125"), // * 8
-  (2, "625"), // * 16
-  (2, "3125"), // * 32
-  (2, "15625"), // * 64
-  (3, "78125"), // * 128
-  (3, "390625"), // * 256
-  (3, "1953125"), // * 512
-  (4, "9765625"), // * 1024
-  (4, "48828125"), // * 2048
-  (4, "244140625"), // * 4096
-  (4, "1220703125"), // * 8192
-  (5, "6103515625"), // * 16384
-  (5, "30517578125"), // * 32768
-  (5, "152587890625"), // * 65536
-  (6, "762939453125"), // * 131072
-  (6, "3814697265625"), // * 262144
-  (6, "19073486328125"), // * 524288
-  (7, "95367431640625"), // * 1048576
-  (7, "476837158203125"), // * 2097152
-  (7, "2384185791015625"), // * 4194304
-  (7, "11920928955078125"), // * 8388608
-  (8, "59604644775390625"), // * 16777216
-  (8, "298023223876953125"), // * 33554432
-  (8, "1490116119384765625"), // * 67108864
-  (9, "7450580596923828125"), // * 134217728
-  (9, "37252902984619140625"), // * 268435456
-  (9, "186264514923095703125"), // * 536870912
-  (10, "931322574615478515625"), // * 1073741824
-  (10, "4656612873077392578125"), // * 2147483648
-  (10, "23283064365386962890625"), // * 4294967296
-  (10, "116415321826934814453125"), // * 8589934592
-  (11, "582076609134674072265625"), // * 17179869184
-  (11, "2910383045673370361328125"), // * 34359738368
-  (11, "14551915228366851806640625"), // * 68719476736
-  (12, "72759576141834259033203125"), // * 137438953472
-  (12, "363797880709171295166015625"), // * 274877906944
-  (12, "1818989403545856475830078125"), // * 549755813888
-  (13, "9094947017729282379150390625"), // * 1099511627776
-  (13, "45474735088646411895751953125"), // * 2199023255552
-  (13, "227373675443232059478759765625"), // * 4398046511104
-  (13, "1136868377216160297393798828125"), // * 8796093022208
-  (14, "5684341886080801486968994140625"), // * 17592186044416
-  (14, "28421709430404007434844970703125"), // * 35184372088832
-  (14, "142108547152020037174224853515625"), // * 70368744177664
-  (15, "710542735760100185871124267578125"), // * 140737488355328
-  (15, "3552713678800500929355621337890625"), // * 281474976710656
-  (15, "17763568394002504646778106689453125"), // * 562949953421312
-  (16, "88817841970012523233890533447265625"), // * 1125899906842624
-  (16, "444089209850062616169452667236328125"), // * 2251799813685248
-  (16, "2220446049250313080847263336181640625"), // * 4503599627370496
-  (16, "11102230246251565404236316680908203125"), // * 9007199254740992
-  (17, "55511151231257827021181583404541015625"), // * 18014398509481984
-  (17, "277555756156289135105907917022705078125"), // * 36028797018963968
-  (17, "1387778780781445675529539585113525390625"), // * 72057594037927936
-  (18, "6938893903907228377647697925567626953125"), // * 144115188075855872
-  (18, "34694469519536141888238489627838134765625"), // * 288230376151711744
-  (18, "173472347597680709441192448139190673828125"), // * 576460752303423488
-  (19, "867361737988403547205962240695953369140625"), // * 1152921504606846976
-]
-
-///|
-/// Lookup the cheat sheet to find the new digits num.
-fn Decimal::new_digits(self : Decimal, s : Int) -> Int {
-  let new_digits = left_shift_cheats[s].0
-  let cheat_num = left_shift_cheats[s].1
-  // check if the leading digits lexicographically less than cheats num.
-  let less = for i, code_unit in cheat_num.code_units() {
-    if i >= self.digits_num {
-      break true
-    }
-    let d = code_unit.to_int() - '0'
-    if self.digits[i].to_int() != d {
-      break self.digits[i].to_int() < d
-    }
-  } nobreak {
-    false
-  }
-  if less {
-    new_digits - 1
-  } else {
-    new_digits
-  }
-}
-
-///|
-/// Binary shift left by s bits.
-fn Decimal::left_shift(self : Decimal, s : Int) -> Unit {
-  let new_digits = self.new_digits(s)
-  // from right to left
-  let mut read_index = self.digits_num
-  let mut write_index = self.digits_num + new_digits
-
-  // read a digit and output a shifted digit
-  let mut acc = 0L
-  read_index -= 1
-  while read_index >= 0 {
-    let d = self.digits[read_index].to_int64()
-    acc += d << s
-    let quo = acc / 10L
-    let rem = (acc - quo * 10L).to_int()
-    write_index -= 1
-    if write_index < self.digits.length() {
-      self.digits[write_index] = rem.to_byte()
-    } else if rem != 0 {
-      self.truncated = true
-    }
-    acc = quo
-    read_index -= 1
-  }
-
-  // output extra digits
-  while acc > 0L {
-    let quo = acc / 10L
-    let rem = (acc - 10L * quo).to_int()
-    write_index -= 1
-    if write_index < self.digits.length() {
-      self.digits[write_index] = rem.to_byte()
-    } else if rem != 0 {
-      self.truncated = true
-    }
-    acc = quo
-  }
-
-  // update and trim
-  self.digits_num += new_digits
-  if self.digits_num > self.digits.length() {
-    self.digits_num = self.digits.length()
-  }
-  self.decimal_point += new_digits
-  self.trim()
-}
-
-///|
-/// Trim trailing zeros.
-fn Decimal::trim(self : Decimal) -> Unit {
-  while self.digits_num > 0 && self.digits[self.digits_num - 1] == 0 {
-    self.digits_num -= 1
-  }
-  if self.digits_num == 0 {
-    self.decimal_point = 0
-  }
-}
-
-///|
-pub impl Show for Decimal with fn output(self, logger) {
-  if self.digits_num == 0 {
-    logger.write_char('0')
-    return
-  }
-  if self.decimal_point <= 0 {
-    // zeros filling between the decimal point and the digits
-    logger.write_string("0.")
-    for _ in 0..<-self.decimal_point {
-      logger.write_char('0')
-    }
-    for i in 0.. 20" {
-  // This test should trigger the uncovered line 252 in rounded_integer
-  // We need to create a decimal that will have decimal_point > 20 after shifting
-  // but not trigger the early overflow check in to_double_priv
-
-  // Create a decimal manually to bypass the early overflow checks
-  let decimal = Decimal::new_priv()
-  decimal.negative = false
-  decimal.decimal_point = 25 // This is > 20 but < 310
-  decimal.digits_num = 1
-  decimal.digits[0] = (1 : Int).to_byte()
-  decimal.truncated = false
-
-  // Call rounded_integer directly to trigger the uncovered line
-  let result = decimal.rounded_integer()
-  inspect(result, content="-1") // Should be Int64::max_value
-}
-
-///|
-test "corner cases" {
-  inspect(try? parse_decimal_priv(".123"), content="Ok(0.123)")
-  inspect(try? parse_decimal_priv("."), content="Err(invalid syntax)")
-  inspect(try? parse_decimal_priv("-"), content="Err(invalid syntax)")
-}
-
-///|
-test "parse_double mantissa normalization boundary" {
-  inspect(parse_double("1.9999999999999999"), content="2")
-  inspect(parse_double("9007199254740991.5"), content="9007199254740992")
-}
-
-///|
-test "parse_double large magnitude cancellation" {
-  let input = "1" + String::make(800, '0') + "e-800"
-  inspect(parse_double(input), content="1")
-
-  let input = "-1" + String::make(800, '0') + "e-800"
-  inspect(parse_double(input), content="-1")
-
-  let input = "1." + String::make(800, '0')
-  inspect(parse_double(input), content="1")
-
-  let input = "1" + String::make(1999, '0') + "e-1999"
-  inspect(parse_double(input), content="1")
-
-  let input = "1" + String::make(399, '0') + ".5"
-  try parse_double(input) catch {
-    e => inspect(e, content="value out of range")
-  } noraise {
-    _ => fail("expected parse_double to raise")
-  }
-
-  let input = "1" + String::make(800, '0') + ".5"
-  try parse_double(input) catch {
-    e => inspect(e, content="value out of range")
-  } noraise {
-    _ => fail("expected parse_double to raise")
-  }
-
-  let input = "1" + String::make(1499, '0') + "e-1499"
-  inspect(parse_double(input), content="1")
-
-  let input = "0." + String::make(1999, '0') + "1"
-  inspect(parse_double(input), content="0")
-}
diff --git a/strconv/deprecated.mbt b/strconv/deprecated.mbt
deleted file mode 100644
index 79ae02694b..0000000000
--- a/strconv/deprecated.mbt
+++ /dev/null
@@ -1,80 +0,0 @@
-// Copyright 2026 International Digital Economy Academy
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-///|
-/// High Precision Decimal structure for "Simple Decimal Conversion" algorithm.
-/// Developed by Ken. Thompson, Russ Cox, Robert Griesemer, Nigel Tao.
-///
-/// reference:
-/// - 
-/// - 
-#deprecated("use `@string.parse_double` instead", skip_current_package=true)
-struct Decimal {
-  digits : FixedArray[Byte]
-  mut digits_num : Int
-  mut decimal_point : Int
-  mut negative : Bool
-  mut truncated : Bool
-  mut overflowed : Int
-} derive(@debug.Debug)
-
-///|
-/// Create a zero decimal.
-#deprecated("use `@string.parse_double` instead", skip_current_package=true)
-pub fn Decimal::new() -> Decimal {
-  Decimal::new_priv()
-}
-
-///|
-/// Create a decimal with an Int64 value.
-#deprecated("use `@string.parse_double` instead", skip_current_package=true)
-pub fn Decimal::from_int64(v : Int64) -> Decimal {
-  Decimal::from_int64_priv(v)
-}
-
-///|
-/// Function `parse_decimal`.
-#deprecated("use `@string.parse_double` instead", skip_current_package=true)
-pub fn parse_decimal(str : StringView) -> Decimal raise StrConvError {
-  parse_decimal_from_view(str)
-}
-
-///|
-/// Function `parse_decimal`.
-#deprecated("use `@string.parse_double` instead", skip_current_package=true)
-pub fn Decimal::parse_decimal(str : StringView) -> Decimal raise StrConvError {
-  parse_decimal_from_view(str)
-}
-
-///|
-/// Convert the decimal to Double.
-#deprecated("use `@string.parse_double` instead", skip_current_package=true)
-pub fn Decimal::to_double(self : Decimal) -> Double raise StrConvError {
-  self.to_double_priv()
-}
-
-///|
-/// Binary shift left (s > 0) or right (s < 0).
-/// The shift count must not larger than the max_shift to avoid overflow.
-#deprecated("use `@string.parse_double` instead", skip_current_package=true)
-pub fn Decimal::shift(self : Decimal, s : Int) -> Unit {
-  self.shift_priv(s)
-}
-
-///|
-/// Parse input into this package's structured value.
-#deprecated("use `@string.from_str` instead", skip_current_package=true)
-pub fn[A : FromStr] parse(str : StringView) -> A raise StrConvError {
-  A::from_str(str)
-}
diff --git a/strconv/double.mbt b/strconv/double.mbt
deleted file mode 100644
index 461d5f2a80..0000000000
--- a/strconv/double.mbt
+++ /dev/null
@@ -1,284 +0,0 @@
-// Copyright 2026 International Digital Economy Academy
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-///|
-priv struct FloatInfo {
-  mantissa_bits : Int
-  exponent_bits : Int
-  bias : Int
-}
-
-///|
-let double_info : FloatInfo = {
-  mantissa_bits: 52,
-  exponent_bits: 11,
-  bias: -1023,
-}
-
-///|
-/// TODO: For `f32` it is 23, but we don't have `f32` yet.
-let mantissa_explicit_bits = 52
-
-///|
-/// TODO: For `f32` it is -10, but we don't have `f32` yet.
-let min_exponent_fast_path : Int64 = -22L
-
-///|
-/// TODO: For `f32` it is 10, but we don't have `f32` yet.
-let max_exponent_fast_path : Int64 = 22L
-
-///|
-/// TODO: For `f32` it is 17, but we don't have `f32` yet.
-let max_exponent_disguised_fast_path : Int64 = 37L
-
-///|
-let max_mantissa_fast_path : UInt64 = 2UL << mantissa_explicit_bits
-
-///|
-/// Parse a string into a double precision floating point number. The string
-/// must contain at least one of:
-/// - An integer part (decimal digits)
-/// - A decimal point followed by a fractional part (decimal digits)
-/// - An exponent part ('e' or 'E' followed by an optional sign and decimal digits)
-///
-/// The string may optionally start with a sign ('+' or '-').
-/// For readability, underscores may appear between digits.
-///
-/// Examples:
-/// ```mbt check
-/// #warnings("-deprecated")
-/// test {
-///   inspect(@strconv.parse_double("123"), content="123")
-///   inspect(@strconv.parse_double("12.34"), content="12.34")
-///   inspect(@strconv.parse_double(".123"), content="0.123")
-///   inspect(@strconv.parse_double("1e5"), content="100000")
-///   inspect(@strconv.parse_double("1.2e-3"), content="0.0012")
-///   inspect(@strconv.parse_double("1_234.5"), content="1234.5")
-/// }
-/// ```
-///
-/// An exponent value exp scales the mantissa (significand) by 10^exp.
-/// For example, "1.23e2" represents 1.23 × 10² = 123.
-#deprecated("use `@string.parse_double` instead", skip_current_package=true)
-pub fn parse_double(str : StringView) -> Double raise StrConvError {
-  guard !str.is_empty() else { syntax_err() }
-  guard check_underscore(str) else { syntax_err() }
-  // validate its a number
-  match parse_number(str) {
-    None => parse_inf_nan(str)
-    Some(num) =>
-      // Clinger's fast path (How to read floating point numbers accurately)[https://doi.org/10.1145/989393.989430]
-      match num.try_fast_path() {
-        Some(value) => value
-        None => parse_decimal_priv(str).to_double_priv() // fallback to slow path
-      }
-  }
-}
-
-///|
-fn Number::is_fast_path(self : Number) -> Bool {
-  min_exponent_fast_path <= self.exponent &&
-  self.exponent <= max_exponent_disguised_fast_path &&
-  self.mantissa <= max_mantissa_fast_path &&
-  !self.many_digits
-}
-
-///|
-let table : ReadOnlyArray[Double] = [
-  1.0, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 10000000.0, 100000000.0,
-  1000000000.0, 10000000000.0, 100000000000.0, 1000000000000.0, 10000000000000.0,
-  100000000000000.0, 1000000000000000.0, 10000000000000000.0, 100000000000000000.0,
-  1000000000000000000.0, 10000000000000000000.0, 100000000000000000000.0, 1000000000000000000000.0,
-  10000000000000000000000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
-]
-
-///|
-fn pow10_fast_path(exponent : Int) -> Double {
-  table[exponent & 31]
-}
-
-///|
-let int_pow10 : ReadOnlyArray[UInt64] = [
-  1UL, 10UL, 100UL, 1000UL, 10000UL, 100000UL, 1000000UL, 10000000UL, 100000000UL,
-  1000000000UL, 10000000000UL, 100000000000UL, 1000000000000UL, 10000000000000UL,
-  100000000000000UL, 1000000000000000UL,
-]
-
-///|
-fn Number::try_fast_path(self : Number) -> Double? {
-  if self.is_fast_path() {
-    let mut value = if self.exponent <= max_exponent_fast_path {
-      // normal fast path
-      let value = Double::convert_uint64(self.mantissa)
-      if self.exponent < 0L {
-        value / pow10_fast_path(-self.exponent.to_int())
-      } else {
-        value * pow10_fast_path(self.exponent.to_int())
-      }
-    } else {
-      // disguised fast path
-      let shift = self.exponent - max_exponent_fast_path
-      let mantissa = match
-        checked_mul(self.mantissa, int_pow10[shift.to_int()]) {
-        Some(m) => m
-        None => return None
-      }
-      if mantissa > max_mantissa_fast_path {
-        return None
-      }
-      Double::convert_uint64(mantissa) *
-      pow10_fast_path(max_exponent_fast_path.to_int())
-    }
-    if self.negative {
-      value = -value
-    }
-    Some(value)
-  } else {
-    None
-  }
-}
-
-///|
-test "parse_double" {
-  let tests : Array[(String, Result[Double, String])] = [
-    ("", Err(syntax_err_str)),
-    ("1x", Err(syntax_err_str)),
-    ("1.1.", Err(syntax_err_str)),
-    ("1e", Err(syntax_err_str)),
-    ("1e-", Err(syntax_err_str)),
-    (".e-1", Err(syntax_err_str)),
-    ("1", Ok(1.0)),
-    ("+1", Ok(1.0)),
-    ("1e23", Ok(1.0e23)),
-    ("1E23", Ok(1.0e23)),
-    ("100000000000000000000000", Ok(1.0e23)),
-    ("1e-100", Ok(1.0e-100)),
-    ("123456700", Ok(1.234567e+08)),
-    ("99999999999999974834176", Ok(9.999999999999997e+22)),
-    ("100000000000000000000001", Ok(1.0000000000000001e+23)),
-    ("100000000000000008388608", Ok(1.0000000000000001e+23)),
-    ("100000000000000016777215", Ok(1.0000000000000001e+23)),
-    ("100000000000000016777216", Ok(1.0000000000000003e+23)),
-    ("-1", Ok(-1.0)),
-    ("-0.1", Ok(-0.1)),
-    ("-0", Ok(-0.0)),
-    ("1e-20", Ok(1.0e-20)),
-    ("625e-3", Ok(0.625)),
-    ("6.62607015e-34", Ok(6.62607015e-34)),
-    ("2.2250738585072012e-308", Ok(2.2250738585072014e-308)),
-    ("2.2250738585072011e-308", Ok(2.225073858507201e-308)),
-    ("0", Ok(0.0)),
-    ("0e0", Ok(0.0)),
-    ("-0e0", Ok(-0.0)),
-    ("+0e0", Ok(0.0)),
-    ("0e-0", Ok(0.0)),
-    ("-0e-0", Ok(-0.0)),
-    ("+0e-0", Ok(0.0)),
-    ("0e+0", Ok(0.0)),
-    ("-0e+0", Ok(-0.0)),
-    ("+0e+0", Ok(0.0)),
-    ("0e+01234567890123456789", Ok(0.0)),
-    ("0.00e-01234567890123456789", Ok(0.0)),
-    ("-0e+01234567890123456789", Ok(-0.0)),
-    ("-0.00e-01234567890123456789", Ok(-0.0)),
-    ("0e292", Ok(0.0)),
-    ("0e347", Ok(0.0)),
-    ("0e348", Ok(0.0)),
-    ("-0e291", Ok(-0.0)),
-    ("-0e292", Ok(-0.0)),
-    ("-0e347", Ok(-0.0)),
-    ("-0e348", Ok(-0.0)),
-    ("1.7976931348623157e308", Ok(1.7976931348623157e308)),
-    ("-1.7976931348623157e308", Ok(-1.7976931348623157e308)),
-    ("1.7976931348623158e308", Ok(1.7976931348623157e308)),
-    ("-1.7976931348623158e308", Ok(-1.7976931348623157e308)),
-    ("1e308", Ok(1.0e308)),
-    (
-      "1.7976931348623159e308",
-      Err(
-        // zeros
-        // large double
-        range_err_str,
-      ),
-    ),
-    (
-      "-1.7976931348623159e308",
-      Err(
-        // overflow
-        range_err_str,
-      ),
-    ),
-    ("2e308", Err(range_err_str)),
-    ("1e309", Err(range_err_str)),
-    ("1e310", Err(range_err_str)),
-    ("1e400", Err(range_err_str)),
-    ("1e40000", Err(range_err_str)),
-    // denormalized
-    ("1e-305", Ok(1.0e-305)),
-    ("1e-306", Ok(1.0e-306)),
-    ("1e-307", Ok(1.0e-307)),
-    ("1e-308", Ok(1.0e-308)),
-    ("1e-309", Ok(1.0e-309)),
-    ("1e-310", Ok(1.0e-310)),
-    ("1e-322", Ok(1.0e-322)),
-    // smallest denormal
-    ("5e-324", Ok(5.0e-324)),
-    ("4e-324", Ok(5.0e-324)),
-    ("3e-324", Ok(5.0e-324)),
-    // underflow
-    ("2e-324", Ok(0.0)),
-    ("1e-350", Ok(0.0)),
-    ("1e-400000", Ok(0.0)),
-    // underscores
-    ("1_23.50_0_0e+1_2", Ok(1.235e+14)),
-    ("-_123.5e+12", Err(syntax_err_str)),
-    ("+_123.5e+12", Err(syntax_err_str)),
-    ("_123.5e+12", Err(syntax_err_str)),
-    ("1__23.5e+12", Err(syntax_err_str)),
-    ("123_.5e+12", Err(syntax_err_str)),
-    ("123._5e+12", Err(syntax_err_str)),
-    ("123.5_e+12", Err(syntax_err_str)),
-    ("123.5__0e+12", Err(syntax_err_str)),
-    ("123.5e_+12", Err(syntax_err_str)),
-    ("123.5e+_12", Err(syntax_err_str)),
-    ("123.5e_-12", Err(syntax_err_str)),
-    ("123.5e-_12", Err(syntax_err_str)),
-    ("123.5e+1__2", Err(syntax_err_str)),
-    ("123.5e+12_", Err(syntax_err_str)),
-  ]
-  for t in tests {
-    assert_true(
-      (Result::Ok(parse_double(t.0)) catch { StrConvError(err) => Err(err) }) ==
-      t.1,
-    )
-  }
-}
-
-///|
-test "parse_double_inf" {
-  assert_true(parse_double("inf") == @double.infinity)
-  assert_true(parse_double("+Inf") == @double.infinity)
-  assert_true(parse_double("-Inf") == @double.neg_infinity)
-  assert_true(parse_double("+Infinity") == @double.infinity)
-  assert_true(parse_double("-Infinity") == @double.neg_infinity)
-  assert_true(parse_double("+INFINITY") == @double.infinity)
-  assert_true(parse_double("-INFINITY") == @double.neg_infinity)
-}
-
-///|
-test "parse_double_nan" {
-  assert_true(parse_double("nan").is_nan())
-  assert_true(parse_double("NaN").is_nan())
-  assert_true(parse_double("NAN").is_nan())
-}
diff --git a/strconv/double_differential_test.mbt b/strconv/double_differential_test.mbt
deleted file mode 100644
index 4da222a701..0000000000
--- a/strconv/double_differential_test.mbt
+++ /dev/null
@@ -1,946 +0,0 @@
-// Copyright 2026 International Digital Economy Academy
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-// Cross-target differential battery for double <-> string conversion.
-//
-// The `inspect(..., content=...)` snapshots below are recorded once (with
-// `moon test --update`) and are shared by every backend. Since IEEE-754
-// semantics are target independent, running this file WITHOUT `--update` on
-// the js / native / wasm / wasm-gc targets turns the snapshots into a
-// cross-target oracle: any backend that disagrees with the recorded content
-// (e.g. the js backend, whose `Double::to_string` delegates to the JS engine
-// while other backends use the MoonBit ryu port, or the Int64-emulation of
-// the js backend inside the parser) fails the test and pinpoints the exact
-// input.
-//
-// All pseudo-random inputs are generated with a fixed-seed splitmix64, so the
-// battery is fully deterministic.
-
-///|
-priv struct Rng {
-  mut s : UInt64
-}
-
-///|
-/// splitmix64: deterministic, high-quality 64-bit PRNG.
-fn Rng::next(self : Rng) -> UInt64 {
-  self.s = self.s + 0x9E3779B97F4A7C15UL
-  let mut z = self.s
-  z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9UL
-  z = (z ^ (z >> 27)) * 0x94D049BB133111EBUL
-  z ^ (z >> 31)
-}
-
-///|
-fn hex16(bits : UInt64) -> String {
-  let s = bits.to_string(radix=16)
-  let b = StringBuilder::new()
-  b.write_string("0x")
-  for _ in 0..<(16 - s.length()) {
-    b.write_char('0')
-  }
-  b.write_string(s)
-  b.to_string()
-}
-
-///|
-/// Parse `input` and render the result as `bits-in-hex shortest-string`,
-/// or the error message. Exercises both directions at once.
-#warnings("-deprecated")
-fn parse_probe(input : String) -> String {
-  match (try? @strconv.parse_double(input)) {
-    Ok(d) => hex16(d.reinterpret_as_uint64()) + " " + d.to_string()
-    Err(e) => e.to_string()
-  }
-}
-
-///|
-#warnings("-deprecated")
-fn reparse_bits(input : String) -> String {
-  match (try? @strconv.parse_double(input)) {
-    Ok(d) => hex16(d.reinterpret_as_uint64())
-    Err(e) => e.to_string()
-  }
-}
-
-///|
-/// bits -> to_string -> parse, rendered on one line.
-fn print_probe(bits : UInt64) -> String {
-  let s = bits.reinterpret_as_double().to_string()
-  hex16(bits) + " => " + s + " => " + reparse_bits(s)
-}
-
-///|
-fn batch(inputs : Array[String]) -> String {
-  let b = StringBuilder::new()
-  for s in inputs {
-    b.write_string(s)
-    b.write_string(" => ")
-    b.write_string(parse_probe(s))
-    b.write_char('\n')
-  }
-  b.to_string()
-}
-
-///|
-/// Classic hard-rounding cases from the literature (golang strconv,
-/// fast_float, the PHP/Java 2.2250738585072011e-308 hang case, halfway
-/// patterns around 2^53, ...).
-let classic_inputs : Array[String] = [
-  "2.2250738585072011e-308", "2.2250738585072012e-308", "2.2250738585072013e-308",
-  "2.2250738585072014e-308", "2.225073858507201136057409796709131975934819546351645648023426109724822222021076945516529523908135087914149158913039621106870086438694594645527657207407820621743379988141063267329253552286881372149012981122451451889849057222307285255133155755015914397476397983411801999323962548289017107081850690630666655994938275772572015763062690663332647565300009245888316433037779791869612049497390377829704905051080609940730262937128958950003583799967207254304360284078895771796150945516748243471030702609144621572289880258182545180325707018860872113128079512233426288368622321503775666622503982534335974568884423900265498198385487948292206894721689831099698365846814022854243330660339850886445804001034933970427567186443383770486037861622771738545623065874679014086723327636718751234567890123456789012345678901e-308",
-  "4.9406564584124654e-324", "4.9406564584124654417656879286822e-324", "2.4703282292062327208828439643412e-324",
-  "2.4703282292062327208828439643413e-324", "2.470328229206232720882843964341e-324",
-  "1.7976931348623157e308", "1.7976931348623158e308", "1.7976931348623159e308", "1.797693134862315807937289714053e308",
-  "1.7976931348623158079372897140530341507993413271003782693617377898044496829276475094664901797758720709633028641669288791094655554785194040263065748867150582068e+308",
-  "9007199254740993", "9007199254740993.00000001", "9007199254740995", "9007199254740995.0000000000001",
-  "9007199254740992.5", "9007199254740991.5", "5.9604644775390625e-8", "72057594037927928.0",
-  "72057594037927936.0", "72057594037927932.0", "7.2057594037927932e16", "0.1", "0.3",
-  "0.123456789012345678901234567890", "0.500000000000000166533453693773481063544750213623046875",
-  "0.50000000000000016656055874808561867439493653364479541778564453125", "3.7455744005952583e15",
-  "1.15507426828740588e-173", "104110013277974872254e-225", "30078505129381147446200",
-  "1777820000000000000001", "0.9999999999999999999999999999999999", "1.0000000000000000000000000000000001",
-  "9214843084008499", "6929495644600919.5", "3.2657175095361160910e+38", "1.0000000000000006661338147750939242541790008544921875",
-]
-
-///|
-/// Overflow / underflow / subnormal boundaries and exponent-clamp overflow
-/// probes (huge exponent digit strings around Int limits).
-let boundary_inputs : Array[String] = [
-  "1e308", "2e308", "1e309", "1e310", "1e-307", "1e-308", "1e-309", "1e-320", "1e-323",
-  "1e-324", "1e-325", "17976931348623157e292", "17976931348623158e292", "5e-324",
-  "4.9e-324", "2.5e-324", "2.47e-324", "2.4703282292062328e-324", "9.8813129168249309e-324",
-  "1e2147483647", "1e-2147483648", "1e-2147483649", "1e+2147483648", "1e999999999999999999999",
-  "1e-999999999999999999999", "0e999999999999999999999", "0e-999999999999999999999",
-  "1e0000000000000000000000000000005", "1e-0000000000000000000000000000005", "0.000000000000000000000000000000000000001e40",
-  "10000000000000000000000000000000000000000e-40", "2.2250738585072009e-308", "2.2250738585072014e-308",
-  "8.98846567431158e307", "4.450147717014403e-308", "1.1125369292536007e-308", "1.5e-323",
-  "2.5e-323", "3.5e-323", "4.5e-323", "-1e-324", "-2.4703282292062328e-324", "-0.0",
-  "-1e309", "-1.7976931348623158e308", "-4.9406564584124654e-324",
-]
-
-///|
-/// The printed form must switch between positional and exponent notation at
-/// exactly the ECMAScript thresholds (1e21 and 1e-7) on every backend.
-let threshold_inputs : Array[String] = [
-  "1e20", "1e21", "1e22", "9.999999999999999e20", "1.0000000000000001e21", "2e21",
-  "999999999999999999999", "1000000000000000000000", "1000000000000000000001", "1e-5",
-  "1e-6", "1e-7", "9.999999999999999e-7", "1.0000000000000001e-6", "0.00001", "0.000001",
-  "0.0000001", "123456789.123456789", "1234567890123456789012", "5e-321", "1.5e300",
-  "-1e21", "-1e-7", "-123456789.123456789",
-]
-
-///|
-/// Long digit strings force the 800-digit slow decimal path, including its
-/// truncation flag handling.
-fn long_digit_cases() -> Array[(String, String)] {
-  [
-    ("9x40", String::make(40, '9')),
-    ("9x100 e-100", String::make(100, '9') + "e-100"),
-    ("9x400 e-380", String::make(400, '9') + "e-380"),
-    ("9x800 e-780", String::make(800, '9') + "e-780"),
-    ("9x805 e-780", String::make(805, '9') + "e-780"),
-    ("1 0x805 e-805", "1" + String::make(805, '0') + "e-805"),
-    ("1 0x805 . 5 e-805", "1" + String::make(805, '0') + ".5e-805"),
-    ("0.4 9x200", "0.4" + String::make(200, '9')),
-    ("0.4 9x800", "0.4" + String::make(800, '9')),
-    ("0.5 0x200 1", "0.5" + String::make(200, '0') + "1"),
-    ("0.5 0x800 1", "0.5" + String::make(800, '0') + "1"),
-    (
-      "0. 0x323 494065645841246544176568792868",
-      "0." + String::make(323, '0') + "494065645841246544176568792868",
-    ),
-    (
-      "0. 0x323 247032822920623272088284396434 05",
-      "0." + String::make(323, '0') + "24703282292062327208828439643405",
-    ),
-    (
-      "0. 0x323 247032822920623272088284396434 5",
-      "0." + String::make(323, '0') + "2470328229206232720882843964345",
-    ),
-    (
-      "9007199254740992.5 0x100 1",
-      "9007199254740992.5" + String::make(100, '0') + "1",
-    ),
-    (
-      "9007199254740992. 0x100 5",
-      "9007199254740992." + String::make(100, '0') + "5",
-    ),
-    (
-      "2.225073858507201 3x50 e-308",
-      "2.225073858507201" + String::make(50, '3') + "e-308",
-    ),
-    ("1. 0x100 1 e0", "1." + String::make(100, '0') + "1"),
-    ("1. 9x100 e0", "1." + String::make(100, '9')),
-    (
-      "17976931348623157 0x292 .5",
-      "17976931348623157" + String::make(292, '0') + ".5",
-    ),
-  ]
-}
-
-///|
-test "parse: classic hard cases" {
-  inspect(
-    batch(classic_inputs),
-    content=(
-      #|2.2250738585072011e-308 => 0x000fffffffffffff 2.225073858507201e-308
-      #|2.2250738585072012e-308 => 0x0010000000000000 2.2250738585072014e-308
-      #|2.2250738585072013e-308 => 0x0010000000000000 2.2250738585072014e-308
-      #|2.2250738585072014e-308 => 0x0010000000000000 2.2250738585072014e-308
-      #|2.225073858507201136057409796709131975934819546351645648023426109724822222021076945516529523908135087914149158913039621106870086438694594645527657207407820621743379988141063267329253552286881372149012981122451451889849057222307285255133155755015914397476397983411801999323962548289017107081850690630666655994938275772572015763062690663332647565300009245888316433037779791869612049497390377829704905051080609940730262937128958950003583799967207254304360284078895771796150945516748243471030702609144621572289880258182545180325707018860872113128079512233426288368622321503775666622503982534335974568884423900265498198385487948292206894721689831099698365846814022854243330660339850886445804001034933970427567186443383770486037861622771738545623065874679014086723327636718751234567890123456789012345678901e-308 => 0x0010000000000000 2.2250738585072014e-308
-      #|4.9406564584124654e-324 => 0x0000000000000001 5e-324
-      #|4.9406564584124654417656879286822e-324 => 0x0000000000000001 5e-324
-      #|2.4703282292062327208828439643412e-324 => 0x0000000000000001 5e-324
-      #|2.4703282292062327208828439643413e-324 => 0x0000000000000001 5e-324
-      #|2.470328229206232720882843964341e-324 => 0x0000000000000000 0
-      #|1.7976931348623157e308 => 0x7fefffffffffffff 1.7976931348623157e+308
-      #|1.7976931348623158e308 => 0x7fefffffffffffff 1.7976931348623157e+308
-      #|1.7976931348623159e308 => value out of range
-      #|1.797693134862315807937289714053e308 => 0x7fefffffffffffff 1.7976931348623157e+308
-      #|1.7976931348623158079372897140530341507993413271003782693617377898044496829276475094664901797758720709633028641669288791094655554785194040263065748867150582068e+308 => 0x7fefffffffffffff 1.7976931348623157e+308
-      #|9007199254740993 => 0x4340000000000000 9007199254740992
-      #|9007199254740993.00000001 => 0x4340000000000001 9007199254740994
-      #|9007199254740995 => 0x4340000000000002 9007199254740996
-      #|9007199254740995.0000000000001 => 0x4340000000000002 9007199254740996
-      #|9007199254740992.5 => 0x4340000000000000 9007199254740992
-      #|9007199254740991.5 => 0x4340000000000000 9007199254740992
-      #|5.9604644775390625e-8 => 0x3e70000000000000 5.960464477539063e-8
-      #|72057594037927928.0 => 0x436fffffffffffff 72057594037927930
-      #|72057594037927936.0 => 0x4370000000000000 72057594037927940
-      #|72057594037927932.0 => 0x4370000000000000 72057594037927940
-      #|7.2057594037927932e16 => 0x4370000000000000 72057594037927940
-      #|0.1 => 0x3fb999999999999a 0.1
-      #|0.3 => 0x3fd3333333333333 0.3
-      #|0.123456789012345678901234567890 => 0x3fbf9add3746f65f 0.12345678901234568
-      #|0.500000000000000166533453693773481063544750213623046875 => 0x3fe0000000000002 0.5000000000000002
-      #|0.50000000000000016656055874808561867439493653364479541778564453125 => 0x3fe0000000000002 0.5000000000000002
-      #|3.7455744005952583e15 => 0x432a9d28ff412a75 3745574400595258.5
-      #|1.15507426828740588e-173 => 0x1c06dad414c1feb8 1.155074268287406e-173
-      #|104110013277974872254e-225 => 0x1560b661a31987ae 1.0411001327797486e-205
-      #|30078505129381147446200 => 0x44997a3c7271b021 3.007850512938115e+22
-      #|1777820000000000000001 => 0x4458180d5bad2e3e 1.77782e+21
-      #|0.9999999999999999999999999999999999 => 0x3ff0000000000000 1
-      #|1.0000000000000000000000000000000001 => 0x3ff0000000000000 1
-      #|9214843084008499 => 0x43405e6cec57761a 9214843084008500
-      #|6929495644600919.5 => 0x43389e56ee5e7a58 6929495644600920
-      #|3.2657175095361160910e+38 => 0x47eeb5edce275c68 3.265717509536116e+38
-      #|1.0000000000000006661338147750939242541790008544921875 => 0x3ff0000000000003 1.0000000000000007
-      #|
-    ),
-  )
-}
-
-///|
-test "parse: overflow, underflow and exponent boundaries" {
-  inspect(
-    batch(boundary_inputs),
-    content=(
-      #|1e308 => 0x7fe1ccf385ebc8a0 1e+308
-      #|2e308 => value out of range
-      #|1e309 => value out of range
-      #|1e310 => value out of range
-      #|1e-307 => 0x0031fa182c40c60d 1e-307
-      #|1e-308 => 0x000730d67819e8d2 1e-308
-      #|1e-309 => 0x0000b8157268fdaf 1e-309
-      #|1e-320 => 0x00000000000007e8 1e-320
-      #|1e-323 => 0x0000000000000002 1e-323
-      #|1e-324 => 0x0000000000000000 0
-      #|1e-325 => 0x0000000000000000 0
-      #|17976931348623157e292 => 0x7fefffffffffffff 1.7976931348623157e+308
-      #|17976931348623158e292 => 0x7fefffffffffffff 1.7976931348623157e+308
-      #|5e-324 => 0x0000000000000001 5e-324
-      #|4.9e-324 => 0x0000000000000001 5e-324
-      #|2.5e-324 => 0x0000000000000001 5e-324
-      #|2.47e-324 => 0x0000000000000000 0
-      #|2.4703282292062328e-324 => 0x0000000000000001 5e-324
-      #|9.8813129168249309e-324 => 0x0000000000000002 1e-323
-      #|1e2147483647 => value out of range
-      #|1e-2147483648 => 0x0000000000000000 0
-      #|1e-2147483649 => 0x0000000000000000 0
-      #|1e+2147483648 => value out of range
-      #|1e999999999999999999999 => value out of range
-      #|1e-999999999999999999999 => 0x0000000000000000 0
-      #|0e999999999999999999999 => 0x0000000000000000 0
-      #|0e-999999999999999999999 => 0x0000000000000000 0
-      #|1e0000000000000000000000000000005 => 0x40f86a0000000000 100000
-      #|1e-0000000000000000000000000000005 => 0x3ee4f8b588e368f1 0.00001
-      #|0.000000000000000000000000000000000000001e40 => 0x4024000000000000 10
-      #|10000000000000000000000000000000000000000e-40 => 0x3ff0000000000000 1
-      #|2.2250738585072009e-308 => 0x000fffffffffffff 2.225073858507201e-308
-      #|2.2250738585072014e-308 => 0x0010000000000000 2.2250738585072014e-308
-      #|8.98846567431158e307 => 0x7fe0000000000000 8.98846567431158e+307
-      #|4.450147717014403e-308 => 0x0020000000000000 4.450147717014403e-308
-      #|1.1125369292536007e-308 => 0x0008000000000000 1.1125369292536007e-308
-      #|1.5e-323 => 0x0000000000000003 1.5e-323
-      #|2.5e-323 => 0x0000000000000005 2.5e-323
-      #|3.5e-323 => 0x0000000000000007 3.5e-323
-      #|4.5e-323 => 0x0000000000000009 4.4e-323
-      #|-1e-324 => 0x8000000000000000 0
-      #|-2.4703282292062328e-324 => 0x8000000000000001 -5e-324
-      #|-0.0 => 0x8000000000000000 0
-      #|-1e309 => value out of range
-      #|-1.7976931348623158e308 => 0xffefffffffffffff -1.7976931348623157e+308
-      #|-4.9406564584124654e-324 => 0x8000000000000001 -5e-324
-      #|
-    ),
-  )
-}
-
-///|
-test "print: positional vs exponent notation thresholds" {
-  inspect(
-    batch(threshold_inputs),
-    content=(
-      #|1e20 => 0x4415af1d78b58c40 100000000000000000000
-      #|1e21 => 0x444b1ae4d6e2ef50 1e+21
-      #|1e22 => 0x4480f0cf064dd592 1e+22
-      #|9.999999999999999e20 => 0x444b1ae4d6e2ef4f 999999999999999900000
-      #|1.0000000000000001e21 => 0x444b1ae4d6e2ef51 1.0000000000000001e+21
-      #|2e21 => 0x445b1ae4d6e2ef50 2e+21
-      #|999999999999999999999 => 0x444b1ae4d6e2ef50 1e+21
-      #|1000000000000000000000 => 0x444b1ae4d6e2ef50 1e+21
-      #|1000000000000000000001 => 0x444b1ae4d6e2ef50 1e+21
-      #|1e-5 => 0x3ee4f8b588e368f1 0.00001
-      #|1e-6 => 0x3eb0c6f7a0b5ed8d 0.000001
-      #|1e-7 => 0x3e7ad7f29abcaf48 1e-7
-      #|9.999999999999999e-7 => 0x3eb0c6f7a0b5ed8d 0.000001
-      #|1.0000000000000001e-6 => 0x3eb0c6f7a0b5ed8e 0.0000010000000000000002
-      #|0.00001 => 0x3ee4f8b588e368f1 0.00001
-      #|0.000001 => 0x3eb0c6f7a0b5ed8d 0.000001
-      #|0.0000001 => 0x3e7ad7f29abcaf48 1e-7
-      #|123456789.123456789 => 0x419d6f34547e6b75 123456789.12345679
-      #|1234567890123456789012 => 0x4450bb448ec2f608 1.2345678901234568e+21
-      #|5e-321 => 0x00000000000003f4 5e-321
-      #|1.5e300 => 0x7e41eb2d66005835 1.5e+300
-      #|-1e21 => 0xc44b1ae4d6e2ef50 -1e+21
-      #|-1e-7 => 0xbe7ad7f29abcaf48 -1e-7
-      #|-123456789.123456789 => 0xc19d6f34547e6b75 -123456789.12345679
-      #|
-    ),
-  )
-}
-
-///|
-test "parse: long digit strings (slow path)" {
-  let b = StringBuilder::new()
-  for c in long_digit_cases() {
-    b.write_string(c.0)
-    b.write_string(" => ")
-    b.write_string(parse_probe(c.1))
-    b.write_char('\n')
-  }
-  inspect(
-    b.to_string(),
-    content=(
-      #|9x40 => 0x483d6329f1c35ca5 1e+40
-      #|9x100 e-100 => 0x3ff0000000000000 1
-      #|9x400 e-380 => 0x4415af1d78b58c40 100000000000000000000
-      #|9x800 e-780 => 0x4415af1d78b58c40 100000000000000000000
-      #|9x805 e-780 => 0x45208b2a2c280291 1e+25
-      #|1 0x805 e-805 => 0x3ff0000000000000 1
-      #|1 0x805 . 5 e-805 => 0x3ff0000000000000 1
-      #|0.4 9x200 => 0x3fe0000000000000 0.5
-      #|0.4 9x800 => 0x3fe0000000000000 0.5
-      #|0.5 0x200 1 => 0x3fe0000000000000 0.5
-      #|0.5 0x800 1 => 0x3fe0000000000000 0.5
-      #|0. 0x323 494065645841246544176568792868 => 0x0000000000000001 5e-324
-      #|0. 0x323 247032822920623272088284396434 05 => 0x0000000000000000 0
-      #|0. 0x323 247032822920623272088284396434 5 => 0x0000000000000001 5e-324
-      #|9007199254740992.5 0x100 1 => 0x4340000000000000 9007199254740992
-      #|9007199254740992. 0x100 5 => 0x4340000000000000 9007199254740992
-      #|2.225073858507201 3x50 e-308 => 0x0010000000000000 2.2250738585072014e-308
-      #|1. 0x100 1 e0 => 0x3ff0000000000000 1
-      #|1. 9x100 e0 => 0x4000000000000000 2
-      #|17976931348623157 0x292 .5 => 0x7fefffffffffffff 1.7976931348623157e+308
-      #|
-    ),
-  )
-}
-
-///|
-test "parse: random 16/17-digit significands with extreme exponents" {
-  let rng = { s: 0xDEADBEEFCAFEF00DUL }
-  let b = StringBuilder::new()
-  for _ in 0..<40 {
-    let m = 4503599627370496UL + rng.next() % 4503599627370496UL
-    let delta = (rng.next() % 5UL).to_int64() - 2L
-    let m2 = (m.reinterpret_as_int64() + delta).reinterpret_as_uint64()
-    let e = (rng.next() % 601UL).to_int() - 300
-    let s1 = m2.to_string() + "e" + e.to_string()
-    let s2 = (m * 10UL + 5UL).to_string() + "e" + (e - 1).to_string()
-    b.write_string(s1 + " => " + parse_probe(s1) + "\n")
-    b.write_string(s2 + " => " + parse_probe(s2) + "\n")
-  }
-  inspect(
-    b.to_string(),
-    content=(
-      #|8250070335255244e-237 => 0x1207db835f11e5ab 8.250070335255245e-222
-      #|82500703352552435e-238 => 0x1207db835f11e5aa 8.250070335255243e-222
-      #|6876095511102352e-272 => 0x0ac084e84086c959 6.876095511102352e-257
-      #|68760955111023545e-273 => 0x0ac084e84086c95b 6.876095511102355e-257
-      #|7993615625236586e-201 => 0x198163f3e4e1c4e7 7.993615625236587e-186
-      #|79936156252365875e-202 => 0x198163f3e4e1c4e8 7.993615625236588e-186
-      #|6476721138375899e-47 => 0x397504a63fd1094d 6.476721138375899e-32
-      #|64767211383759005e-48 => 0x397504a63fd1094e 6.4767211383759e-32
-      #|7866289431641284e-261 => 0x0d0b800f4200974c 7.866289431641284e-246
-      #|78662894316412835e-262 => 0x0d0b800f4200974b 7.866289431641283e-246
-      #|8694024001178170e-259 => 0x0d77becabcdafed6 8.69402400117817e-244
-      #|86940240011781715e-260 => 0x0d77becabcdafed7 8.694024001178172e-244
-      #|8024813654957345e299 => value out of range
-      #|80248136549573455e298 => value out of range
-      #|7759959144085814e-132 => 0x27d39189d3d3366f 7.759959144085814e-117
-      #|77599591440858125e-133 => 0x27d39189d3d3366e 7.759959144085812e-117
-      #|5551055266700981e-247 => 0x0feb93f571f3715a 5.551055266700981e-232
-      #|55510552667009815e-248 => 0x0feb93f571f3715b 5.5510552667009815e-232
-      #|8247423158557397e117 => 0x5b873d15a22cc852 8.247423158557396e+132
-      #|82474231585573955e116 => 0x5b873d15a22cc851 8.247423158557395e+132
-      #|6466246321686816e224 => 0x71b8d34b4163717a 6.466246321686816e+239
-      #|64662463216868185e223 => 0x71b8d34b4163717c 6.466246321686818e+239
-      #|5206238882622822e92 => 0x564c5ffeefe845a1 5.206238882622822e+107
-      #|52062388826228225e91 => 0x564c5ffeefe845a2 5.206238882622823e+107
-      #|8579243222953919e187 => 0x6a11833d137df314 8.579243222953918e+202
-      #|85792432229539195e186 => 0x6a11833d137df315 8.57924322295392e+202
-      #|7453843768624851e288 => 0x7f05bd1ef79d8cc0 7.453843768624851e+303
-      #|74538437686248505e287 => 0x7f05bd1ef79d8cc0 7.453843768624851e+303
-      #|6491411355558664e285 => 0x7e6362e03743ed5f 6.491411355558664e+300
-      #|64914113555586665e284 => 0x7e6362e03743ed61 6.491411355558666e+300
-      #|6980612947582390e-222 => 0x1521eddf210457fc 6.98061294758239e-207
-      #|69806129475823925e-223 => 0x1521eddf210457fe 6.980612947582393e-207
-      #|5874149158665844e-143 => 0x25845bdd3676ec54 5.874149158665844e-128
-      #|58741491586658455e-144 => 0x25845bdd3676ec55 5.874149158665845e-128
-      #|5811700489358454e15 => 0x465256a77e20f804 5.811700489358454e+30
-      #|58117004893584525e14 => 0x465256a77e20f803 5.811700489358453e+30
-      #|7318553798009832e6 => 0x4478cbd562fee19f 7.318553798009832e+21
-      #|73185537980098315e5 => 0x4478cbd562fee19f 7.318553798009832e+21
-      #|5007896658550760e-103 => 0x2dcfe10334feab6f 5.00789665855076e-88
-      #|50078966585507585e-104 => 0x2dcfe10334feab6d 5.0078966585507587e-88
-      #|7823250561086879e-217 => 0x162ea8ffce4a2e53 7.823250561086879e-202
-      #|78232505610868805e-218 => 0x162ea8ffce4a2e55 7.823250561086881e-202
-      #|6344826673643265e93 => 0x56859ce15c646cd2 6.344826673643265e+108
-      #|63448266736432635e92 => 0x56859ce15c646cd1 6.344826673643264e+108
-      #|8824932890967818e234 => 0x73d3b8a57d7c5585 8.824932890967819e+249
-      #|88249328909678175e233 => 0x73d3b8a57d7c5584 8.824932890967817e+249
-      #|6079345373338008e267 => 0x7aa4ee980275e146 6.079345373338008e+282
-      #|60793453733380065e266 => 0x7aa4ee980275e144 6.079345373338006e+282
-      #|5905688751472758e68 => 0x515374b71658b5a4 5.905688751472758e+83
-      #|59056887514727565e67 => 0x515374b71658b5a3 5.905688751472757e+83
-      #|8460557769326730e252 => 0x77906632672af28f 8.46055776932673e+267
-      #|84605577693267315e251 => 0x77906632672af290 8.460557769326732e+267
-      #|6917064715568631e228 => 0x72903566642efb74 6.917064715568631e+243
-      #|69170647155686315e227 => 0x72903566642efb74 6.917064715568631e+243
-      #|5989080829643252e-7 => 0x41c1d94e597b6f02 598908082.9643252
-      #|59890808296432505e-8 => 0x41c1d94e597b6f01 598908082.9643251
-      #|7626260075168607e-104 => 0x2da36b306d0ffb3e 7.626260075168607e-89
-      #|76262600751686055e-105 => 0x2da36b306d0ffb3d 7.626260075168606e-89
-      #|8020096608599812e213 => 0x6f7528ccef603383 8.020096608599812e+228
-      #|80200966085998135e212 => 0x6f7528ccef603384 8.020096608599813e+228
-      #|8216303089528086e-79 => 0x32d5a1c6feed32d3 8.216303089528087e-64
-      #|82163030895280855e-80 => 0x32d5a1c6feed32d2 8.216303089528085e-64
-      #|5493339502558223e-159 => 0x223126195ef0445e 5.493339502558223e-144
-      #|54933395025582245e-160 => 0x223126195ef04460 5.493339502558225e-144
-      #|8136187378835297e-2 => 0x42d27fe26577703e 81361873788352.97
-      #|81361873788352985e-3 => 0x42d27fe26577703f 81361873788352.98
-      #|5111109054514164e-70 => 0x34a910971e1bc285 5.111109054514164e-55
-      #|51111090545141655e-71 => 0x34a910971e1bc287 5.1111090545141656e-55
-      #|4820132354079803e-147 => 0x24ab5eeaee8a8fac 4.820132354079803e-132
-      #|48201323540798035e-148 => 0x24ab5eeaee8a8fad 4.8201323540798036e-132
-      #|6659923322881210e124 => 0x5cf65eca01d4a5e5 6.65992332288121e+139
-      #|66599233228812105e123 => 0x5cf65eca01d4a5e6 6.659923322881211e+139
-      #|8206471997446280e-89 => 0x30c28f390229cd5a 8.20647199744628e-74
-      #|82064719974462825e-90 => 0x30c28f390229cd5c 8.206471997446283e-74
-      #|6560283044910313e-264 => 0x0c677c235ae41e82 6.560283044910313e-249
-      #|65602830449103115e-265 => 0x0c677c235ae41e81 6.560283044910312e-249
-      #|8248207049091058e256 => 0x786384205bf6ef69 8.248207049091058e+271
-      #|82482070490910595e255 => 0x786384205bf6ef6a 8.24820704909106e+271
-      #|8311745274442023e138 => 0x5fe3d65ede1bcb1c 8.311745274442023e+153
-      #|83117452744420245e137 => 0x5fe3d65ede1bcb1d 8.311745274442024e+153
-      #|
-    ),
-  )
-}
-
-///|
-test "parse: exact halfway fractions in [2^52, 2^53)" {
-  // Doubles in [2^52, 2^53) are exactly the integers, so `.5` is a
-  // perfect tie (round-half-to-even), and the 20-digit neighbours force the
-  // slow path to decide the tie from truncated digits.
-  let rng = { s: 0x0123456789ABCDEFUL }
-  let b = StringBuilder::new()
-  for _ in 0..<20 {
-    let m = 4503599627370496UL + rng.next() % 4503599627370496UL
-    let base = m.to_string()
-    for suffix in [".5", ".49999999999999999999", ".50000000000000000001"] {
-      let s = base + suffix
-      b.write_string(s + " => " + parse_probe(s) + "\n")
-    }
-  }
-  inspect(
-    b.to_string(),
-    content=(
-      #|7379954871282333.5 => 0x433a3807a48faa9e 7379954871282334
-      #|7379954871282333.49999999999999999999 => 0x433a3807a48faa9d 7379954871282333
-      #|7379954871282333.50000000000000000001 => 0x433a3807a48faa9e 7379954871282334
-      #|5438851113930899.5 => 0x4333529b34a1d094 5438851113930900
-      #|5438851113930899.49999999999999999999 => 0x4333529b34a1d093 5438851113930899
-      #|5438851113930899.50000000000000000001 => 0x4333529b34a1d094 5438851113930900
-      #|4705010397859006.5 => 0x4330b72e996dccbe 4705010397859006
-      #|4705010397859006.49999999999999999999 => 0x4330b72e996dccbe 4705010397859006
-      #|4705010397859006.50000000000000000001 => 0x4330b72e996dccbf 4705010397859007
-      #|5657207647922156.5 => 0x433419334c4667ec 5657207647922156
-      #|5657207647922156.49999999999999999999 => 0x433419334c4667ec 5657207647922156
-      #|5657207647922156.50000000000000000001 => 0x433419334c4667ed 5657207647922157
-      #|4588163583672328.5 => 0x43304ce914938008 4588163583672328
-      #|4588163583672328.49999999999999999999 => 0x43304ce914938008 4588163583672328
-      #|4588163583672328.50000000000000000001 => 0x43304ce914938009 4588163583672329
-      #|7977283984510066.5 => 0x433c574c2a2b4c72 7977283984510066
-      #|7977283984510066.49999999999999999999 => 0x433c574c2a2b4c72 7977283984510066
-      #|7977283984510066.50000000000000000001 => 0x433c574c2a2b4c73 7977283984510067
-      #|7981425243491333.5 => 0x433c5b1060708c06 7981425243491334
-      #|7981425243491333.49999999999999999999 => 0x433c5b1060708c05 7981425243491333
-      #|7981425243491333.50000000000000000001 => 0x433c5b1060708c06 7981425243491334
-      #|4877842938504785.5 => 0x4331545f4f9ea652 4877842938504786
-      #|4877842938504785.49999999999999999999 => 0x4331545f4f9ea651 4877842938504785
-      #|4877842938504785.50000000000000000001 => 0x4331545f4f9ea652 4877842938504786
-      #|5870631636688411.5 => 0x4334db4ef14fde1c 5870631636688412
-      #|5870631636688411.49999999999999999999 => 0x4334db4ef14fde1b 5870631636688411
-      #|5870631636688411.50000000000000000001 => 0x4334db4ef14fde1c 5870631636688412
-      #|4732735251016935.5 => 0x4330d065cb73ece8 4732735251016936
-      #|4732735251016935.49999999999999999999 => 0x4330d065cb73ece7 4732735251016935
-      #|4732735251016935.50000000000000000001 => 0x4330d065cb73ece8 4732735251016936
-      #|6977284336704015.5 => 0x4338c9cd9a62da10 6977284336704016
-      #|6977284336704015.49999999999999999999 => 0x4338c9cd9a62da0f 6977284336704015
-      #|6977284336704015.50000000000000000001 => 0x4338c9cd9a62da10 6977284336704016
-      #|8550890395512300.5 => 0x433e60fd5089adec 8550890395512300
-      #|8550890395512300.49999999999999999999 => 0x433e60fd5089adec 8550890395512300
-      #|8550890395512300.50000000000000000001 => 0x433e60fd5089aded 8550890395512301
-      #|7465351326955335.5 => 0x433a85b28df77748 7465351326955336
-      #|7465351326955335.49999999999999999999 => 0x433a85b28df77747 7465351326955335
-      #|7465351326955335.50000000000000000001 => 0x433a85b28df77748 7465351326955336
-      #|6410805923787067.5 => 0x4336c69811cfb13c 6410805923787068
-      #|6410805923787067.49999999999999999999 => 0x4336c69811cfb13b 6410805923787067
-      #|6410805923787067.50000000000000000001 => 0x4336c69811cfb13c 6410805923787068
-      #|8597478304659919.5 => 0x433e8b5c685039d0 8597478304659920
-      #|8597478304659919.49999999999999999999 => 0x433e8b5c685039cf 8597478304659919
-      #|8597478304659919.50000000000000000001 => 0x433e8b5c685039d0 8597478304659920
-      #|7824818871845877.5 => 0x433bcca19d49c3f6 7824818871845878
-      #|7824818871845877.49999999999999999999 => 0x433bcca19d49c3f5 7824818871845877
-      #|7824818871845877.50000000000000000001 => 0x433bcca19d49c3f6 7824818871845878
-      #|6971881190807896.5 => 0x4338c4e395cb5958 6971881190807896
-      #|6971881190807896.49999999999999999999 => 0x4338c4e395cb5958 6971881190807896
-      #|6971881190807896.50000000000000000001 => 0x4338c4e395cb5959 6971881190807897
-      #|6960727120698628.5 => 0x4338babe93685d04 6960727120698628
-      #|6960727120698628.49999999999999999999 => 0x4338babe93685d04 6960727120698628
-      #|6960727120698628.50000000000000000001 => 0x4338babe93685d05 6960727120698629
-      #|8780668264619136.5 => 0x433f31f8a4cd9c80 8780668264619136
-      #|8780668264619136.49999999999999999999 => 0x433f31f8a4cd9c80 8780668264619136
-      #|8780668264619136.50000000000000000001 => 0x433f31f8a4cd9c81 8780668264619137
-      #|8332043669572524.5 => 0x433d99f3172d8bac 8332043669572524
-      #|8332043669572524.49999999999999999999 => 0x433d99f3172d8bac 8332043669572524
-      #|8332043669572524.50000000000000000001 => 0x433d99f3172d8bad 8332043669572525
-      #|
-    ),
-  )
-}
-
-///|
-test "print: structured bit patterns" {
-  // Exponent fields: subnormals, smallest normals, around 1.0, the exact
-  // integer range (0x432/0x433), the largest normals, and inf/nan.
-  let exp_fields : Array[UInt64] = [
-    0x000, 0x001, 0x3FE, 0x3FF, 0x432, 0x433, 0x7FD, 0x7FE, 0x7FF,
-  ]
-  let sig_fields : Array[UInt64] = [
-    0x0000000000000UL, 0x0000000000001UL, 0x0000000000002UL, 0x8000000000000UL, 0xFFFFFFFFFFFFFUL,
-    0xFFFFFFFFFFFFEUL, 0x5555555555555UL, 0xAAAAAAAAAAAAAUL,
-  ]
-  let b = StringBuilder::new()
-  for e in exp_fields {
-    for m in sig_fields {
-      b.write_string(print_probe((e << 52) | m))
-      b.write_char('\n')
-    }
-  }
-  // A few negative representatives.
-  for
-    bits in [
-      0x8000000000000001UL, // -min subnormal
-       0x800FFFFFFFFFFFFFUL, // -max subnormal
-       0xBFF0000000000000UL, // -1.0
-       0xFFEFFFFFFFFFFFFFUL, // -max finite
-       0xFFF0000000000000UL, // -inf
-    ] {
-    b.write_string(print_probe(bits))
-    b.write_char('\n')
-  }
-  inspect(
-    b.to_string(),
-    content=(
-      #|0x0000000000000000 => 0 => 0x0000000000000000
-      #|0x0000000000000001 => 5e-324 => 0x0000000000000001
-      #|0x0000000000000002 => 1e-323 => 0x0000000000000002
-      #|0x0008000000000000 => 1.1125369292536007e-308 => 0x0008000000000000
-      #|0x000fffffffffffff => 2.225073858507201e-308 => 0x000fffffffffffff
-      #|0x000ffffffffffffe => 2.2250738585072004e-308 => 0x000ffffffffffffe
-      #|0x0005555555555555 => 7.41691286169067e-309 => 0x0005555555555555
-      #|0x000aaaaaaaaaaaaa => 1.483382572338134e-308 => 0x000aaaaaaaaaaaaa
-      #|0x0010000000000000 => 2.2250738585072014e-308 => 0x0010000000000000
-      #|0x0010000000000001 => 2.225073858507202e-308 => 0x0010000000000001
-      #|0x0010000000000002 => 2.2250738585072024e-308 => 0x0010000000000002
-      #|0x0018000000000000 => 3.337610787760802e-308 => 0x0018000000000000
-      #|0x001fffffffffffff => 4.4501477170144023e-308 => 0x001fffffffffffff
-      #|0x001ffffffffffffe => 4.450147717014402e-308 => 0x001ffffffffffffe
-      #|0x0015555555555555 => 2.9667651446762683e-308 => 0x0015555555555555
-      #|0x001aaaaaaaaaaaaa => 3.7084564308453353e-308 => 0x001aaaaaaaaaaaaa
-      #|0x3fe0000000000000 => 0.5 => 0x3fe0000000000000
-      #|0x3fe0000000000001 => 0.5000000000000001 => 0x3fe0000000000001
-      #|0x3fe0000000000002 => 0.5000000000000002 => 0x3fe0000000000002
-      #|0x3fe8000000000000 => 0.75 => 0x3fe8000000000000
-      #|0x3fefffffffffffff => 0.9999999999999999 => 0x3fefffffffffffff
-      #|0x3feffffffffffffe => 0.9999999999999998 => 0x3feffffffffffffe
-      #|0x3fe5555555555555 => 0.6666666666666666 => 0x3fe5555555555555
-      #|0x3feaaaaaaaaaaaaa => 0.8333333333333333 => 0x3feaaaaaaaaaaaaa
-      #|0x3ff0000000000000 => 1 => 0x3ff0000000000000
-      #|0x3ff0000000000001 => 1.0000000000000002 => 0x3ff0000000000001
-      #|0x3ff0000000000002 => 1.0000000000000004 => 0x3ff0000000000002
-      #|0x3ff8000000000000 => 1.5 => 0x3ff8000000000000
-      #|0x3fffffffffffffff => 1.9999999999999998 => 0x3fffffffffffffff
-      #|0x3ffffffffffffffe => 1.9999999999999996 => 0x3ffffffffffffffe
-      #|0x3ff5555555555555 => 1.3333333333333333 => 0x3ff5555555555555
-      #|0x3ffaaaaaaaaaaaaa => 1.6666666666666665 => 0x3ffaaaaaaaaaaaaa
-      #|0x4320000000000000 => 2251799813685248 => 0x4320000000000000
-      #|0x4320000000000001 => 2251799813685248.5 => 0x4320000000000001
-      #|0x4320000000000002 => 2251799813685249 => 0x4320000000000002
-      #|0x4328000000000000 => 3377699720527872 => 0x4328000000000000
-      #|0x432fffffffffffff => 4503599627370495.5 => 0x432fffffffffffff
-      #|0x432ffffffffffffe => 4503599627370495 => 0x432ffffffffffffe
-      #|0x4325555555555555 => 3002399751580330.5 => 0x4325555555555555
-      #|0x432aaaaaaaaaaaaa => 3752999689475413 => 0x432aaaaaaaaaaaaa
-      #|0x4330000000000000 => 4503599627370496 => 0x4330000000000000
-      #|0x4330000000000001 => 4503599627370497 => 0x4330000000000001
-      #|0x4330000000000002 => 4503599627370498 => 0x4330000000000002
-      #|0x4338000000000000 => 6755399441055744 => 0x4338000000000000
-      #|0x433fffffffffffff => 9007199254740991 => 0x433fffffffffffff
-      #|0x433ffffffffffffe => 9007199254740990 => 0x433ffffffffffffe
-      #|0x4335555555555555 => 6004799503160661 => 0x4335555555555555
-      #|0x433aaaaaaaaaaaaa => 7505999378950826 => 0x433aaaaaaaaaaaaa
-      #|0x7fd0000000000000 => 4.49423283715579e+307 => 0x7fd0000000000000
-      #|0x7fd0000000000001 => 4.494232837155791e+307 => 0x7fd0000000000001
-      #|0x7fd0000000000002 => 4.494232837155792e+307 => 0x7fd0000000000002
-      #|0x7fd8000000000000 => 6.741349255733685e+307 => 0x7fd8000000000000
-      #|0x7fdfffffffffffff => 8.988465674311579e+307 => 0x7fdfffffffffffff
-      #|0x7fdffffffffffffe => 8.988465674311578e+307 => 0x7fdffffffffffffe
-      #|0x7fd5555555555555 => 5.992310449541053e+307 => 0x7fd5555555555555
-      #|0x7fdaaaaaaaaaaaaa => 7.490388061926316e+307 => 0x7fdaaaaaaaaaaaaa
-      #|0x7fe0000000000000 => 8.98846567431158e+307 => 0x7fe0000000000000
-      #|0x7fe0000000000001 => 8.988465674311582e+307 => 0x7fe0000000000001
-      #|0x7fe0000000000002 => 8.988465674311584e+307 => 0x7fe0000000000002
-      #|0x7fe8000000000000 => 1.348269851146737e+308 => 0x7fe8000000000000
-      #|0x7fefffffffffffff => 1.7976931348623157e+308 => 0x7fefffffffffffff
-      #|0x7feffffffffffffe => 1.7976931348623155e+308 => 0x7feffffffffffffe
-      #|0x7fe5555555555555 => 1.1984620899082105e+308 => 0x7fe5555555555555
-      #|0x7feaaaaaaaaaaaaa => 1.4980776123852631e+308 => 0x7feaaaaaaaaaaaaa
-      #|0x7ff0000000000000 => Infinity => 0x7ff0000000000000
-      #|0x7ff0000000000001 => NaN => 0x7ff8000000000001
-      #|0x7ff0000000000002 => NaN => 0x7ff8000000000001
-      #|0x7ff8000000000000 => NaN => 0x7ff8000000000001
-      #|0x7fffffffffffffff => NaN => 0x7ff8000000000001
-      #|0x7ffffffffffffffe => NaN => 0x7ff8000000000001
-      #|0x7ff5555555555555 => NaN => 0x7ff8000000000001
-      #|0x7ffaaaaaaaaaaaaa => NaN => 0x7ff8000000000001
-      #|0x8000000000000001 => -5e-324 => 0x8000000000000001
-      #|0x800fffffffffffff => -2.225073858507201e-308 => 0x800fffffffffffff
-      #|0xbff0000000000000 => -1 => 0xbff0000000000000
-      #|0xffefffffffffffff => -1.7976931348623157e+308 => 0xffefffffffffffff
-      #|0xfff0000000000000 => -Infinity => 0xfff0000000000000
-      #|
-    ),
-  )
-}
-
-///|
-test "print: random doubles, snapshot and parse-print roundtrip" {
-  let rng = { s: 0xF00DF00DF00DF00DUL }
-  let b = StringBuilder::new()
-  for i in 0..<100 {
-    let mut bits = rng.next()
-    if ((bits >> 52) & 0x7FFUL) == 0x7FFUL {
-      bits = bits ^ (1UL << 62) // avoid inf/nan
-    }
-    if i % 3 == 0 {
-      bits = bits & 0x800FFFFFFFFFFFFFUL // subnormal
-    }
-    let d = bits.reinterpret_as_double()
-    let s = d.to_string()
-    b.write_string(hex16(bits) + " => " + s + "\n")
-    // Shortest-representation guarantee: parsing the printed string must give
-    // back the exact same bits (carving out -0, which prints as "0").
-    if bits != 0x8000000000000000UL {
-      assert_eq(reparse_bits(s), hex16(bits))
-    }
-  }
-  inspect(
-    b.to_string(),
-    content=(
-      #|0x0007c46be20e80c3 => 1.0801720012480097e-308
-      #|0x815d8981f3534dd2 => -4.3071777111461805e-302
-      #|0x8d556de273c67973 => -1.9615086614278688e-244
-      #|0x800373e5fff21227 => -4.801609631370286e-309
-      #|0x641c37336af1ed63 => 1.7446456702150113e+174
-      #|0x73a670b35fa8a455 => 1.255202718877545e+249
-      #|0x000ffb26ee4a40da => 2.2224403149320393e-308
-      #|0xc95acd1a6ab328f6 => -2.390745531791066e+45
-      #|0xa3515c1fd0b6540b => -1.4577673979115943e-138
-      #|0x000f2797d5fde582 => 2.107514943479682e-308
-      #|0x4deb5c0f01904158 => 2.3050420750034607e+67
-      #|0xa9815745997fbf19 => -9.229610037141704e-109
-      #|0x0008c2daaf876d6a => 1.218387778266606e-308
-      #|0x0031ae2828698a87 => 9.834993479949858e-308
-      #|0x738de3e1f86770cf => 4.179793040146483e+248
-      #|0x80036e05372d5b20 => -4.76967817288755e-309
-      #|0x0eb6f01ba61612ed => 8.806381662987168e-238
-      #|0xc28faae248fe1338 => -4352350166978.4023
-      #|0x80062f29844da198 => -8.60022648792232e-309
-      #|0x533090fdda6780ac => 5.399407807947039e+92
-      #|0xab02f4016742fea5 => -1.6924380762458583e-101
-      #|0x800ad72ba8695c47 => -1.507558451691954e-308
-      #|0x429d178d8a4759c6 => 7996749025750.443
-      #|0xef036901025af29e => -5.747726947785295e+226
-      #|0x80086ea190e8897c => -1.1726351732014977e-308
-      #|0x63e3b88eaf41b634 => 1.5242452626104103e+173
-      #|0x83d70d0ed266adcd => -3.6958479780263125e-290
-      #|0x800effe6488fd1cb => -2.0859521719279965e-308
-      #|0xae0576eea1d29287 => -5.3950271085579e-87
-      #|0x561cbe28522d46e0 => 6.592157832607865e+106
-      #|0x800bd668656a5d37 => -1.6462112233358277e-308
-      #|0xe994fd88049fa2aa => -4.0167698525295605e+200
-      #|0x6dac91154f574577 => 2.0168212066708758e+220
-      #|0x00042975bd29a59e => 5.7879077393451e-309
-      #|0x710b3b81d9f51c52 => 3.4634851546597005e+236
-      #|0x0256bde4a0e9b957 => 2.1733444010777703e-297
-      #|0x800ac05a8681b769 => -1.4951635932379846e-308
-      #|0xa940e58a37370e3b => -5.620719971284912e-110
-      #|0xc68c900c306627ed => -7.241501207940949e+31
-      #|0x8000a17da3dede10 => -8.77267863250466e-310
-      #|0x185adee1752eba3b => 2.3558117213296455e-191
-      #|0xb176470288ac1b45 => -2.017368258564087e-70
-      #|0x800d18b0d388fb8d => -1.821285276860092e-308
-      #|0x22dca3cafbbca3e1 => 9.394469523123814e-141
-      #|0x478db183ed99a952 => 4.933665530251465e+36
-      #|0x0002a8c2d4a9e5c9 => 3.69810457253575e-309
-      #|0x6f63fd434c5d7d28 => 3.788301091410593e+228
-      #|0x2f6153300d29aa57 => 1.8264307288171104e-80
-      #|0x000973ea0b0f5119 => 1.3145722401868545e-308
-      #|0x8413c77df144f82b => -5.0740451914306585e-289
-      #|0xf8e0ee7d76dfa5c9 => -1.8319099300098468e+274
-      #|0x80031c761464f301 => -4.326623788526383e-309
-      #|0x29771e2f38bcb698 => 6.1521952525074115e-109
-      #|0xcd92d401edc31b10 => -4.957090227417967e+65
-      #|0x800467fcb6a8aad7 => -6.12757506649973e-309
-      #|0x31ba505f7809cd4f => 3.812651960476203e-69
-      #|0x8289650fb39fd101 => -1.941505288076566e-296
-      #|0x800eedfb736298ed => -2.076218932199568e-308
-      #|0xfd233632d4d74ada => -6.134961835458884e+294
-      #|0x58e23c1a077216d1 => 1.4714605693404418e+120
-      #|0x800ba0e4d39e8ad0 => -1.6171407944841497e-308
-      #|0x4804ecbcb337d42d => 8.900406165259934e+38
-      #|0x214010c959cfe12b => 1.570537639455502e-148
-      #|0x000b863d739e2284 => 1.6026616214407804e-308
-      #|0x5d281db2ad83b573 => 5.743721919386511e+140
-      #|0x2136a5dce3638a5a => 1.1070063771744163e-148
-      #|0x00057b5c065db25f => 7.623482606302756e-309
-      #|0xe989d399bc2db166 => -2.47111965038449e+200
-      #|0xc4cd4821922b5233 => -2.765584252402174e+23
-      #|0x000b34c13796edc9 => 1.5583962916651584e-308
-      #|0xf5f684cbd84382ef => -1.7311722000675536e+260
-      #|0xfb65519f423e4742 => -2.536117609230091e+286
-      #|0x800c04912b4d5852 => -1.6712863658933804e-308
-      #|0x29ff8666777478ad => 2.1477150125553315e-106
-      #|0xf0ddfeffb02d107b => -4.768713815913437e+235
-      #|0x000b6b3e62b65ade => 1.587996368399862e-308
-      #|0x2e624681a5c503ce => 2.939804847464298e-85
-      #|0x239f096d79ab34a6 => 4.1700311322004474e-137
-      #|0x000cf87f4b345bf4 => 1.803796779496454e-308
-      #|0x96705a04f105d3be => -1.3351318546951285e-200
-      #|0x034c37f3b8e108ab => 8.836679756072912e-293
-      #|0x800e1ca9efd041da => -1.9625106971373003e-308
-      #|0x6c40ca73f77c49ce => 2.826305865066686e+213
-      #|0x0562f73f0e775689 => 1.0203411034714472e-282
-      #|0x800f0057ff635b32 => -2.086193472908124e-308
-      #|0xba714097e31a8fd9 => -3.4840777957925955e-27
-      #|0x3cbbacb9dace471e => 3.8406376576006924e-16
-      #|0x0009e61248331e3a => 1.376585951973046e-308
-      #|0x2009cb90c96beca4 => 2.404865336309037e-154
-      #|0x267a133261ed2f2f => 2.4652779754456173e-123
-      #|0x0001557d363842a5 => 1.855074434721203e-309
-      #|0x478471091502f13c => 3.3964342083876504e+36
-      #|0x5b7ba00a2b04358b => 4.902107551038494e+132
-      #|0x8000d1500831a429 => -1.1370509038279e-309
-      #|0x68ed27c5cee59a23 => 2.724241573884183e+197
-      #|0xa8637ad384795a31 => -3.955085921963507e-114
-      #|0x80077839edf5144b => -1.0387804499947974e-308
-      #|0xe9b177110153d207 => -1.3368672300808775e+201
-      #|0x8039f9bca5c65a8d => -1.4449370851762422e-307
-      #|0x800df39dc3bdc529 => -1.940212400049216e-308
-      #|
-    ),
-  )
-}
-
-///|
-/// FNV-1a style digest over 20000 pseudo-random conversions. A single hash
-/// per direction keeps the snapshot tiny while giving the cross-target
-/// comparison broad coverage; any backend whose parser or printer deviates on
-/// any of the 20000 cases fails the snapshot.
-#warnings("-deprecated")
-test "digest: 20000 random parse/print cases" {
-  let rng = { s: 0xABCDEF0123456789UL }
-  let fnv_prime = 0x100000001B3UL
-  let mut parse_digest = 0xCBF29CE484222325UL
-  let mut print_digest = 0xCBF29CE484222325UL
-  for i in 0..<20000 {
-    let a = rng.next()
-    let b = rng.next()
-    if i % 4 == 2 {
-      // print side: random finite double -> shortest string
-      let mut bits = a
-      if ((bits >> 52) & 0x7FFUL) == 0x7FFUL {
-        bits = bits ^ (1UL << 62)
-      }
-      let s = bits.reinterpret_as_double().to_string()
-      for c in s.code_units() {
-        print_digest = (print_digest ^ c.to_uint64()) * fnv_prime
-      }
-      continue
-    }
-    let s = if i % 4 == 0 {
-      // up to 19 significant digits across the whole exponent range
-      (a % 10000000000000000000UL).to_string() +
-      "e" +
-      ((b % 700UL).to_int() - 350).to_string()
-    } else if i % 4 == 1 {
-      // 17-digit significands ending in 5: halfway torture
-      (4503599627370496UL + a % 4503599627370496UL).to_string() +
-      "5e" +
-      ((b % 60UL).to_int() - 30).to_string()
-    } else {
-      // subnormal and underflow range
-      (a % 1000000000000000000UL).to_string() +
-      "e-" +
-      (300 + (b % 40UL).to_int()).to_string()
-    }
-    let r = match (try? @strconv.parse_double(s)) {
-      Ok(d) => d.reinterpret_as_uint64()
-      Err(StrConvError(msg)) =>
-        if msg == "value out of range" {
-          0xFFFFFFFFFFFFFFFDUL
-        } else {
-          0xFFFFFFFFFFFFFFFEUL
-        }
-    }
-    parse_digest = (parse_digest ^ r) * fnv_prime
-  }
-  inspect(
-    parse_digest.to_string(radix=16),
-    content=(
-      #|c6b32dd983490f11
-    ),
-  )
-  inspect(
-    print_digest.to_string(radix=16),
-    content=(
-      #|8219a803b391f85c
-    ),
-  )
-}
-
-///|
-test "property: value-preserving syntactic transforms" {
-  // Leading zeros, a leading '+', trailing fraction zeros, shifting the
-  // decimal point while adjusting the exponent -- none of these may change
-  // the parsed value (or the error).
-  let rng = { s: 0x5EEDBA5EBA115EEDUL }
-  for _ in 0..<120 {
-    let m = rng.next() % 10000000000000000000UL
-    let e = (rng.next() % 641UL).to_int() - 320
-    let ms = m.to_string()
-    let base = ms + "e" + e.to_string()
-    let expected = parse_probe(base)
-    assert_eq(parse_probe("000" + base), expected)
-    assert_eq(parse_probe("+" + base), expected)
-    assert_eq(parse_probe(ms + ".000e" + e.to_string()), expected)
-    assert_eq(parse_probe(ms + "00e" + (e - 2).to_string()), expected)
-    assert_eq(
-      parse_probe("0." + ms + "e" + (e + ms.length()).to_string()),
-      expected,
-    )
-    assert_eq(
-      parse_probe("." + ms + "e" + (e + ms.length()).to_string()),
-      expected,
-    )
-  }
-}
-
-///|
-#warnings("-deprecated")
-test "property: json number parsing agrees with strconv" {
-  let rng = { s: 0x1234ABCD5678EF90UL }
-  let inputs : Array[String] = [
-    "9007199254740993", "0.1", "1e308", "1e-324", "-2.2250738585072011e-308", "123456789012345678901234567890",
-    "-0.000000000000000000000000000001",
-  ]
-  for _ in 0..<100 {
-    let m = rng.next() % 10000000000000000000UL
-    let e = (rng.next() % 621UL).to_int() - 320
-    inputs.push(m.to_string() + "e" + e.to_string())
-  }
-  for s in inputs {
-    let json_bits = match (try? @json.parse(s)) {
-      Ok(Number(d, ..)) => d.reinterpret_as_uint64()
-      Ok(_) => fail("json parse of \{s} did not produce a number")
-      Err(err) => fail("json parse of \{s} failed: \{err}")
-    }
-    match (try? @strconv.parse_double(s)) {
-      Ok(d) => assert_eq(hex16(json_bits), hex16(d.reinterpret_as_uint64()))
-      Err(_) =>
-        // out-of-range literal: json falls back to +/- infinity
-        assert_true(
-          json_bits.reinterpret_as_double() == @double.infinity ||
-          json_bits.reinterpret_as_double() == @double.neg_infinity,
-        )
-    }
-  }
-}
-
-///|
-#warnings("-deprecated")
-test "property: public @string.parse_double agrees with @strconv" {
-  // @string.parse_double routes to internal/strconv, a separate copy of the
-  // parser; both copies must agree bit-for-bit (and on error-ness).
-  let all = classic_inputs + boundary_inputs
-  for c in long_digit_cases() {
-    all.push(c.1)
-  }
-  for s in all {
-    let a = match (try? @strconv.parse_double(s)) {
-      Ok(d) => hex16(d.reinterpret_as_uint64())
-      Err(_) => "error"
-    }
-    let b = match (try? @string.parse_double(s)) {
-      Ok(d) => hex16(d.reinterpret_as_uint64())
-      Err(_) => "error"
-    }
-    assert_eq(a, b)
-  }
-}
diff --git a/strconv/double_test.mbt b/strconv/double_test.mbt
deleted file mode 100644
index 97bc87559b..0000000000
--- a/strconv/double_test.mbt
+++ /dev/null
@@ -1,60 +0,0 @@
-// Copyright 2026 International Digital Economy Academy
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-///|
-#warnings("-deprecated")
-test "try_fast_path overflow when shift is too large" {
-  // When the shift (exponent - max_exponent_fast_path) is too large,
-  // the multiplication of mantissa with int_pow10[shift] will overflow,
-  // triggering line 130
-  let result = @strconv.parse_double("9007199254740992e30")
-  // The function successfully falls back to slow path
-  inspect(result, content="9.007199254740992e+45")
-}
-
-///|
-#warnings("-deprecated")
-test "try_fast_path overflow when mantissa is too large" {
-  // When the mantissa after shifting is larger than max_mantissa_fast_path,
-  // line 133 will be triggered
-  let result = @strconv.parse_double("9007199254740992e23")
-  // The function successfully falls back to slow path
-  inspect(result, content="9.007199254740991e+38")
-}
-
-///|
-#warnings("-deprecated")
-test "corner cases" {
-  inspect(try? @strconv.parse_double(".123"), content="Ok(0.123)")
-  inspect(try? @strconv.parse_double("."), content="Err(invalid syntax)")
-  inspect(try? @strconv.parse_double("-"), content="Err(invalid syntax)")
-}
-
-///|
-#warnings("-deprecated")
-test "parse_double infinity and NaN with trailing characters should error" {
-  // These should trigger the uncovered line 84 in parse_double
-  // parse_inf_nan succeeds but doesn't consume the entire string
-  inspect(try? @strconv.parse_double("infabc"), content="Err(invalid syntax)")
-  inspect(try? @strconv.parse_double("nanxyz"), content="Err(invalid syntax)")
-  inspect(
-    try? @strconv.parse_double("+infinity123"),
-    content="Err(invalid syntax)",
-  )
-  inspect(
-    try? @strconv.parse_double("-inf_extra"),
-    content="Err(invalid syntax)",
-  )
-  inspect(try? @strconv.parse_double("NaN!"), content="Err(invalid syntax)")
-}
diff --git a/strconv/errors.mbt b/strconv/errors.mbt
deleted file mode 100644
index 3857e5b852..0000000000
--- a/strconv/errors.mbt
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright 2026 International Digital Economy Academy
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-///|
-/// Error type `StrConvError`.
-#deprecated("use `@string` parsing APIs instead", skip_current_package=true)
-pub(all) suberror StrConvError {
-  StrConvError(String)
-} derive(@debug.Debug)
-
-///|
-pub impl Show for StrConvError with fn output(self, logger) {
-  match self {
-    StrConvError(err) => logger.write_string(err)
-  }
-}
-
-///|
-let range_err_str = "value out of range"
-
-///|
-let syntax_err_str = "invalid syntax"
-
-///|
-let base_err_str = "invalid base"
-
-///|
-fn[T] range_err() -> T raise StrConvError {
-  raise StrConvError(range_err_str)
-}
-
-///|
-fn[T] syntax_err() -> T raise StrConvError {
-  raise StrConvError(syntax_err_str)
-}
-
-///|
-fn[T] base_err() -> T raise StrConvError {
-  raise StrConvError(base_err_str)
-}
diff --git a/strconv/int.mbt b/strconv/int.mbt
deleted file mode 100644
index 64b1cbe07c..0000000000
--- a/strconv/int.mbt
+++ /dev/null
@@ -1,224 +0,0 @@
-// Copyright 2026 International Digital Economy Academy
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-///|
-const INT_MIN = 0x80000000
-
-///|
-const INT_MAX = 0x7fffffff
-
-///|
-const INT64_MIN = -0x8000000000000000L
-
-///|
-const INT64_MAX = 0x7fffffffffffffffL
-
-///|
-/// This function check whether the prefix of the string is consistent with the given base,
-/// and consume the prefix.
-/// The boolean flag `allow_underscore` is used to check validity of underscores.
-fn check_and_consume_base(
-  view : StringView,
-  base : Int,
-) -> (Int, StringView, Bool) raise StrConvError {
-  // if the base is not given, we need to determine it from the prefix
-  if base == 0 {
-    match view {
-      ['0', 'x' | 'X', .. rest] => (16, rest, true)
-      ['0', 'o' | 'O', .. rest] => (8, rest, true)
-      ['0', 'b' | 'B', .. rest] => (2, rest, true)
-      _ => (10, view, false)
-    }
-  } else {
-    // if the base is given, we need to check whether the prefix is consistent with it
-    match view {
-      ['0', 'x' | 'X', .. rest] if base == 16 => (16, rest, true)
-      ['0', 'o' | 'O', .. rest] if base == 8 => (8, rest, true)
-      ['0', 'b' | 'B', .. rest] if base == 2 => (2, rest, true)
-      _ => if base is (2..=36) { (base, view, false) } else { base_err() }
-    }
-  }
-}
-
-///|
-test {
-  inspect(try? parse_int64("0b01", base=3), content="Err(invalid syntax)")
-  inspect(try? parse_int64("0x01", base=3), content="Err(invalid syntax)")
-  inspect(try? parse_int64("0o01", base=3), content="Err(invalid syntax)")
-}
-
-///|
-/// Parses a string into an Int64 number using the specified base, or returns an error.
-/// The base must be 0 or between 2 and 36 (inclusive). If base is 0, it will be 
-/// inferred from the string prefix:
-///   - "0x" or "0X" for base 16 (hex)
-///   - "0o" or "0O" for base 8 (octal) 
-///   - "0b" or "0B" for base 2 (binary)
-///   - Default is base 10 (decimal)
-/// For readability, underscores may appear after base prefixes or between digits.
-/// These underscores do not affect the value.
-/// Examples:
-/// ```mbt check
-/// #warnings("-deprecated")
-/// test {
-///   inspect(@strconv.parse_int64("123"), content="123")
-///   inspect(@strconv.parse_int64("0xff", base=0), content="255")
-///   inspect(@strconv.parse_int64("0o10"), content="8")
-///   inspect(@strconv.parse_int64("0b1010"), content="10")
-///   inspect(@strconv.parse_int64("1_234"), content="1234")
-///   inspect(@strconv.parse_int64("-123"), content="-123")
-///   inspect(@strconv.parse_int64("ff", base=16), content="255")
-///   inspect(@strconv.parse_int64("zz", base=36), content="1295")
-/// }
-/// ```
-/// 
-#deprecated("use `@string.parse_int64` instead", skip_current_package=true)
-pub fn parse_int64(
-  str : StringView,
-  base? : Int = 0,
-) -> Int64 raise StrConvError {
-  guard str != "" else { syntax_err() }
-  let (neg, rest) = match str.view() {
-    ['+', .. rest] => (false, rest)
-    ['-', .. rest] => (true, rest)
-    rest => (false, rest)
-  }
-
-  // `allow_underscore` is used to check validity of underscores
-  let (num_base, rest, allow_underscore) = check_and_consume_base(rest, base)
-
-  // calculate overflow threshold
-  let overflow_threshold = overflow_threshold(num_base, neg)
-  let has_digit = rest
-    is (['0'..='9' | 'a'..='z' | 'A'..='Z', ..]
-    | ['_', '0'..='9' | 'a'..='z' | 'A'..='Z', ..])
-  guard has_digit else { syntax_err() }
-  // convert
-  for s = rest, acc = 0L, au = allow_underscore {
-    match (s, acc, au) {
-      (['_'], _, _) =>
-        // the last character cannot be underscore
-        syntax_err()
-      (['_', ..], _, false) => syntax_err()
-      (['_', .. rest], acc, true) => continue rest, acc, false
-      ([c, .. rest], acc, _) => {
-        let c = c.to_int()
-        let d = match c {
-          '0'..='9' => c - '0'
-          'a'..='z' => c + (10 - 'a')
-          'A'..='Z' => c + (10 - 'A')
-          _ => syntax_err()
-        }
-        guard d < num_base else { syntax_err() }
-        if neg {
-          guard acc >= overflow_threshold else { range_err() }
-          let next_acc = acc * num_base.to_int64() - d.to_int64()
-          guard next_acc <= acc else { range_err() }
-          continue rest, next_acc, true
-        } else {
-          guard acc < overflow_threshold else { range_err() }
-          let next_acc = acc * num_base.to_int64() + d.to_int64()
-          guard next_acc >= acc else { range_err() }
-          continue rest, next_acc, true
-        }
-      }
-      ([], acc, _) => break acc
-    }
-  }
-}
-
-///|
-/// Parse a string in the given base (0, 2 to 36), return a Int number or an error.
-/// If the `~base` argument is 0, the base will be inferred by the prefix.
-#deprecated("use `@string.parse_int` instead", skip_current_package=true)
-pub fn parse_int(str : StringView, base? : Int = 0) -> Int raise StrConvError {
-  let n = parse_int64(str, base~)
-  if n < INT_MIN.to_int64() || n > INT_MAX.to_int64() {
-    range_err()
-  }
-  n.to_int()
-}
-
-// Check whether the underscores are correct.
-// Underscores must appear only between digits or between a base prefix and a digit.
-
-///|
-fn check_underscore(str : StringView) -> Bool {
-  // skip the sign
-  let rest = match str {
-    ['+' | '-', .. rest] => rest
-    rest => rest
-    // CR: the type maybe a bit confusing?
-  }
-
-  // base prefix
-  let (rest, allow_underscore, hex) = lexmatch rest with longest {
-    (re"^0[xX]", after=rest) => (rest, true, true)
-    (re"^0[oO]", after=rest) => (rest, true, false)
-    (re"^0[bB]", after=rest) => (rest, true, false)
-    _ => (rest, false, false)
-  }
-
-  // 'e' and 'E' are valid hex digits
-  // but are not treated as digits in decimal strings since they're used for scientific notation
-  fn is_digit(c : Char) -> Bool {
-    c is ('0'..='9') || (hex && c is ('a'..='f' | 'A'..='F'))
-  }
-
-  // Track whether the previous character was an underscore
-  let follow_underscore = false
-  for s = rest, au = allow_underscore, fu = follow_underscore {
-    match (s, au, fu) {
-      // Empty string is valid
-      ([], _, _) => break true
-      // String ending with underscore is invalid
-      (['_'], _, _) => break false
-      // Underscore not allowed in current position (e.g., between non-digits)
-      (['_', ..], false, _) => break false
-      // Valid underscore - continue but mark that next char must be a digit
-      (['_', .. rest], true, _) => continue rest, false, true
-      // Handle non-underscore character
-      ([c, .. rest], _, fu) =>
-        if is_digit(c) {
-          // Digit found - allow underscore in next position
-          continue rest, true, false
-        } else if fu {
-          // Non-digit found after underscore - invalid
-          break false
-        } else {
-          // Non-digit found (not after underscore) - continue but don't allow underscores
-          continue rest, false, false
-        }
-    }
-  }
-}
-
-///|
-fn overflow_threshold(base : Int, neg : Bool) -> Int64 {
-  if !neg {
-    if base == 10 {
-      INT64_MAX / 10L + 1L
-    } else if base == 16 {
-      INT64_MAX / 16L + 1L
-    } else {
-      INT64_MAX / base.to_int64() + 1L
-    }
-  } else if base == 10 {
-    INT64_MIN / 10L
-  } else if base == 16 {
-    INT64_MIN / 16L
-  } else {
-    INT64_MIN / base.to_int64()
-  }
-}
diff --git a/strconv/int_test.mbt b/strconv/int_test.mbt
deleted file mode 100644
index 161f313626..0000000000
--- a/strconv/int_test.mbt
+++ /dev/null
@@ -1,282 +0,0 @@
-// Copyright 2026 International Digital Economy Academy
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-///|
-let syntax_err = "invalid syntax"
-
-///|
-let range_err = "value out of range"
-
-///|
-let base_err = "invalid base"
-
-///|
-#warnings("-deprecated")
-fn parse_int64_as_result(s : String, base? : Int = 0) -> Result[Int64, String] {
-  try @strconv.parse_int64(s, base~) |> Ok catch {
-    StrConvError(err) => Err(err)
-  }
-}
-
-///|
-#warnings("-deprecated")
-fn parse_int_as_result(s : String, base? : Int = 0) -> Result[Int, String] {
-  try @strconv.parse_int(s, base~) |> Ok catch {
-    StrConvError(err) => Err(err)
-  }
-}
-
-///|
-test "parse_int64" {
-  let tests : Array[(String, Int, Result[Int64, String])] = [
-    // basic cases
-    ("", 0, Err(syntax_err)),
-    ("0", 0, Ok(0L)),
-    ("-0", 0, Ok(0L)),
-    ("+0", 0, Ok(0L)),
-    ("1", 0, Ok(1L)),
-    ("-1", 0, Ok(-1L)),
-    ("+1", 0, Ok(1L)),
-    ("12345", 0, Ok(12345L)),
-    ("-12345", 0, Ok(-12345L)),
-    ("012345", 0, Ok(12345L)),
-    ("-012345", 0, Ok(-12345L)),
-    ("0x12345", 0, Ok(0x12345L)),
-    ("-0x12345", 0, Ok(-0x12345L)),
-    ("9876543210", 0, Ok(9876543210L)),
-    ("-9876543210", 0, Ok(-9876543210L)),
-    // boundary values
-    ("9223372036854775807", 0, Ok(9223372036854775807L)),
-    ("-9223372036854775807", 0, Ok(-9223372036854775807L)),
-    ("9223372036854775808", 0, Err(range_err)),
-    ("-9223372036854775808", 0, Ok(-9223372036854775808L)),
-    ("9223372036854775809", 0, Err(range_err)),
-    ("-9223372036854775809", 0, Err(range_err)),
-    ("922_3372_0368_5477_5807", 0, Ok(9223372036854775807L)),
-    // invalid syntax
-    ("12345x", 0, Err(syntax_err)),
-    ("-12345x", 0, Err(syntax_err)),
-    ("12345%", 0, Err(syntax_err)),
-    (" 1", 0, Err(syntax_err)),
-    ("1 ", 0, Err(syntax_err)),
-    // sign only
-    ("+", 0, Err(syntax_err)),
-    ("-", 0, Err(syntax_err)),
-    // underscore handling
-    ("-1_2_3_4_5", 0, Ok(-12345L)),
-    ("-_12345", 0, Err(syntax_err)),
-    ("_12345", 0, Err(syntax_err)),
-    ("1__2345", 0, Err(syntax_err)),
-    ("12345_", 0, Err(syntax_err)),
-    ("_", 0, Err(syntax_err)),
-    ("+_", 0, Err(syntax_err)),
-    ("-_", 0, Err(syntax_err)),
-    ("-0_1_2_3_4_5", 0, Ok(-12345L)),
-    ("0_1_2_3_4_5", 0, Ok(12345L)),
-    ("-_012345", 0, Err(syntax_err)),
-    ("_-012345", 0, Err(syntax_err)),
-    ("_012345", 0, Err(syntax_err)),
-    ("0__12345", 0, Err(syntax_err)),
-    ("01234__5", 0, Err(syntax_err)),
-    ("012345_", 0, Err(syntax_err)),
-    // other bases
-    ("h", 18, Ok(17L)),
-    ("10", 25, Ok(25L)),
-    (
-      "moonbit",
-      35,
-      Ok(
-        (
-          ((((22L * 35L + 24L) * 35L + 24L) * 35L + 23L) * 35L + 11L) * 35L +
-          18L
-        ) *
-        35L +
-        29L,
-      ),
-    ),
-    (
-      "moonbit",
-      36,
-      Ok(
-        (
-          ((((22L * 36L + 24L) * 36L + 24L) * 36L + 23L) * 36L + 11L) * 36L +
-          18L
-        ) *
-        36L +
-        29L,
-      ),
-    ),
-    ("a", 11, Ok(10L)),
-    ("b", 11, Err(syntax_err)),
-    ("y", 35, Ok(34L)),
-    ("z", 35, Err(syntax_err)),
-    ("Y", 35, Ok(34L)),
-    ("Z", 35, Err(syntax_err)),
-    ("z", 36, Ok(35L)),
-    // base 2
-    ("0", 2, Ok(0L)),
-    ("-1", 2, Ok(-1L)),
-    ("1010", 2, Ok(10L)),
-    ("1000000000000000", 2, Ok(1L << 15)),
-    (
-      "111111111111111111111111111111111111111111111111111111111111111",
-      2,
-      Ok((1L << 63) - 1L),
-    ),
-    (
-      "1000000000000000000000000000000000000000000000000000000000000000",
-      2,
-      Err(range_err),
-    ),
-    (
-      "-1000000000000000000000000000000000000000000000000000000000000000",
-      2,
-      Ok(-1L << 63),
-    ),
-    (
-      "-1000000000000000000000000000000000000000000000000000000000000001",
-      2,
-      Err(range_err),
-    ),
-    // base 8
-    ("-10", 8, Ok(-8L)),
-    ("57635436545", 8, Ok(0o57635436545L)),
-    ("100000000", 8, Ok(1L << 24)),
-    ("777", 8, Ok(511L)),
-    // base 16
-    ("10", 16, Ok(16L)),
-    ("-123456789abcdef", 16, Ok(-0x123456789abcdefL)),
-    ("7fffffffffffffff", 16, Ok((1L << 63) - 1L)),
-    ("ff", 16, Ok(255L)),
-    ("A", 16, Ok(10L)),
-    ("a", 16, Ok(10L)),
-    ("Z", 36, Ok(35L)),
-    ("z", 36, Ok(35L)),
-    ("G", 16, Err(syntax_err)),
-    ("g", 16, Err(syntax_err)),
-    ("@", 16, Err(syntax_err)),
-    ("`", 16, Err(syntax_err)),
-    ("[", 16, Err(syntax_err)),
-    ("{", 16, Err(syntax_err)),
-    // hex prefix
-    ("0x", 0, Err(syntax_err)),
-    ("0x_", 0, Err(syntax_err)),
-    ("0x_DEADBEEF", 0, Ok(3735928559L)),
-    ("-0x_1_2_3_4_5", 0, Ok(-0x12345L)),
-    ("0x_1_2_3_4_5", 0, Ok(0x12345L)),
-    ("-_0x12345", 0, Err(syntax_err)),
-    ("_-0x12345", 0, Err(syntax_err)),
-    ("_0x12345", 0, Err(syntax_err)),
-    ("0x__12345", 0, Err(syntax_err)),
-    ("0x1__2345", 0, Err(syntax_err)),
-    ("0x1234__5", 0, Err(syntax_err)),
-    ("0x12345_", 0, Err(syntax_err)),
-    ("+0xf", 0, Ok(0xfL)),
-    ("-0xf", 0, Ok(-0xfL)),
-    ("0x+f", 0, Err(syntax_err)),
-    ("0x-f", 0, Err(syntax_err)),
-    ("0xFF", 0, Ok(255L)),
-    ("0Xff", 0, Ok(255L)),
-    ("0x_FF", 0, Ok(255L)),
-    ("+0x_FF", 0, Ok(255L)),
-    ("-0x_FF", 0, Ok(-255L)),
-    ("+0x", 0, Err(syntax_err)),
-    ("-0x", 0, Err(syntax_err)),
-    ("+0x_", 0, Err(syntax_err)),
-    ("0x10", 16, Ok(16L)),
-    ("0x_10", 16, Ok(16L)),
-    ("0x10", 8, Err(syntax_err)),
-    // binary prefix
-    ("0b1010", 0, Ok(10L)),
-    ("0B1010", 0, Ok(10L)),
-    ("0b_1010", 0, Ok(10L)),
-    ("0b_1_0_1_0", 0, Ok(10L)),
-    ("+0b", 0, Err(syntax_err)),
-    ("-0b", 0, Err(syntax_err)),
-    ("0b_", 0, Err(syntax_err)),
-    ("-0b_", 0, Err(syntax_err)),
-    ("0b1010", 2, Ok(10L)),
-    ("0b_1010", 2, Ok(10L)),
-    ("0b1010", 10, Err(syntax_err)),
-    ("0b01", 16, Ok(2817L)),
-    // octal prefix
-    ("0o777", 0, Ok(511L)),
-    ("0O77", 0, Ok(63L)),
-    ("0o_77", 0, Ok(63L)),
-    ("0o_7_7", 0, Ok(63L)),
-    ("+0o", 0, Err(syntax_err)),
-    ("-0o", 0, Err(syntax_err)),
-    ("0o_", 0, Err(syntax_err)),
-    ("+0o_", 0, Err(syntax_err)),
-    ("0o77", 8, Ok(63L)),
-    ("0o_77", 8, Ok(63L)),
-    ("0o77", 16, Err(syntax_err)),
-    ("0o01", 16, Err(syntax_err)),
-    ("0o01", 10, Err(syntax_err)),
-    ("0x01", 10, Err(syntax_err)),
-    // invalid base
-    ("12345", 1, Err(base_err)),
-    ("12345", 37, Err(base_err)),
-    ("12345", -1, Err(base_err)),
-    ("12345", 100, Err(base_err)),
-  ]
-  for t in tests {
-    assert_eq(parse_int64_as_result(t.0, base=t.1), t.2)
-  }
-}
-
-///|
-test "parse_int" {
-  let tests : Array[(String, Result[Int, String])] = [
-    ("", Err(syntax_err)),
-    ("0", Ok(0)),
-    ("-0", Ok(0)),
-    ("1", Ok(1)),
-    ("-1", Ok(-1)),
-    ("12345", Ok(12345)),
-    ("-12345", Ok(-12345)),
-    ("012345", Ok(12345)),
-    ("-012345", Ok(-12345)),
-    ("12345x", Err(syntax_err)),
-    ("-12345x", Err(syntax_err)),
-    ("987654321", Ok(987654321)),
-    ("-987654321", Ok(-987654321)),
-    ("2147483647", Ok((1 << 31) - 1)),
-    ("-2147483647", Ok(-((1 << 31) - 1))),
-    ("2147483648", Err(range_err)),
-    ("-2147483648", Ok(-1 << 31)),
-    ("2147483649", Err(range_err)),
-    ("-2147483649", Err(range_err)),
-    ("-1_2_3_4_5", Ok(-12345)),
-    ("-_12345", Err(syntax_err)),
-    ("_12345", Err(syntax_err)),
-    ("1__2345", Err(syntax_err)),
-    ("12345_", Err(syntax_err)),
-    ("123%45", Err(syntax_err)),
-  ]
-  for t in tests {
-    assert_eq(parse_int_as_result(t.0), t.1)
-  }
-}
-
-///|
-test "Debug for StrConvError" {
-  let err : @strconv.StrConvError = StrConvError("invalid syntax")
-  @debug.debug_inspect(
-    err,
-    content=(
-      #|StrConvError("invalid syntax")
-    ),
-  )
-}
diff --git a/strconv/number.mbt b/strconv/number.mbt
deleted file mode 100644
index 361227c39e..0000000000
--- a/strconv/number.mbt
+++ /dev/null
@@ -1,210 +0,0 @@
-// Copyright 2026 International Digital Economy Academy
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-///|
-let min_19digit_int : UInt64 = 100_0000_0000_0000_0000UL
-
-///|
-priv struct Number {
-  exponent : Int64
-  mantissa : UInt64
-  negative : Bool
-  many_digits : Bool
-}
-
-///|
-/// Returns the remaining slice, the parsed number, and the number of digits parsed.
-fn parse_digits(s : StringView, x : UInt64) -> (StringView, UInt64, Int) {
-  s.fold_digits(x, (digit, acc : UInt64) => {
-    acc * 10UL + UInt64::extend_uint(digit.reinterpret_as_uint())
-  })
-}
-
-///|
-fn try_parse_19digits(s : StringView, x : UInt64) -> (StringView, UInt64, Int) {
-  let mut x = x
-  let mut len = 0
-  for s = s {
-    match s {
-      ['0'..='9' as ch, .. rest] if x < min_19digit_int => {
-        len += 1
-        x = x * 10UL +
-          UInt64::extend_uint((ch.to_int() - '0').reinterpret_as_uint()) // no overflows here
-        continue rest
-      }
-      ['_', .. rest] => continue rest
-      s => return (s, x, len)
-    }
-  }
-}
-
-///|
-fn parse_scientific(s : StringView) -> (StringView, Int64)? {
-  let mut s = s
-  let exp_num = 0L
-  let mut neg_exp = false
-  if s is ['+' | '-' as ch, .. rest] {
-    neg_exp = ch == '-'
-    s = rest
-  }
-  if s is ['0'..='9', ..] {
-    let (s, exp_num, _) = s.fold_digits(exp_num, (digit, exp_num : Int64) => {
-      if exp_num < 0x10000L {
-        10L * exp_num + digit.to_int64() // no overflows here
-      } else {
-        exp_num
-      }
-    })
-    if neg_exp {
-      Some((s, -exp_num))
-    } else {
-      Some((s, exp_num))
-    }
-  } else {
-    None
-  }
-}
-
-///|
-/// Parse the number from the string, raising StrConvError if invalid.
-fn parse_number(s : StringView) -> Number? raise StrConvError {
-  let start = s
-
-  // handle optional +/- sign
-  let (s, negative) = match s {
-    ['-', .. rest] => (rest, true)
-    ['+', .. rest] | rest => (rest, false)
-  }
-  if s.is_empty() {
-    return None
-  }
-
-  // parse initial digits before dot
-  let (s, mantissa, consumed) = parse_digits(s, 0UL)
-  let mut mantissa = mantissa
-  let mut s = s
-  let mut n_digits = consumed
-
-  // handle dot with the following digits
-  let mut n_after_dot = 0
-  let mut exponent = 0L
-  if s is ['.', .. rest] {
-    s = rest
-    // TODO: optimization chance. In the original Rust implementation,
-    // the digits are stored as consecutive bytes in the string.
-    // It directly reads 8 bytes to `u64`.
-    let (new_s, new_mantissa, consumed_digit) = parse_digits(s, mantissa)
-    s = new_s
-    mantissa = new_mantissa
-    n_after_dot = consumed_digit
-    exponent = -n_after_dot.to_int64()
-  }
-  n_digits += n_after_dot
-  if n_digits == 0 {
-    return None
-  }
-
-  // handle scientific format
-  let exp_number = 0L
-  if s is ['e' | 'E', .. rest] {
-    let (new_s, exp_number) = match parse_scientific(rest) {
-      Some(res) => res
-      None => return None
-    }
-    s = new_s
-    exponent += exp_number
-  }
-  guard s is "" else { syntax_err() }
-
-  // handle uncommon case with many digits
-  if n_digits <= 19 {
-    return Some({ exponent, mantissa, negative, many_digits: false })
-  }
-  n_digits -= 19
-  let mut many_digits = false
-  for s = start {
-    match s {
-      ['0' | '.' as ch, .. rest] => {
-        n_digits -= (ch.to_int() - 46) / 2 // '0' = b'.' + 2
-        continue rest
-      }
-      _ => break
-    }
-  }
-  let mut mantissa = mantissa
-  if n_digits > 0 {
-    // at this point we have more than 19 significant digits, let's try again
-    many_digits = true
-    mantissa = 0UL
-    let s = start
-    let (s, new_mantissa, consumed_digit) = try_parse_19digits(s, mantissa)
-    mantissa = new_mantissa
-    exponent = (if mantissa >= min_19digit_int {
-      consumed_digit // big int
-    } else {
-      // fractional component, skip the '.'
-      guard s is [_, .. s] else { return None }
-      let (_, new_mantissa, consumed_digit) = try_parse_19digits(s, mantissa)
-      mantissa = new_mantissa
-      consumed_digit
-    }).to_int64()
-    exponent += exp_number
-  } // add back the explicit part
-  Some({ exponent, mantissa, negative, many_digits })
-}
-
-///|
-/// Parse the number from the string, raising `StrConvError` if invalid.
-fn parse_inf_nan(rest : StringView) -> Double raise StrConvError {
-  let (pos, rest) = match rest {
-    ['-', .. rest] => (false, rest)
-    ['+', .. rest] | rest => (true, rest)
-  }
-  lexmatch rest with longest {
-    re"^(?i:nan)$" => @double.not_a_number
-    re"^(?i:inf(inity)?)$" =>
-      if pos {
-        @double.infinity
-      } else {
-        @double.neg_infinity
-      }
-    _ => syntax_err()
-  }
-}
-
-///|
-/// Returns None if the multiplication might overflow (there are some false-negative corner cases).
-/// Otherwise, returns Some(m), where m = self * b.
-/// WARNING: Note this function is only used internally in the strconv module, 
-/// the current implementation is not completely safe against overflows.
-fn checked_mul(a : UInt64, b : UInt64) -> UInt64? {
-  if a == 0UL || b == 0UL {
-    return Some(0UL)
-  }
-  if a == 1UL {
-    return Some(b)
-  }
-  if b == 1UL {
-    return Some(a)
-  }
-  // Can only multiply by 1 or 0, which is handled above.
-  if b.clz() == 0 || a.clz() == 0 {
-    return None
-  }
-  let quotient : UInt64 = @uint64.MAX_VALUE / b
-  if a > quotient {
-    return None
-  }
-  Some(a * b)
-}
diff --git a/strconv/number_wbtest.mbt b/strconv/number_wbtest.mbt
deleted file mode 100644
index 3a233e6bd4..0000000000
--- a/strconv/number_wbtest.mbt
+++ /dev/null
@@ -1,99 +0,0 @@
-// Copyright 2026 International Digital Economy Academy
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-///|
-test "parse_inf_nan basic cases" {
-  inspect(parse_inf_nan("nan"), content="NaN")
-  inspect(parse_inf_nan("inf"), content="Infinity")
-}
-
-///|
-test "parse_inf_nan with signs" {
-  inspect(parse_inf_nan("+nan"), content="NaN")
-  inspect(parse_inf_nan("-inf"), content="-Infinity")
-}
-
-///|
-test "parse_inf_nan case insensitive" {
-  inspect(parse_inf_nan("NAN"), content="NaN")
-  inspect(parse_inf_nan("InF"), content="Infinity")
-}
-
-///|
-test "parse_inf_nan with infinity suffix" {
-  inspect(parse_inf_nan("infinity"), content="Infinity")
-  inspect(parse_inf_nan("-INFINITY"), content="-Infinity")
-}
-
-///|
-test "parse_inf_nan with trailing strings" {
-  inspect(try? parse_inf_nan("infabc"), content="Err(invalid syntax)")
-  inspect(try? parse_inf_nan("nanxyz"), content="Err(invalid syntax)")
-  inspect(try? parse_inf_nan("+infinity123"), content="Err(invalid syntax)")
-}
-
-///|
-test "parse_inf_nan failures" {
-  // Test invalid input
-  inspect(try? parse_inf_nan("hello"), content="Err(invalid syntax)")
-  inspect(try? parse_inf_nan(""), content="Err(invalid syntax)")
-  inspect(try? parse_inf_nan("in"), content="Err(invalid syntax)")
-  inspect(try? parse_inf_nan("na"), content="Err(invalid syntax)")
-}
-
-///|
-test "checked_mul basic cases" {
-  // Test zero multiplication
-  inspect(checked_mul(0UL, 5UL), content="Some(0)")
-  inspect(checked_mul(5UL, 0UL), content="Some(0)")
-
-  // Test multiplication by one
-  inspect(checked_mul(1UL, 42UL), content="Some(42)")
-  inspect(checked_mul(42UL, 1UL), content="Some(42)")
-
-  // Test normal multiplication
-  inspect(checked_mul(3UL, 4UL), content="Some(12)")
-  inspect(checked_mul(10UL, 10UL), content="Some(100)")
-}
-
-///|
-test "checked_mul edge cases" {
-  // Test potential overflow
-  let large_val = 0xFFFFFFFFFFFFFFFFUL // max uint64
-  inspect(checked_mul(large_val, 2UL), content="None")
-  inspect(checked_mul(2UL, large_val), content="None")
-
-  // Test small multiplication that should work
-  inspect(checked_mul(2UL, 3UL), content="Some(6)")
-
-  // Test corner case
-  inspect(
-    checked_mul(5UL, 3689348814741910323UL),
-    content="Some(18446744073709551615)",
-  )
-  inspect(
-    checked_mul(3689348814741910323UL, 5UL),
-    content="Some(18446744073709551615)",
-  )
-  inspect(
-    checked_mul(4UL, 4611686018427387903UL),
-    content="Some(18446744073709551612)",
-  )
-  inspect(
-    checked_mul(4611686018427387903UL, 4UL),
-    content="Some(18446744073709551612)",
-  )
-  inspect(checked_mul(4611686018427387904UL, 4UL), content="None")
-  inspect(checked_mul(4UL, 4611686018427387904UL), content="None")
-}
diff --git a/strconv/pkg.generated.mbti b/strconv/pkg.generated.mbti
deleted file mode 100644
index 97a6ae8664..0000000000
--- a/strconv/pkg.generated.mbti
+++ /dev/null
@@ -1,73 +0,0 @@
-// Generated using `moon info`, DON'T EDIT IT
-package "moonbitlang/core/strconv"
-
-import {
-  "moonbitlang/core/debug",
-}
-
-// Values
-#deprecated
-pub fn[A : FromStr] parse(StringView) -> A raise StrConvError
-
-#deprecated
-pub fn parse_bool(StringView) -> Bool raise StrConvError
-
-#deprecated
-pub fn parse_decimal(StringView) -> Decimal raise StrConvError
-
-#deprecated
-pub fn parse_double(StringView) -> Double raise StrConvError
-
-#deprecated
-pub fn parse_int(StringView, base? : Int) -> Int raise StrConvError
-
-#deprecated
-pub fn parse_int64(StringView, base? : Int) -> Int64 raise StrConvError
-
-#deprecated
-pub fn parse_uint(StringView, base? : Int) -> UInt raise StrConvError
-
-#deprecated
-pub fn parse_uint64(StringView, base? : Int) -> UInt64 raise StrConvError
-
-// Errors
-#deprecated
-pub(all) suberror StrConvError {
-  StrConvError(String)
-} derive(@debug.Debug)
-pub fn StrConvError::to_string(Self) -> String
-pub impl Show for StrConvError
-
-// Types and methods
-#deprecated
-type Decimal derive(@debug.Debug)
-#deprecated
-pub fn Decimal::from_int64(Int64) -> Self
-#deprecated
-pub fn Decimal::new() -> Self
-#deprecated
-pub fn Decimal::parse_decimal(StringView) -> Self raise StrConvError
-#deprecated
-pub fn Decimal::shift(Self, Int) -> Unit
-#deprecated
-pub fn Decimal::to_double(Self) -> Double raise StrConvError
-pub fn Decimal::to_string(Self) -> String
-pub impl Show for Decimal
-
-// Type aliases
-
-// Traits
-#deprecated
-pub(open) trait FromStr {
-  #as_free_fn
-  #deprecated
-  fn from_str(StringView) -> Self raise StrConvError = _
-  #deprecated
-  fn from_string(String) -> Self raise StrConvError = _
-}
-pub impl FromStr for Bool
-pub impl FromStr for Int
-pub impl FromStr for Int64
-pub impl FromStr for UInt
-pub impl FromStr for UInt64
-pub impl FromStr for Double
diff --git a/strconv/quickcheck_test.mbt b/strconv/quickcheck_test.mbt
deleted file mode 100644
index 2b4106a915..0000000000
--- a/strconv/quickcheck_test.mbt
+++ /dev/null
@@ -1,258 +0,0 @@
-// Copyright 2026 International Digital Economy Academy
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-// The built-in Arbitrary instances for the integer types are bounded by the
-// generator size, so they never reach interesting regions such as subnormal
-// doubles or 20-digit integers. Assembling values from arbitrary bytes gives
-// uniform coverage of the full 64-bit (or 32-bit) space instead.
-
-///|
-fn u64_of_bytes(
-  q : ((Byte, Byte, Byte, Byte), (Byte, Byte, Byte, Byte)),
-) -> UInt64 {
-  let ((b7, b6, b5, b4), (b3, b2, b1, b0)) = q
-  (b7.to_uint64() << 56) |
-  (b6.to_uint64() << 48) |
-  (b5.to_uint64() << 40) |
-  (b4.to_uint64() << 32) |
-  (b3.to_uint64() << 24) |
-  (b2.to_uint64() << 16) |
-  (b1.to_uint64() << 8) |
-  b0.to_uint64()
-}
-
-///|
-fn u32_of_bytes(q : (Byte, Byte, Byte, Byte)) -> UInt {
-  let (b3, b2, b1, b0) = q
-  (b3.to_uint() << 24) |
-  (b2.to_uint() << 16) |
-  (b1.to_uint() << 8) |
-  b0.to_uint()
-}
-
-///|
-fn insert_char(s : String, pos : Int, inserted : Char) -> String {
-  let sb = StringBuilder::new()
-  let mut i = 0
-  for c in s {
-    if i == pos {
-      sb.write_char(inserted)
-    }
-    sb.write_char(c)
-    i += 1
-  }
-  sb.to_string()
-}
-
-///|
-/// The killer property: `Double::to_string` produces the shortest decimal
-/// representation that rounds back to the value, so `parse_double` must invert
-/// it *bitwise* for every double, including subnormals, extreme exponents and
-/// infinities. Arbitrary bit patterns reach all of those; NaN is excluded
-/// because it has many payloads and never compares equal.
-#warnings("-deprecated")
-test "quickcheck: parse_double inverts to_string for arbitrary bit patterns" {
-  @quickcheck.check(
-    (q : ((Byte, Byte, Byte, Byte), (Byte, Byte, Byte, Byte))) => {
-      let d = u64_of_bytes(q).reinterpret_as_double()
-      let s = d.to_string()
-      let parsed = @strconv.parse_double(s)
-      if parsed.reinterpret_as_int64() == d.reinterpret_as_int64() {
-        true
-      } else {
-        // `Double::to_string` follows the ECMAScript number-to-string
-        // algorithm on every backend, and that algorithm renders negative
-        // zero as "0", so the sign bit cannot survive the trip through the
-        // string. Parsing that "0" must still yield exactly +0.0. Every
-        // other double must round-trip bit-for-bit.
-        d.reinterpret_as_int64() == -0x8000000000000000L &&
-        s == "0" &&
-        parsed.reinterpret_as_int64() == 0L
-      }
-    },
-    filter=q => !u64_of_bytes(q).reinterpret_as_double().is_nan(),
-    count=500,
-  )
-}
-
-///|
-/// Same round-trip on uniformly distributed doubles in [0, 1]: dense mantissas
-/// with small exponents exercise the fast path a bit-pattern generator rarely
-/// hits.
-#warnings("-deprecated")
-test "quickcheck: parse_double inverts to_string for uniform doubles" {
-  @quickcheck.check(
-    (d : Double) => {
-      @strconv.parse_double(d.to_string()).reinterpret_as_int64() ==
-      d.reinterpret_as_int64()
-    },
-    count=500,
-  )
-}
-
-///|
-/// Decimal round-trip over the full signed/unsigned 64-bit range, and
-/// agreement with `to_double`: parsing the exact decimal string of an integer
-/// must give the same correctly-rounded double as converting the integer
-/// directly (19-20 significant digits exercise the slow path).
-#warnings("-deprecated")
-test "quickcheck: 64-bit integers round-trip through decimal strings" {
-  @quickcheck.check(
-    (q : ((Byte, Byte, Byte, Byte), (Byte, Byte, Byte, Byte))) => {
-      let u = u64_of_bytes(q)
-      let n = u.reinterpret_as_int64()
-      @strconv.parse_uint64(u.to_string()) == u &&
-      @strconv.parse_int64(n.to_string()) == n &&
-      @strconv.parse_int64(n.to_string(), base=10) == n &&
-      @strconv.parse_double(u.to_string()) == u.to_double() &&
-      @strconv.parse_double(n.to_string()) == n.to_double()
-    },
-    count=500,
-  )
-}
-
-///|
-/// Radix round-trip: formatting in any base 2..=36 and parsing with the same
-/// explicit base is the identity, for both signed and unsigned 64-bit values.
-#warnings("-deprecated")
-test "quickcheck: radix formatting and parsing are inverse in every base" {
-  @quickcheck.check(
-    (input : (((Byte, Byte, Byte, Byte), (Byte, Byte, Byte, Byte)), UInt)) => {
-      let (q, r) = input
-      let base = 2 + (r % 35).reinterpret_as_int()
-      let u = u64_of_bytes(q)
-      let n = u.reinterpret_as_int64()
-      @strconv.parse_uint64(u.to_string(radix=base), base~) == u &&
-      @strconv.parse_int64(n.to_string(radix=base), base~) == n
-    },
-    count=500,
-  )
-}
-
-///|
-/// 32-bit round-trip, an explicit leading '+', and agreement between the
-/// integer and floating-point parsers on the same string (exact: every Int
-/// fits in a double).
-#warnings("-deprecated")
-test "quickcheck: 32-bit integers round-trip and parsers agree" {
-  @quickcheck.check(
-    (q : (Byte, Byte, Byte, Byte)) => {
-      let u = u32_of_bytes(q)
-      let n = u.reinterpret_as_int()
-      let s = n.to_string()
-      @strconv.parse_int(s) == n &&
-      @strconv.parse_uint(u.to_string()) == u &&
-      @strconv.parse_int64(s) == n.to_int64() &&
-      @strconv.parse_double(s) == n.to_double() &&
-      (n < 0 || @strconv.parse_int("+" + s) == n)
-    },
-    count=500,
-  )
-}
-
-///|
-/// Underscores between digits are documented as pure readability sugar: they
-/// must not change the parsed value, for integers and doubles alike.
-#warnings("-deprecated")
-test "quickcheck: an underscore between digits never changes the value" {
-  @quickcheck.check(
-    (input : (((Byte, Byte, Byte, Byte), (Byte, Byte, Byte, Byte)), UInt)) => {
-      let (q, pos_seed) = input
-      let u = u64_of_bytes(q)
-      let s = u.to_string()
-      guard s.length() >= 2 else { return true }
-      // Any interior position sits between two digits.
-      let pos = 1 +
-        (pos_seed % (s.length() - 1).reinterpret_as_uint()).reinterpret_as_int()
-      let with_underscore = insert_char(s, pos, '_')
-      @strconv.parse_uint64(with_underscore) == u &&
-      @strconv.parse_double(with_underscore) == u.to_double()
-    },
-    count=300,
-  )
-}
-
-///|
-/// The grammar accepts no decoration: a stray character that is not a digit,
-/// letter, sign, dot or underscore — including whitespace — must make parsing
-/// fail whether it is prepended or appended.
-#warnings("-deprecated")
-test "quickcheck: a stray non-numeric character is rejected" {
-  @quickcheck.check(
-    (input : (Int, Char)) => {
-      let (n, c) = input
-      let s = n.to_string()
-      (try? @strconv.parse_int("\{c}\{s}")) is Err(_) &&
-      (try? @strconv.parse_int("\{s}\{c}")) is Err(_) &&
-      (try? @strconv.parse_double("\{c}\{s}")) is Err(_) &&
-      (try? @strconv.parse_double("\{s}\{c}")) is Err(_)
-    },
-    filter=input => {
-      !(input.1 is ('0'..='9' | 'a'..='z' | 'A'..='Z' | '_' | '+' | '-' | '.'))
-    },
-    count=300,
-  )
-}
-
-///|
-/// Small-grammar fuzz: every string of the shape
-/// `[-]digits[.digits][e[-]digits]` must either parse — and then the parsed
-/// value's own rendering must reparse to the same bits (a fixed point) — or
-/// fail with a range error, which is only legitimate for a genuinely huge
-/// exponent (overflow past Double::max_value; underflow flushes to zero
-/// silently and never errors).
-#warnings("-deprecated")
-test "quickcheck: structured numeric strings parse to a printing fixed point" {
-  @quickcheck.check(
-    (parts : (UInt, UInt, Int, (Bool, Bool, Bool))) => {
-      let (int_part, frac_part, e, (neg, with_frac, with_exp)) = parts
-      let exp = e * 4
-      let sb = StringBuilder::new()
-      if neg {
-        sb.write_char('-')
-      }
-      sb.write_string(int_part.to_string())
-      if with_frac {
-        sb.write_char('.')
-        sb.write_string(frac_part.to_string())
-      }
-      if with_exp {
-        sb.write_char('e')
-        sb.write_string(exp.to_string())
-      }
-      let s = sb.to_string()
-      match (try? @strconv.parse_double(s)) {
-        Ok(p) => {
-          let reparsed = @strconv.parse_double(p.to_string())
-          if p.reinterpret_as_int64() == -0x8000000000000000L {
-            // "-0" parses to -0.0, but the ECMAScript-style to_string renders
-            // negative zero as "0", so one reparse lands on +0.0.
-            reparsed.reinterpret_as_int64() == 0L
-          } else {
-            reparsed.reinterpret_as_int64() == p.reinterpret_as_int64()
-          }
-        }
-        Err(_) => with_exp && exp > 250
-      }
-    },
-    count=500,
-  )
-}
-
-///|
-/// Booleans round-trip too.
-#warnings("-deprecated")
-test "quickcheck: parse_bool inverts to_string" {
-  @quickcheck.check((b : Bool) => @strconv.parse_bool(b.to_string()) == b)
-}
diff --git a/strconv/traits.mbt b/strconv/traits.mbt
deleted file mode 100644
index 7e77e9199a..0000000000
--- a/strconv/traits.mbt
+++ /dev/null
@@ -1,89 +0,0 @@
-// Copyright 2026 International Digital Economy Academy
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-// TODO:
-// 
-// - Format functions.
-// - Support hexadecimal floating point number.
-// - Implements Eisel-Lemire algorithm to speed up floating point parsing.
-
-///|
-/// Trait for parsing values from textual input.
-///
-/// Implement either `from_str` (preferred) or legacy `from_string`.
-#deprecated("use `@string.FromStr` instead", skip_current_package=true)
-pub(open) trait FromStr {
-  #as_free_fn
-  #deprecated("use `@string.from_str` instead", skip_current_package=true)
-  fn from_str(StringView) -> Self raise StrConvError = _
-  #deprecated("use `@string.from_str` instead", skip_current_package=true)
-  fn from_string(String) -> Self raise StrConvError = _
-}
-
-///|
-#deprecated("replace `impl @strconv.FromStr::from_string` with `impl @string.FromStr::from_str`", skip_current_package=true)
-impl FromStr with fn from_str(str) {
-  FromStr::from_string(str.to_string())
-}
-
-///|
-impl FromStr with fn from_string(str) {
-  FromStr::from_str(str)
-}
-
-///|
-pub impl FromStr for Bool with fn from_str(str) {
-  parse_bool(str)
-}
-
-///|
-pub impl FromStr for Int with fn from_str(str) {
-  parse_int(str)
-}
-
-///|
-pub impl FromStr for Int64 with fn from_str(str) {
-  parse_int64(str)
-}
-
-///|
-pub impl FromStr for UInt with fn from_str(str) {
-  parse_uint(str)
-}
-
-///|
-pub impl FromStr for UInt64 with fn from_str(str) {
-  parse_uint64(str)
-}
-
-///|
-pub impl FromStr for Double with fn from_str(str) {
-  parse_double(str)
-}
-
-///|
-test "parse" {
-  let b : Bool = from_str("true")
-  inspect(b, content="true")
-  let i : Int = from_str("12345")
-  inspect(i, content="12345")
-  let i64 : Int64 = from_str("9223372036854775807")
-  assert_true(i64 == 9223372036854775807L)
-  let ui : UInt = from_str("4294967295")
-  inspect(ui, content="4294967295")
-  let ui64 : UInt64 = from_str("18446744073709551615")
-  assert_true(ui64 == 18446744073709551615UL)
-  let d : Double = from_str("1234.56789")
-  assert_true(d == 1234.56789)
-}
diff --git a/strconv/uint.mbt b/strconv/uint.mbt
deleted file mode 100644
index 713df77dea..0000000000
--- a/strconv/uint.mbt
+++ /dev/null
@@ -1,268 +0,0 @@
-// Copyright 2026 International Digital Economy Academy
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-///|
-const UINT_MAX : UInt = 0xffffffff
-
-///|
-const UINT64_MAX : UInt64 = 0xffffffffffffffffUL
-
-///|
-/// Parses a string into a UInt64 number using the specified base, or returns an error.
-/// The base must be 0 or between 2 and 36 (inclusive). If base is 0, it will be 
-/// inferred from the string prefix:
-///   - "0x" or "0X" for base 16 (hex)
-///   - "0o" or "0O" for base 8 (octal)
-///   - "0b" or "0B" for base 2 (binary)
-///   - Default is base 10 (decimal)
-/// For readability, underscores may appear after base prefixes or between digits.
-/// These underscores do not affect the value.
-/// Examples:
-/// ```mbt check
-/// #warnings("-deprecated")
-/// test {
-///   inspect(@strconv.parse_uint64("123"), content="123")
-///   inspect(@strconv.parse_uint64("0xff", base=0), content="255")
-///   inspect(@strconv.parse_uint64("0o10"), content="8")
-///   inspect(@strconv.parse_uint64("0b1010"), content="10")
-///   inspect(@strconv.parse_uint64("1_234"), content="1234")
-///   inspect(@strconv.parse_uint64("ff", base=16), content="255")
-///   inspect(@strconv.parse_uint64("zz", base=36), content="1295")
-/// }
-/// ```
-/// 
-#deprecated("use `@string.parse_uint64` instead", skip_current_package=true)
-pub fn parse_uint64(
-  str : StringView,
-  base? : Int = 0,
-) -> UInt64 raise StrConvError {
-  guard str != "" else { syntax_err() }
-  if str is ['+' | '-', ..] {
-    syntax_err()
-  }
-
-  // `allow_underscore` is used to check validity of underscores
-  let (num_base, rest, allow_underscore) = check_and_consume_base(str, base)
-
-  // calculate overflow threshold
-  let overflow_threshold = match num_base {
-    10 => UINT64_MAX / 10 + 1
-    16 => UINT64_MAX / 16 + 1
-    _ => UINT64_MAX / num_base.to_uint64() + 1
-  }
-  let has_digit = rest
-    is (['0'..='9' | 'a'..='z' | 'A'..='Z', ..]
-    | ['_', '0'..='9' | 'a'..='z' | 'A'..='Z', ..])
-  guard has_digit else { syntax_err() }
-  for s = rest, acc = 0UL, au = allow_underscore {
-    match (s, acc, au) {
-      (['_'], _, _) =>
-        // the last character cannot be underscore
-        syntax_err()
-      (['_', ..], _, false) => syntax_err()
-      (['_', .. rest], acc, true) => continue rest, acc, false
-      ([c, .. rest], acc, _) => {
-        let c = c.to_int()
-        let d = match c {
-          '0'..='9' => c - '0'
-          'a'..='z' => c + (10 - 'a')
-          'A'..='Z' => c + (10 - 'A')
-          _ => syntax_err()
-        }
-        guard d < num_base else { syntax_err() }
-        guard acc < overflow_threshold else { range_err() }
-        let next_acc = acc * num_base.to_uint64() + d.to_uint64()
-        guard next_acc >= acc && next_acc <= UINT64_MAX else { range_err() }
-        continue rest, next_acc, true
-      }
-      ([], acc, _) => break acc
-    }
-  }
-}
-
-///|
-/// Parse a string in the given base (0, 2 to 36), return an UInt number or an error.
-/// If the `~base` argument is 0, the base will be inferred by the prefix.
-#deprecated("use `@string.parse_uint` instead", skip_current_package=true)
-pub fn parse_uint(str : StringView, base? : Int = 0) -> UInt raise StrConvError {
-  let n = parse_uint64(str, base~)
-  if n > UINT_MAX.to_uint64() {
-    range_err()
-  }
-  n.to_uint()
-}
-
-///|
-test "parse_uint64" {
-  let tests : Array[(String, Result[UInt64, String])] = [
-    ("", Err(syntax_err_str)),
-    ("0", Ok(0UL)),
-    ("-0", Err(syntax_err_str)),
-    ("+0", Err(syntax_err_str)),
-    ("1", Ok(1UL)),
-    ("-1", Err(syntax_err_str)),
-    ("12345", Ok(12345UL)),
-    ("-12345", Err(syntax_err_str)),
-    ("012345", Ok(12345UL)),
-    ("9876543210", Ok(9876543210UL)),
-    ("18446744073709551615", Ok(18446744073709551615UL)),
-    ("18446744073709551616", Err(range_err_str)),
-    ("1_2_3_4_5", Ok(12345UL)),
-    ("_12345", Err(syntax_err_str)),
-    ("1__2345", Err(syntax_err_str)),
-    ("12345_", Err(syntax_err_str)),
-    ("12345%", Err(syntax_err_str)),
-  ]
-  for t in tests {
-    assert_true(
-      (Result::Ok(parse_uint64(t.0)) catch { StrConvError(err) => Err(err) }) ==
-      t.1,
-    )
-  }
-}
-
-///|
-test "parse_uint64_base" {
-  let tests : Array[(String, Int, Result[UInt64, String])] = [
-    ("", 0, Err(syntax_err_str)),
-    ("0", 0, Ok(0UL)),
-    ("1", 0, Ok(1UL)),
-    ("12345", 0, Ok(12345UL)),
-    ("012345", 0, Ok(12345UL)),
-    ("0x12345", 0, Ok(0x12345UL)),
-    ("9876543210", 0, Ok(9876543210UL)),
-    ("18446744073709551615", 0, Ok(UINT64_MAX)),
-    ("0xffffffffffffffff", 0, Ok(UINT64_MAX)),
-    ("18446744073709551616", 0, Err(range_err_str)),
-    ("12345x", 0, Err(syntax_err_str)),
-    ("-12345x", 0, Err(syntax_err_str)),
-    // other bases
-    ("h", 18, Ok(17UL)),
-    ("10", 25, Ok(25UL)),
-    (
-      "moonbit",
-      35,
-      Ok(
-        (
-          ((((22UL * 35UL + 24UL) * 35UL + 24UL) * 35UL + 23UL) * 35UL + 11UL) *
-          35UL +
-          18UL
-        ) *
-        35UL +
-        29UL,
-      ),
-    ),
-    (
-      "moonbit",
-      36,
-      Ok(
-        (
-          ((((22UL * 36UL + 24UL) * 36UL + 24UL) * 36UL + 23UL) * 36UL + 11UL) *
-          36UL +
-          18UL
-        ) *
-        36UL +
-        29UL,
-      ),
-    ),
-    // base 2
-    ("0", 2, Ok(0UL)),
-    ("-1", 2, Err(syntax_err_str)),
-    ("1010", 2, Ok(10UL)),
-    ("1000000000000000", 2, Ok(1UL << 15)),
-    (
-      "1111111111111111111111111111111111111111111111111111111111111111",
-      2,
-      Ok(UINT64_MAX),
-    ),
-    (
-      "1000000000000000000000000000000000000000000000000000000000000000",
-      2,
-      Ok(1UL << 63),
-    ),
-    // base 8
-    ("10", 8, Ok(8UL)),
-    ("57635436545", 8, Ok(0o57635436545UL)),
-    ("100000000", 8, Ok(1UL << 24)),
-    // base 16
-    ("10", 16, Ok(16UL)),
-    ("ffffffffffffffff", 16, Ok(UINT64_MAX)),
-    // underscores
-    ("0x_1_2_3_4_5", 0, Ok(0x12345UL)),
-    ("-_0x12345", 0, Err(syntax_err_str)),
-    ("_-0x12345", 0, Err(syntax_err_str)),
-    ("_0x12345", 0, Err(syntax_err_str)),
-    ("0x__12345", 0, Err(syntax_err_str)),
-    ("0x1__2345", 0, Err(syntax_err_str)),
-    ("0x1234__5", 0, Err(syntax_err_str)),
-    ("0x12345_", 0, Err(syntax_err_str)),
-    ("0_1_2_3_4_5", 0, Ok(12345UL)),
-    ("-_012345", 0, Err(syntax_err_str)),
-    ("_-012345", 0, Err(syntax_err_str)),
-    ("_012345", 0, Err(syntax_err_str)),
-    ("0__12345", 0, Err(syntax_err_str)),
-    ("01234__5", 0, Err(syntax_err_str)),
-    ("012345_", 0, Err(syntax_err_str)),
-    ("0xf", 0, Ok(0xfUL)),
-    ("-0xf", 0, Err(syntax_err_str)),
-    ("0x+f", 0, Err(syntax_err_str)),
-    ("0x-f", 0, Err(syntax_err_str)),
-  ]
-  for t in tests {
-    assert_true(
-      (Result::Ok(parse_uint64(t.0, base=t.1)) catch {
-        StrConvError(err) => Err(err)
-      }) ==
-      t.2,
-    )
-  }
-}
-
-///|
-test "parse_uint" {
-  let tests : Array[(String, Result[UInt, String])] = [
-    ("", Err(syntax_err_str)),
-    ("0", Ok(0)),
-    ("-0", Err(syntax_err_str)),
-    ("+0", Err(syntax_err_str)),
-    ("1", Ok(1)),
-    ("-1", Err(syntax_err_str)),
-    ("12345", Ok(12345)),
-    ("012345", Ok(12345)),
-    ("12345x", Err(syntax_err_str)),
-    ("-12345x", Err(syntax_err_str)),
-    ("987654321", Ok(987654321)),
-    ("4294967295", Ok(UINT_MAX)),
-    ("0xffffffff", Ok(UINT_MAX)),
-    ("4294967296", Err(range_err_str)),
-    ("1_2_3_4_5", Ok(12345)),
-    ("-_12345", Err(syntax_err_str)),
-    ("_12345", Err(syntax_err_str)),
-    ("1__2345", Err(syntax_err_str)),
-    ("12345_", Err(syntax_err_str)),
-    ("123%45", Err(syntax_err_str)),
-  ]
-  for t in tests {
-    assert_true(
-      (Result::Ok(parse_uint(t.0)) catch { StrConvError(err) => Err(err) }) ==
-      t.1,
-    )
-  }
-}
-
-///|
-test "parse_uint64 uppercase hex and invalid base" {
-  inspect(try? parse_uint64("ABCD", base=16), content="Ok(43981)")
-  inspect(try? parse_uint64("1234", base=37), content="Err(invalid base)")
-}
diff --git a/strconv/uint_test.mbt b/strconv/uint_test.mbt
deleted file mode 100644
index cfe0431f58..0000000000
--- a/strconv/uint_test.mbt
+++ /dev/null
@@ -1,108 +0,0 @@
-// Copyright 2026 International Digital Economy Academy
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-///|
-#warnings("-deprecated")
-test "@strconv.parse_uint64/base_handling" {
-  // Different bases with valid input
-  inspect(try? @strconv.parse_uint64("FF", base=16), content="Ok(255)")
-  inspect(try? @strconv.parse_uint64("0xFF"), content="Ok(255)")
-  inspect(try? @strconv.parse_uint64("377", base=8), content="Ok(255)")
-  inspect(try? @strconv.parse_uint64("0o377"), content="Ok(255)")
-  inspect(try? @strconv.parse_uint64("11111111", base=2), content="Ok(255)")
-  inspect(try? @strconv.parse_uint64("0b11111111"), content="Ok(255)")
-}
-
-///|
-#warnings("-deprecated")
-test "@strconv.parse_uint64/underscore" {
-  // Valid underscore placements
-  inspect(try? @strconv.parse_uint64("1_000_000"), content="Ok(1000000)")
-  inspect(try? @strconv.parse_uint64("0xff_ff_ff"), content="Ok(16777215)")
-  // Invalid underscore placements
-  inspect(try? @strconv.parse_uint64("_123"), content="Err(invalid syntax)")
-  inspect(try? @strconv.parse_uint64("123_"), content="Err(invalid syntax)")
-  inspect(try? @strconv.parse_uint64("1__23"), content="Err(invalid syntax)")
-}
-
-///|
-#warnings("-deprecated")
-test "panic @strconv.parse_uint64/errors" {
-  // Empty string
-  ignore(@strconv.parse_uint64(""))
-  // Invalid base
-  ignore(@strconv.parse_uint64("123", base=37))
-  // Signs not allowed
-  ignore(@strconv.parse_uint64("+123"))
-  ignore(@strconv.parse_uint64("-123"))
-  // Range error (overflow)
-  ignore(@strconv.parse_uint64("18446744073709551616"))
-}
-
-///|
-#warnings("-deprecated")
-test "@strconv.parse_uint64/hex_and_edge_cases" {
-  // Valid hexadecimal numbers with 0x/0X prefix
-  inspect(try? @strconv.parse_uint64("0xDEADBEEF"), content="Ok(3735928559)")
-  inspect(try? @strconv.parse_uint64("0XDEADBEEF"), content="Ok(3735928559)")
-
-  // Valid hexadecimal numbers without prefix when base=16 is specified
-  inspect(
-    try? @strconv.parse_uint64("deadbeef", base=16),
-    content="Ok(3735928559)",
-  )
-
-  // Valid hexadecimal numbers with allowed underscore placements
-  inspect(try? @strconv.parse_uint64("0xDEAD_BEEF"), content="Ok(3735928559)")
-  inspect(
-    try? @strconv.parse_uint64("dead_beef", base=16),
-    content="Ok(3735928559)",
-  )
-  inspect(try? @strconv.parse_uint64("0x1_2345"), content="Ok(74565)")
-
-  // Edge case: zero value
-  inspect(try? @strconv.parse_uint64("0x0"), content="Ok(0)")
-  inspect(try? @strconv.parse_uint64("0x00"), content="Ok(0)")
-
-  // Edge case: maximum valid uint64 value (all bits set)
-  inspect(
-    try? @strconv.parse_uint64("0xFFFFFFFFFFFFFFFF"),
-    content="Ok(18446744073709551615)",
-  )
-  inspect(
-    try? @strconv.parse_uint64("0xDEADBEEF_"),
-    content="Err(invalid syntax)",
-  )
-  inspect(
-    try? @strconv.parse_uint64("0xDE__AD_BEEF"),
-    content="Err(invalid syntax)",
-  )
-
-  // Invalid hex digit (G is not a valid base-16 digit)
-  inspect(
-    try? @strconv.parse_uint64("0xDEADBEG"),
-    content="Err(invalid syntax)",
-  )
-}
-
-///|
-#warnings("-deprecated")
-test "edge cases" {
-  // Invalid: Missing digits after hex prefix
-  inspect(try? @strconv.parse_uint64("0x"), content="Err(invalid syntax)")
-  inspect(try? @strconv.parse_uint64("0x_"), content="Err(invalid syntax)")
-
-  // Underscore is allowed between the prefix and the first digit
-  inspect(try? @strconv.parse_uint64("0x_DEADBEEF"), content="Ok(3735928559)")
-}
diff --git a/string/DESIGN.mbt.md b/string/DESIGN.mbt.md
index e949f9dded..1c1d038a00 100644
--- a/string/DESIGN.mbt.md
+++ b/string/DESIGN.mbt.md
@@ -7,7 +7,7 @@
   represented using surrogate pairs - two 16-bit code units.
 
 * **Char vs Charcode**: MoonBit distinguishes between:
-  - `Charcode`: A UTF-16 code unit (type `Int`)
+  - `Charcode`: A UTF-16 code unit (type `UInt16`)
   - `Char`: A Unicode character (type `Char`)
 
 * **Indexing**: There are two ways to count or index elements in a string:
@@ -18,12 +18,13 @@
   have specific guidelines for string indexing:
   - The `s[i]` syntax is available but should be used with caution. It accesses
     the i-th UTF-16 code unit (charcode) for efficiency and consistency with
-    other APIs. The return type of `s[i]` is `Int`, which reminds you that it
+    other APIs. The return type of `s[i]` is `UInt16`, which reminds you that it
     returns the charcode.
-  - The slice operator `s[i:j]` is intentionally disallowed to prevent
-    accidental creation of invalid strings. Instead, use `s.charcodes(start = i,
-    end = j)` to get a view of the String. The API reminds you that it creates
-    a view based on charcode indices, not characters.
+  - The slice operator `s[i:j]` is available but should also be used with
+    caution: it slices on charcode indices, not character indices, and it
+    aborts when an endpoint is out of range or would split a surrogate pair.
+    Use `s.get_view(start = i, end = j)`, which returns `None` instead of
+    aborting, when the range may be invalid.
 
 * **Performance and Unicode Safety**:
   - Most APIs in this package operate on UTF-16 offsets rather than Unicode
@@ -31,9 +32,10 @@
     while character-based operations typically require O(n) scanning. However,
     direct offset manipulation is not unicode-safe as it may split surrogate
     pairs. For example,
-    * `char_at(i)` and `charcode_at(i)` reads the data at the i-th offset and
-      returns the corresponding character and charcode respectively. Both APIs
-      take O(1) time complexity.
+    * `get_char(i)` reads the data at the i-th offset and returns the
+      corresponding character (or `None` if that offset splits a surrogate
+      pair), while `at(i)` (a.k.a. `code_unit_at(i)`) returns the charcode at
+      that offset. Both APIs take O(1) time complexity.
     * `find` and `rev_find` will return the charcode index if the target is
       found. The index can be used to create a view of the string.
   - For unicode-safe operations, it's recommended to use:
@@ -43,9 +45,9 @@
 ```mbt check
 ///|
 test "unsafe vs safe" {
-  // Unsafe: May split surrogate pairs
+  // Unsafe: raw offsets may land in the middle of a surrogate pair
   let emoji = "🎉"
-  let _ = emoji.get_char(1) // Gets second half of surrogate pair
+  let _ = emoji.get_char(1) // None: offset 1 is the second half of the pair
   // Safe: Uses iterator
   for c in "Hello 🌍".iter() {
     // Properly handles both ASCII and Unicode chars
@@ -56,9 +58,9 @@ test "unsafe vs safe" {
 
 * **Validity**: The string APIs assume the validity of strings and that provided
   offsets don't fall between surrogate pairs. The APIs don't perform validity
-  checks for efficiency reasons. Creating invalid characters is possible (e.g.,
-  `"🍎".char_at(1)` accesses the second half of a surrogate pair). When
-  displaying invalid characters, a replacement character � will be shown.
+  checks for efficiency reasons. Creating invalid strings is possible (e.g.,
+  `"🍎".view(start_offset=1)` starts at the second half of a surrogate pair).
+  When displaying invalid characters, a replacement character � will be shown.
 
 * **View**: A `View` represents a view of a String that maintains proper Unicode
   character boundaries while providing efficient access to substrings. Views are
diff --git a/string/README.mbt.md b/string/README.mbt.md
index a11b5271cb..2b0227b09d 100644
--- a/string/README.mbt.md
+++ b/string/README.mbt.md
@@ -16,7 +16,7 @@ test "string creation" {
   inspect(str1, content="Hello")
 
   // From character iterator
-  let str2 = String::from_iter(['W', 'o', 'r', 'l', 'd'].iter())
+  let str2 = String::from_iter([|'W', 'o', 'r', 'l', 'd'|])
   inspect(str2, content="World")
 
   // Default empty string
@@ -78,7 +78,7 @@ test "string conversion" {
 
   // Convert to bytes (UTF-16 LE encoding)
   let bytes = @utf16.encode(text)
-  inspect(bytes.length(), content="16") // 5 chars * 2 bytes each
+  inspect(bytes.length(), content="16") // 8 code units * 2 bytes each
 }
 ```
 
@@ -109,7 +109,7 @@ test "unicode handling" {
 
 ## String Comparison
 
-Strings are ordered using shortlex order by Unicode code points:
+Strings are ordered using shortlex order by their UTF-16 code units:
 
 ```mbt check
 ///|
@@ -257,9 +257,9 @@ test "from_str" {
 ```
 
 The `FromStr` trait provides `from_str()` for `Bool`, `Int`, `Int64`, `UInt`,
-`UInt64`, and `Double`. Use concrete parsers when you need parser-specific
-options or an explicit parser name. For example, integer parsers accept an
-optional base:
+`UInt64`, `Double`, and `@bigint.BigInt`. Use concrete parsers when you need
+parser-specific options or an explicit parser name. For example, integer parsers
+accept an optional base:
 
 ```mbt check
 ///|
@@ -355,4 +355,4 @@ test "regex combinators" {
 - Unicode iteration handles surrogate pairs correctly but is slower than UTF-16
   code unit iteration
 - Character length operations (`char_length_eq`, `char_length_ge`) have O(n)
-  complexity where n is the character count
+  complexity where n is the length passed in, not the whole string's length
diff --git a/string/ascii_case_insensitive_map_test.mbt b/string/ascii_case_insensitive_map_test.mbt
index 4ed69e8d35..e21e63e032 100644
--- a/string/ascii_case_insensitive_map_test.mbt
+++ b/string/ascii_case_insensitive_map_test.mbt
@@ -36,7 +36,7 @@ priv struct AsciiCaseInsensitiveString {
 
 ///|
 fn AsciiCaseInsensitiveString::new(s : String) -> AsciiCaseInsensitiveString {
-  { raw: s }
+  { raw: s, }
 }
 
 ///|
diff --git a/string/internal/regex_engine/ast/pattern.mbt b/string/internal/regex_engine/ast/pattern.mbt
index 50769d9178..9ca9f99b34 100644
--- a/string/internal/regex_engine/ast/pattern.mbt
+++ b/string/internal/regex_engine/ast/pattern.mbt
@@ -81,13 +81,13 @@ pub fn char(cs : @shared_types.RecharSet) -> Pattern {
   if cs.is_empty() {
     empty
   } else {
-    { desc: Char(cs), nullable: false }
+    { desc: Char(cs), nullable: false, }
   }
 }
 
 ///|
 /// Epsilon pattern that matches empty input.
-pub let epsilon : Pattern = { desc: Sequence([]), nullable: true }
+pub let epsilon : Pattern = { desc: Sequence([]), nullable: true, }
 
 ///|
 /// Concatenate patterns.
@@ -115,14 +115,14 @@ pub fn seq(exprs : ReadOnlyArray[Pattern]) -> Pattern {
 
 ///|
 /// Empty pattern that matches nothing.
-pub let empty : Pattern = { desc: Alternation([]), nullable: false }
+pub let empty : Pattern = { desc: Alternation([]), nullable: false, }
 
 ///|
 /// Build an alternation of patterns.
 pub fn alt(exprs : ReadOnlyArray[Pattern]) -> Pattern {
   match exprs {
     [expr] | ([] with expr = empty) => expr
-    exprs => { desc: Alternation(exprs), nullable: exprs.any(e => e.nullable) }
+    exprs => { desc: Alternation(exprs), nullable: exprs.any(e => e.nullable), }
   }
 }
 
@@ -133,7 +133,7 @@ pub fn alt(exprs : ReadOnlyArray[Pattern]) -> Pattern {
 pub fn quantifier(expr : Pattern, q : Quantifier) -> Pattern {
   guard q.min >= 0 else { panic() }
   guard q.max is None || (q.max is Some(max) && max >= q.min) else { panic() }
-  { desc: Quantifier(q, expr), nullable: expr.nullable || q.min == 0 }
+  { desc: Quantifier(q, expr), nullable: expr.nullable || q.min == 0, }
 }
 
 ///|
@@ -142,7 +142,7 @@ pub fn preference(pref : @shared_types.Preference, expr : Pattern) -> Pattern {
   if expr.desc is Char(_) {
     expr
   } else {
-    { desc: Preference(pref, expr), nullable: expr.nullable }
+    { desc: Preference(pref, expr), nullable: expr.nullable, }
   }
 }
 
@@ -167,13 +167,13 @@ pub fn first(expr : Pattern) -> Pattern {
 ///|
 /// Wrap a pattern in a capturing group.
 pub fn capture(name? : String, expr : Pattern) -> Pattern {
-  { desc: Capture(name~, expr), nullable: expr.nullable }
+  { desc: Capture(name~, expr), nullable: expr.nullable, }
 }
 
 ///|
 /// Create an assertion pattern node.
 pub fn assertion(a : Assertion) -> Pattern {
-  { desc: Assertion(a), nullable: true }
+  { desc: Assertion(a), nullable: true, }
 }
 
 ///|
diff --git a/string/internal/regex_engine/compile.mbt b/string/internal/regex_engine/compile.mbt
index 061fe6b634..4da579281d 100644
--- a/string/internal/regex_engine/compile.mbt
+++ b/string/internal/regex_engine/compile.mbt
@@ -20,7 +20,7 @@ pub fn compile(profile~ : Profile, ast : Pattern) -> Regex {
   } else {
     seq([
       shortest(
-        quantifier(char(profile.valid), { min: 0, max: None, mode: Greedy }),
+        quantifier(char(profile.valid), { min: 0, max: None, mode: Greedy, }),
       ),
       capture(ast),
     ])
diff --git a/string/internal/regex_engine/regex_engine_test.mbt b/string/internal/regex_engine/regex_engine_test.mbt
index 0dceb12fd0..248b77db48 100644
--- a/string/internal/regex_engine/regex_engine_test.mbt
+++ b/string/internal/regex_engine/regex_engine_test.mbt
@@ -74,7 +74,7 @@ test "execute with last_index and cached failure transitions" {
 ///|
 test "nested stars execute repeatedly" {
   let a = @regex_engine.char(@regex_engine.RecharSet::char('a'))
-  let inner = @regex_engine.quantifier(a, { min: 0, max: None, mode: Greedy })
+  let inner = @regex_engine.quantifier(a, { min: 0, max: None, mode: Greedy, })
   let outer = @regex_engine.quantifier(inner, {
     min: 0,
     max: None,
@@ -129,7 +129,7 @@ test "nested sequence vs flattened sequence preserve effective-tail preference"
   let dot = @regex_engine.char(@regex_engine.RecharSet::char('.'))
   let space = @regex_engine.char(@regex_engine.RecharSet::char(' '))
   let reluctant_any = @regex_engine.shortest(
-    @regex_engine.quantifier(any, { min: 0, max: None, mode: Greedy }),
+    @regex_engine.quantifier(any, { min: 0, max: None, mode: Greedy, }),
   )
   let nested_pat = @regex_engine.seq([
     @regex_engine.seq([reluctant_any, dot]),
@@ -209,7 +209,7 @@ test "quantifier min > 0 uses iter step" {
   let a = @regex_engine.char(@regex_engine.RecharSet::char('a'))
   let pat = @regex_engine.seq([
     @regex_engine.start_of_input,
-    @regex_engine.quantifier(a, { min: 2, max: Some(3), mode: Greedy }),
+    @regex_engine.quantifier(a, { min: 2, max: Some(3), mode: Greedy, }),
     @regex_engine.end_of_input,
   ])
   let re = @regex_engine.compile(profile~, pat)
diff --git a/string/internal/regex_engine/translate.mbt b/string/internal/regex_engine/translate.mbt
index f183023b70..bf641be015 100644
--- a/string/internal/regex_engine/translate.mbt
+++ b/string/internal/regex_engine/translate.mbt
@@ -25,7 +25,7 @@ fn TranslateContext::new(
   ctx : @automata.Context,
   symbol_table : @symbol_map.Table,
 ) -> TranslateContext {
-  { ctx, pref: First, groups: [], symbol_table }
+  { ctx, pref: First, groups: [], symbol_table, }
 }
 
 ///|
@@ -33,7 +33,7 @@ fn translate(
   tc : TranslateContext,
   ast : Pattern,
 ) -> (@automata.Expr, Preference) {
-  let { ctx, pref, groups, symbol_table } = tc
+  let { ctx, pref, groups, symbol_table, } = tc
   match ast.desc {
     Char(c) => (@automata.e_cset(ctx~, symbol_table.map_set(c)), pref)
     Sequence(exprs) => (transl_seq(tc, exprs), pref)
@@ -78,7 +78,7 @@ fn translate(
       (result, pref)
     }
     Preference(pref2, expr) => {
-      let (cr, pref3) = translate({ ..tc, pref: pref2 }, expr)
+      let (cr, pref3) = translate({ ..tc, pref: pref2, }, expr)
       (enforce_pref(ctx, pref2, pref3, cr), pref2)
     }
     Capture(name~, expr) => {
diff --git a/string/internal/regex_parser/parser.mbt b/string/internal/regex_parser/parser.mbt
index 66fa7777b5..4d3df8fddf 100644
--- a/string/internal/regex_parser/parser.mbt
+++ b/string/internal/regex_parser/parser.mbt
@@ -63,6 +63,7 @@ fn Parser::class_atom(
       }
       (rest, Char(c))
     }
+    (re"^\\u", after=_) => raise ctx.error_at(rest, HINT_INVALID_ESCAPE)
     (re"^\\" + (re"[\^$\\.*+?()\[\]{}|/\"\-:&]" as c), after=rest) =>
       (rest, Char(c.to_int()))
     (re"^\[:ascii:\]", after=rest) => (rest, Class(posix_cset_ascii))
@@ -221,7 +222,7 @@ fn Parser::quantifier_opt(
           raise ctx.error_at(rest0, HINT_UNSUPPORTED_POSSESSIVE_QUANTIFIER)
         _ => (rest, Greedy)
       }
-      (rest, Some({ min, max, mode }))
+      (rest, Some({ min, max, mode, }))
     }
     None => (rest, None)
   }
@@ -289,6 +290,7 @@ fn Parser::term(
       }
       (rest, @re.char(@re.RecharSet::char(c)), false)
     }
+    (re"^\\u", after=_) => raise ctx.error_at(rest, HINT_INVALID_ESCAPE)
     (re"^\\" + (re"[\^$\\.*+?()\[\]{}|/\"]" as c), after=rest) =>
       (rest, @re.char(@re.RecharSet::char(c.to_int())), false)
     (re"^\\.", after=_) => raise ctx.error_at(rest, HINT_INVALID_ESCAPE)
diff --git a/string/internal/regex_parser/parser_context.mbt b/string/internal/regex_parser/parser_context.mbt
index d72b9d6b87..87c5e0039f 100644
--- a/string/internal/regex_parser/parser_context.mbt
+++ b/string/internal/regex_parser/parser_context.mbt
@@ -33,7 +33,7 @@ fn ParserContext::new(
   base~ : Int,
   mode~ : Mode,
 ) -> ParserContext {
-  { profile, base, ignore_case: false, mode }
+  { profile, base, ignore_case: false, mode, }
 }
 
 ///|
diff --git a/string/internal/regex_parser/parser_test.mbt b/string/internal/regex_parser/parser_test.mbt
index 78f8047162..b5f57c72a6 100644
--- a/string/internal/regex_parser/parser_test.mbt
+++ b/string/internal/regex_parser/parser_test.mbt
@@ -894,6 +894,18 @@ test "parse/error_invalid_escape" {
   )
 }
 
+///|
+test "parse/error_empty_unicode_brace_escape" {
+  debug_inspect(
+    Ok(parse(profile=profile_unicode(), mode=String, "[\\u{}]")) catch {
+      e => Err(e)
+    },
+    content=(
+      #|Err(ParserError(at=1, hint="Invalid escape sequence"))
+    ),
+  )
+}
+
 ///|
 test "parse/error_unicode_out_of_range" {
   debug_inspect(
diff --git a/string/regex.mbt b/string/regex.mbt
index 1cd5ac9f08..8bb4f09620 100644
--- a/string/regex.mbt
+++ b/string/regex.mbt
@@ -35,8 +35,8 @@ pub struct Regex {
 /// - Assertions and modifiers: `^`, `$`, `\b`, `\B`, `(?i: ... )`
 ///
 /// Escape sequences include `\n`, `\r`, `\t`, `\f`, `\v`, and escaped
-/// metacharacters. In `Regex::compile`, Unicode escapes are supported:
-/// `\uXXXX` and `\u{X...}`. `\xHH` is not supported in `Regex::compile`.
+/// metacharacters. In `Regex`, Unicode escapes are supported:
+/// `\uXXXX` and `\u{X...}`. `\xHH` is not supported in `Regex`.
 ///
 /// `^` and `$` are non-multiline anchors: they match only the beginning and
 /// end of the whole input, not per-line boundaries.
@@ -71,7 +71,7 @@ pub fn Regex::Regex(pattern : StringView) -> Regex raise {
     pattern,
     mode=String,
   )
-  { pat, re: None }
+  { pat, re: None, }
 }
 
 ///|
@@ -138,7 +138,7 @@ pub fn MatchResult::named_group(
 #doc(hidden)
 pub fn Regex::internal_compile_pattern(pat : Pattern) -> Regex {
   let lowered_pat = re_lower_to_utf16(pat.0)
-  { pat: pat.0, re: Some(@re.compile(profile=re_profile_utf16, lowered_pat)) }
+  { pat: pat.0, re: Some(@re.compile(profile=re_profile_utf16, lowered_pat)), }
 }
 
 ///|
@@ -188,7 +188,8 @@ pub fn Regex::unsafe_from_string(pattern : StringView) -> Regex {
 ///|
 /// Builds a regex that matches `str` literally.
 ///
-/// This is equivalent to `Regex(Regex::escape(str))`.
+/// Every character of `str` is matched literally; regex metacharacters have no
+/// special meaning.
 ///
 /// Example:
 ///
@@ -205,7 +206,7 @@ pub fn Regex::string(str : StringView) -> Regex {
       str.to_array().map(b => @re.char(@re.RecharSet::char(b.to_int()))),
     ),
   )
-  { pat, re: None }
+  { pat, re: None, }
 }
 
 ///|
@@ -268,7 +269,7 @@ pub fn Regex::repeat(
 /// ```
 #intrinsic("%regex.seq")
 pub impl Add for Regex with fn add(self, other) -> Regex {
-  { pat: @re.seq([self.pat, other.pat]), re: None }
+  { pat: @re.seq([self.pat, other.pat]), re: None, }
 }
 
 ///|
@@ -285,7 +286,7 @@ pub impl Add for Regex with fn add(self, other) -> Regex {
 /// ```
 #intrinsic("%regex.alt")
 pub impl BitOr for Regex with fn lor(self, other) -> Regex {
-  { pat: @re.alt([self.pat, other.pat]), re: None }
+  { pat: @re.alt([self.pat, other.pat]), re: None, }
 }
 
 ///|
@@ -353,7 +354,7 @@ pub fn Regex::execute(
   match self.re().execute(input, last_index) {
     None => None
     Some(result) =>
-      Some({ input, group_names: self.re().group_names(), result })
+      Some({ input, group_names: self.re().group_names(), result, })
   }
 }
 
@@ -413,5 +414,5 @@ pub fn Regex::execute(
 /// }
 /// ```
 pub fn Regex::capture(self : Regex, group_name : String) -> Regex {
-  { pat: @re.capture(name=group_name, self.pat), re: None }
+  { pat: @re.capture(name=group_name, self.pat), re: None, }
 }
diff --git a/string/regex_bench_test.mbt b/string/regex_bench_test.mbt
index ada0555401..d8f204204b 100644
--- a/string/regex_bench_test.mbt
+++ b/string/regex_bench_test.mbt
@@ -45,7 +45,7 @@ test "bench regex large pattern" (it : @bench.T) {
     "\{i}".pad_start(3, '0')
   }
   fn make_large_pattern() -> String {
-    let parts = Array::new()
+    let parts = Array()
     for i in 0..<38 {
       let idx = padded3(i)
       parts.push(
@@ -55,7 +55,7 @@ test "bench regex large pattern" (it : @bench.T) {
     parts.join("|")
   }
   fn make_filenames() -> Array[String] {
-    let filenames = Array::new(capacity=20_000)
+    let filenames = Array(capacity=20_000)
     for i in 0..<20_000 {
       if i % 5 == 0 {
         let idx = padded3(i % 38)
diff --git a/string/regex_test.mbt b/string/regex_test.mbt
index 26436adaec..95da9a4749 100644
--- a/string/regex_test.mbt
+++ b/string/regex_test.mbt
@@ -724,6 +724,151 @@ test "capture/email_with_named_groups" {
   )
 }
 
+// Alternation branches that accept the same character.
+//
+// When more than one branch can consume the same character, the alternation
+// leaves more than one thread behind — two that have finished it where the
+// branches are single characters, one finished and one still going where
+// they differ in length. A thread that has finished used to be rewritten to
+// carry the continuation, and the sequence it sat in then appended that
+// continuation a second time, so the text after the alternation had to
+// appear twice for the match to succeed. Every case below turns on that,
+// and each character is exercised on both sides of the overlap.
+
+///|
+test "execute/alternation with an overlapping branch" {
+  // The two branches are the same expression, so both accept 'a'.
+  guard @string.Regex("(?:a|a)c").execute("ac") is Some(m) else {
+    fail("expected a match")
+  }
+  inspect(m.content(), content="ac")
+  // The continuation must be consumed once, not twice.
+  guard @string.Regex("(?:a|a)c").execute("acc") is Some(m) else {
+    fail("expected a match")
+  }
+  inspect(m.content(), content="ac")
+  // ...however long it is.
+  guard @string.Regex("(?:a|a)bc").execute("abcbc") is Some(m) else {
+    fail("expected a match")
+  }
+  inspect(m.content(), content="abc")
+}
+
+///|
+test "execute/alternation of overlapping character classes" {
+  let regex = @string.Regex("(?:[ab]|[bc])x")
+  // 'a' comes only from the left branch, 'c' only from the right, and 'b'
+  // from both — it is the shared one that used to fail.
+  guard regex.execute("ax") is Some(m) else { fail("expected a match") }
+  inspect(m.content(), content="ax")
+  guard regex.execute("bx") is Some(m) else { fail("expected a match") }
+  inspect(m.content(), content="bx")
+  guard regex.execute("cx") is Some(m) else { fail("expected a match") }
+  inspect(m.content(), content="cx")
+  // The same alternation reached through the combinator API.
+  let combined = (re"[ab]" | re"[bc]") + re"x"
+  guard combined.execute("bx") is Some(m) else { fail("expected a match") }
+  inspect(m.content(), content="bx")
+}
+
+///|
+test "execute/alternation branches of different lengths" {
+  // Both branches start with 'a', and they finish at different points.
+  guard @string.Regex("(?:a|ab)c").execute("ac") is Some(m) else {
+    fail("expected a match")
+  }
+  inspect(m.content(), content="ac")
+  guard @string.Regex("(?:ab|a)c").execute("abc") is Some(m) else {
+    fail("expected a match")
+  }
+  inspect(m.content(), content="abc")
+  // A span that is not in the language must not be reported: from 0 only
+  // `a` applies, so the match can end at 1 or 2 but never at 3.
+  guard @string.Regex("(?:.a|a)(?:b|)").execute("abb") is Some(m) else {
+    fail("expected a match")
+  }
+  inspect(m.content(), content="ab")
+}
+
+///|
+test "execute/overlapping alternation under a counted repetition" {
+  // Each iteration can only take one character here, so the bounds are what
+  // decide the length.
+  guard @string.Regex("(?:.|ab){2}").execute("aaaaa") is Some(m) else {
+    fail("expected a match")
+  }
+  inspect(m.content(), content="aa")
+  guard @string.Regex("(?:.|ab){2,4}").execute("aaaaa") is Some(m) else {
+    fail("expected a match")
+  }
+  inspect(m.content(), content="aaaa")
+}
+
+///|
+test "execute/overlapping alternation keeps anchors and preference" {
+  // Anchored: the whole subject has to be consumed exactly once.
+  guard @string.Regex("^(?:a|a)c$").execute("ac") is Some(m) else {
+    fail("expected a match")
+  }
+  inspect(m.content(), content="ac")
+  inspect(@string.Regex("^(?:a|a)c$").execute("acc") is None, content="true")
+  // Preference survives the overlap: the greedy branch wins the character
+  // and the lazy one yields it, and the capture reports which.
+  guard @string.Regex("(?:(a+)|a)(a*)").execute("aaa") is Some(m) else {
+    fail("expected a match")
+  }
+  inspect(m.content(), content="aaa")
+  debug_inspect(
+    m.group(1),
+    content=(
+      #|Some()
+    ),
+  )
+  guard @string.Regex("(?:(a+?)|a)(a*)").execute("aaa") is Some(m) else {
+    fail("expected a match")
+  }
+  inspect(m.content(), content="aaa")
+  debug_inspect(
+    m.group(1),
+    content=(
+      #|Some()
+    ),
+  )
+  // The left branch is preferred where both accept the character.
+  guard @string.Regex("(?:(a)|(a))b").execute("ab") is Some(m) else {
+    fail("expected a match")
+  }
+  debug_inspect(
+    m.group(1),
+    content=(
+      #|Some()
+    ),
+  )
+  debug_inspect(m.group(2), content="None")
+}
+
+// Each character can finish the current iteration or leave its optional
+// suffix pending. Those paths converge to the same future under differently
+// nested `Seq` wrappers; without global deduplication the engine retains one
+// thread per partition of the input and grows exponentially.
+
+///|
+test "execute/variable-length repetition keeps a bounded thread set" {
+  let subject = "a".repeat(1000)
+  for
+    regex in [
+      @string.Regex("^(?:.a?)*$"),
+      re"^(?:.a?)*$",
+      Regex("^(?:.a?b?)*$"),
+      Regex("^(?:.a?)+$"),
+      Regex("^(?:.a?){2,}$"),
+      Regex("^(?:.a?){2,}?$"),
+    ] {
+    guard regex.execute(subject) is Some(m) else { fail("expected a match") }
+    assert_eq(m.content().length(), subject.length())
+  }
+}
+
 ///|
 test "capture/multiple_captures_same_pattern" {
   let word = re"[[:alpha:]]+"
diff --git a/string/string_test.mbt b/string/string_test.mbt
index 8f699dca50..de5309aec0 100644
--- a/string/string_test.mbt
+++ b/string/string_test.mbt
@@ -19,7 +19,7 @@ test "String::from_array" {
 
 ///|
 test "String::from_iter" {
-  inspect(String::from_iter(['1', '2', '3', '4', '5'].iter()), content="12345")
+  inspect(String::from_iter([|'1', '2', '3', '4', '5'|]), content="12345")
 }
 
 ///|
diff --git a/string/string_unicode_test.mbt b/string/string_unicode_test.mbt
index 5225b8309d..ee4377c5f1 100644
--- a/string/string_unicode_test.mbt
+++ b/string/string_unicode_test.mbt
@@ -19,7 +19,7 @@ priv struct Data {
 
 ///|
 test {
-  let data = Data::{ val: "Hello, 世界\t\n!" }
+  let data = Data::{ val: "Hello, 世界\t\n!", }
   // TODO: the data type annotation is not needed
   debug_inspect(
     data,
diff --git a/string/view_test.mbt b/string/view_test.mbt
index 78bc725f11..5f794dc25e 100644
--- a/string/view_test.mbt
+++ b/string/view_test.mbt
@@ -635,7 +635,7 @@ test "from_array" {
 
 ///|
 test "from_iter" {
-  let v = StringView::from_iter(['a', '😭', 'b', '😂', 'c'].iter())
+  let v = StringView::from_iter([|'a', '😭', 'b', '😂', 'c'|])
   inspect(v, content="a😭b😂c")
 }
 
diff --git a/test/README.mbt.md b/test/README.mbt.md
index 409d8aa70e..f30af5c3a5 100644
--- a/test/README.mbt.md
+++ b/test/README.mbt.md
@@ -283,7 +283,7 @@ Keep tests focused on a single concept:
 ///|
 ///  Good - tests one specific behavior
 test "array_push_increases_length" {
-  let arr = Array::new()
+  let arr = Array()
   let initial_length = arr.length()
   arr.push(42)
   let new_length = arr.length()
@@ -293,7 +293,7 @@ test "array_push_increases_length" {
 ///|
 ///  Good - tests another specific behavior
 test "array_push_adds_element_at_end" {
-  let arr = Array::new()
+  let arr = Array()
   arr.push(10)
   arr.push(20)
   inspect(arr[arr.length() - 1], content="20")
diff --git a/test/types.mbt b/test/types.mbt
index 5b8ca8c9e8..a560047f86 100644
--- a/test/types.mbt
+++ b/test/types.mbt
@@ -24,5 +24,5 @@ struct Test {
 #as_free_fn
 #as_free_fn(new, deprecated="Use `Test()` instead")
 pub fn Test::Test(name : String) -> Test {
-  { name, buffer: StringBuilder() }
+  { name, buffer: StringBuilder(), }
 }
diff --git a/uint/README.mbt.md b/uint/README.mbt.md
index 7ea93cb8fa..bf79dba5f9 100644
--- a/uint/README.mbt.md
+++ b/uint/README.mbt.md
@@ -1,6 +1,6 @@
 # `uint`
 
-This package provides functionalities for handling 32-bit unsigned integers in MoonBit. To this end, it includes methods for converting between `UInt` and other number formats, as well as utilities for byte representation.
+This package provides functionalities for handling 32-bit unsigned integers in MoonBit. To this end, it includes `UInt`'s value-range constants, its default value, and conversion to `Int64`.
 
 ## Basic Properties
 
diff --git a/v128/simd_basic.mbt b/v128/simd_basic.mbt
index 51497cfb28..337fc05b16 100644
--- a/v128/simd_basic.mbt
+++ b/v128/simd_basic.mbt
@@ -21,7 +21,7 @@ pub impl Eq for V128 with fn equal(self : V128, other : V128) -> Bool {
 }
 
 ///|
-/// Compares two `V128` values for bitwise equality across both 64-bit words.
+/// Compares two `V128` values for bitwise inequality across both 64-bit words.
 #internal(experimental, "subject to breaking change without notice")
 #doc(hidden)
 pub impl Eq for V128 with fn not_equal(self : V128, other : V128) -> Bool {
@@ -29,14 +29,19 @@ pub impl Eq for V128 with fn not_equal(self : V128, other : V128) -> Bool {
 }
 
 ///|
-fn u64_hex(value : UInt64) -> String {
+fn u64_hex_to(logger : &Logger, value : UInt64) -> Unit {
   let digits = value.to_string(radix=16)
-  let buf = StringBuilder::new()
-  buf.write_string("0x")
+  logger <+ "0x"
   for _ in digits.length()..<16 {
-    buf.write_char('0')
+    logger.write_char('0')
   }
-  buf.write_string(digits)
+  logger.write_string(digits)
+}
+
+///|
+fn u64_hex(value : UInt64) -> String {
+  let buf = StringBuilder()
+  u64_hex_to(buf, value)
   buf.to_string()
 }
 
@@ -46,11 +51,8 @@ fn u64_hex(value : UInt64) -> String {
 #internal(experimental, "subject to breaking change without notice")
 #doc(hidden)
 pub impl Show for V128 with fn output(self : V128, logger) {
-  logger.write_string("V128(")
-  logger.write_string(u64_hex(lo(self)))
-  logger.write_string(", ")
-  logger.write_string(u64_hex(hi(self)))
-  logger.write_string(")")
+  logger <+
+    "V128(\{cb => u64_hex_to(cb, lo(self))}, \{cb => u64_hex_to(cb, hi(self))})"
 }
 
 ///|
diff --git a/v128/simd_memory.mbt b/v128/simd_memory.mbt
index 5ca4667f90..0e8f218985 100644
--- a/v128/simd_memory.mbt
+++ b/v128/simd_memory.mbt
@@ -18,6 +18,15 @@ fn load_u16_le(bytes : FixedArray[Byte], offset : Int) -> UInt16 {
   (bytes.unsafe_get(offset + 1).to_uint16() << 8)
 }
 
+///|
+fn load_u16x4_le(str : String, offset : Int) -> UInt64 {
+  for i in 0..<4; word = (0 : UInt64) {
+    continue word | (str.unsafe_get(offset + i).to_uint64() << (16 * i))
+  } nobreak {
+    word
+  }
+}
+
 ///|
 fn load_u32_le(bytes : FixedArray[Byte], offset : Int) -> UInt {
   bytes.unsafe_get(offset).to_uint() |
@@ -155,6 +164,18 @@ pub fn v128_load(bytes : FixedArray[Byte], offset : Int) -> V128 {
   make(load_u64_le(bytes, offset), load_u64_le(bytes, offset + 8))
 }
 
+///|
+/// Loads eight consecutive UTF-16 code units of `str` starting at code unit
+/// `offset` into the eight 16-bit lanes of the vector. The caller must ensure
+/// `offset + 8 <= str.length()`.
+#intrinsic("%v128.v128_load_i16x8")
+#borrow(str)
+#internal(experimental, "subject to breaking change without notice")
+#doc(hidden)
+pub fn v128_load_i16x8(str : String, offset : Int) -> V128 {
+  make(load_u16x4_le(str, offset), load_u16x4_le(str, offset + 4))
+}
+
 ///|
 /// Loads packed elements from `bytes` and sign-extends them into the vector.
 #intrinsic("%v128.v128_load8x8_s")
diff --git a/v128/v128_test.mbt b/v128/v128_test.mbt
index bae374da6f..db7a7a7a38 100644
--- a/v128/v128_test.mbt
+++ b/v128/v128_test.mbt
@@ -459,6 +459,25 @@ test "loads and stores" {
   assert_eq(with_lane, @v128.i32x4_const(0, 0, 0x40302010, 0))
 }
 
+///|
+test "load eight code units from a string" {
+  // The offset counts code units, not bytes, and a code unit above 0xFF is
+  // loaded into its lane whole.
+  let str = "AB\u{4E2D}\u{6587}CDEFGH"
+  assert_eq(
+    @v128.v128_load_i16x8(str, 0),
+    @v128.i16x8_const(
+      0x0041, 0x0042, 0x4E2D, 0x6587, 0x0043, 0x0044, 0x0045, 0x0046,
+    ),
+  )
+  assert_eq(
+    @v128.v128_load_i16x8(str, 2),
+    @v128.i16x8_const(
+      0x4E2D, 0x6587, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, 0x0048,
+    ),
+  )
+}
+
 ///|
 test "integer widening narrowing and dot products" {
   let bytes = @v128.i8x16_const(