diff --git a/BENCHMARKS.md b/BENCHMARKS.md new file mode 100644 index 0000000..0c3a9e8 --- /dev/null +++ b/BENCHMARKS.md @@ -0,0 +1,77 @@ +# Benchmarks + +Raw results for `collection.New` (borrowed) vs `lo`. + +| Op | ns/op (vs lo) | × (faster) | bytes/op (vs lo) | × (less memory) | allocs/op (vs lo) | +|---:|----------------|:--:|------------------|:--:|--------------------| +| **All** | 248ns / 232ns | ≈ | 24B / 0B | ∞x more | 1 / 0 | +| **Any** | 248ns / 235ns | ≈ | 24B / 0B | ∞x more | 1 / 0 | +| **Chunk** | 138ns / 1.1µs | **7.64x** | 1.3KB / 9.3KB | **7.12x less** | 2 / 51 | +| **Contains** | 250ns / 233ns | ≈ | 24B / 0B | ∞x more | 1 / 0 | +| **CountBy** | 8.6µs / 8.4µs | ≈ | 9.4KB / 9.4KB | ≈ | 12 / 11 | +| **CountByValue** | 8.3µs / 8.3µs | ≈ | 9.4KB / 9.4KB | ≈ | 12 / 11 | +| **Difference** | 19.5µs / 45.6µs | **2.33x** | 82.2KB / 108.8KB | **1.32x less** | 14 / 43 | +| **Each** | 256ns / 233ns | ≈ | 24B / 0B | ∞x more | 1 / 0 | +| **Filter** | 734ns / 1.0µs | **1.41x** | 24B / 8.2KB | **341.33x less** | 1 / 1 | +| **First** | 10ns / <1ns | ≈ | 24B / 0B | ∞x more | 1 / 0 | +| **FirstWhere** | 251ns / 244ns | ≈ | 24B / 0B | ∞x more | 1 / 0 | +| **GroupBySlice** | 8.1µs / 8.3µs | ≈ | 21.0KB / 21.0KB | ≈ | 84 / 83 | +| **IndexWhere** | 247ns / 233ns | ≈ | 24B / 0B | ∞x more | 1 / 0 | +| **Intersect** | 11.1µs / 10.8µs | ≈ | 11.5KB / 11.4KB | ≈ | 22 / 19 | +| **Last** | 10ns / <1ns | ≈ | 24B / 0B | ∞x more | 1 / 0 | +| **Map** | 358ns / 790ns | **2.21x** | 24B / 8.2KB | **341.33x less** | 1 / 1 | +| **Max** | 263ns / 230ns | 0.87x | 32B / 0B | ∞x more | 2 / 0 | +| **Min** | 253ns / 230ns | ≈ | 32B / 0B | ∞x more | 2 / 0 | +| **None** | 248ns / 231ns | ≈ | 24B / 0B | ∞x more | 1 / 0 | +| **Pipeline F→M→T→R** | 690ns / 1.2µs | **1.79x** | 48B / 12.3KB | **256.00x less** | 2 / 2 | +| **Reduce (sum)** | 262ns / 233ns | 0.89x | 24B / 0B | ∞x more | 1 / 0 | +| **Reverse** | 230ns / 230ns | ≈ | 24B / 0B | ∞x more | 1 / 0 | +| **Shuffle** | 3.6µs / 5.6µs | **1.57x** | 24B / 0B | ∞x more | 1 / 0 | +| **Skip** | 10ns / 714ns | **71.40x** | 24B / 8.2KB | **341.33x less** | 1 / 1 | +| **SkipLast** | 10ns / 719ns | **71.90x** | 24B / 8.2KB | **341.33x less** | 1 / 1 | +| **Sum** | 267ns / 231ns | 0.87x | 32B / 0B | ∞x more | 2 / 0 | +| **Take** | 21ns / <1ns | ≈ | 48B / 0B | ∞x more | 2 / 0 | +| **ToMap** | 7.8µs / 7.9µs | ≈ | 37.0KB / 37.0KB | ≈ | 6 / 6 | +| **Union** | 17.4µs / 17.9µs | ≈ | 90.3KB / 90.3KB | ≈ | 13 / 10 | +| **Unique** | 6.5µs / 6.2µs | ≈ | 45.2KB / 45.1KB | ≈ | 7 / 6 | +| **UniqueBy** | 6.6µs / 6.4µs | ≈ | 45.2KB / 45.1KB | ≈ | 8 / 6 | +| **Zip** | 1.4µs / 3.3µs | **2.35x** | 16.4KB / 16.4KB | ≈ | 3 / 1 | +| **ZipWith** | 1.0µs / 3.2µs | **3.15x** | 8.2KB / 8.2KB | ≈ | 3 / 1 | + +Raw results for `collection.New().Clone()` (explicit copy) vs `lo`. + +| Op | ns/op (vs lo) | × (faster) | bytes/op (vs lo) | × (less memory) | allocs/op (vs lo) | +|---:|----------------|:--:|------------------|:--:|--------------------| +| **All** | 921ns / 245ns | 0.27x | 8.2KB / 0B | ∞x more | 3 / 0 | +| **Any** | 908ns / 245ns | 0.27x | 8.2KB / 0B | ∞x more | 3 / 0 | +| **Chunk** | 855ns / 1.1µs | **1.28x** | 9.5KB / 9.3KB | ≈ | 4 / 51 | +| **Contains** | 912ns / 244ns | 0.27x | 8.2KB / 0B | ∞x more | 3 / 0 | +| **CountBy** | 9.2µs / 8.6µs | ≈ | 17.6KB / 9.4KB | 0.53x more | 14 / 11 | +| **CountByValue** | 9.2µs / 8.6µs | ≈ | 17.6KB / 9.4KB | 0.53x more | 14 / 11 | +| **Difference** | 21.1µs / 45.9µs | **2.17x** | 98.6KB / 108.8KB | **1.10x less** | 18 / 43 | +| **Each** | 887ns / 235ns | 0.26x | 8.2KB / 0B | ∞x more | 3 / 0 | +| **Filter** | 1.2µs / 1.0µs | 0.85x | 8.2KB / 8.2KB | ≈ | 3 / 1 | +| **First** | 707ns / <1ns | 0.00x | 8.2KB / 0B | ∞x more | 3 / 0 | +| **FirstWhere** | 903ns / 247ns | 0.27x | 8.2KB / 0B | ∞x more | 3 / 0 | +| **GroupBySlice** | 9.4µs / 8.7µs | ≈ | 29.3KB / 21.0KB | 0.72x more | 86 / 83 | +| **IndexWhere** | 887ns / 236ns | 0.27x | 8.2KB / 0B | ∞x more | 3 / 0 | +| **Intersect** | 12.6µs / 11.2µs | 0.89x | 27.9KB / 11.4KB | 0.41x more | 26 / 19 | +| **Last** | 730ns / <1ns | 0.00x | 8.2KB / 0B | ∞x more | 3 / 0 | +| **Map** | 903ns / 782ns | 0.87x | 8.2KB / 8.2KB | ≈ | 3 / 1 | +| **Max** | 928ns / 236ns | 0.25x | 8.3KB / 0B | ∞x more | 5 / 0 | +| **Min** | 931ns / 235ns | 0.25x | 8.3KB / 0B | ∞x more | 5 / 0 | +| **None** | 882ns / 232ns | 0.26x | 8.2KB / 0B | ∞x more | 3 / 0 | +| **Pipeline F→M→T→R** | 1.2µs / 1.2µs | ≈ | 8.3KB / 12.3KB | **1.49x less** | 4 / 2 | +| **Reduce (sum)** | 887ns / 231ns | 0.26x | 8.2KB / 0B | ∞x more | 3 / 0 | +| **Reverse** | 904ns / 235ns | 0.26x | 8.2KB / 0B | ∞x more | 3 / 0 | +| **Shuffle** | 4.1µs / 5.7µs | **1.39x** | 8.2KB / 0B | ∞x more | 3 / 0 | +| **Skip** | 730ns / 955ns | **1.31x** | 8.2KB / 8.2KB | ≈ | 3 / 1 | +| **SkipLast** | 768ns / 772ns | ≈ | 8.2KB / 8.2KB | ≈ | 3 / 1 | +| **Sum** | 944ns / 239ns | 0.25x | 8.3KB / 0B | ∞x more | 5 / 0 | +| **Take** | 727ns / <1ns | 0.00x | 8.3KB / 0B | ∞x more | 4 / 0 | +| **ToMap** | 8.4µs / 8.1µs | ≈ | 45.2KB / 37.0KB | 0.82x more | 8 / 6 | +| **Union** | 19.6µs / 18.3µs | ≈ | 106.8KB / 90.3KB | 0.85x more | 17 / 10 | +| **Unique** | 7.4µs / 7.5µs | ≈ | 53.4KB / 45.1KB | 0.85x more | 9 / 6 | +| **UniqueBy** | 10.4µs / 6.6µs | 0.64x | 53.4KB / 45.1KB | 0.85x more | 10 / 6 | +| **Zip** | 2.9µs / 3.3µs | **1.15x** | 32.9KB / 16.4KB | 0.50x more | 7 / 1 | +| **ZipWith** | 2.5µs / 3.1µs | **1.27x** | 24.7KB / 8.2KB | 0.33x more | 7 / 1 | diff --git a/README.md b/README.md index 36af4d3..b36a4c6 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@

- goforj/collection logo + goforj/collection logo

- Fluent, Laravel-style Collections for Go - with generics, chainable pipelines, and expressive data transforms. + Fluent collections for Go - with generics, chainable pipelines, and expressive data transforms. Inspired by Laravel, designed to feel natural in Go.

@@ -14,27 +14,26 @@ Latest tag - Tests + Tests Go Report Card

- collection brings an expressive, fluent API to Go. - Iterate, filter, transform, sort, reduce, group, and debug your data with zero dependencies. - Designed to feel natural to Go developers - and luxurious to everyone else. + collection brings an expressive, fluent API to Go. + Iterate, filter, transform, sort, reduce, group, and debug data with zero dependencies - familiar to Go developers, pleasant to use everywhere.

-# Features +## Features - **Fluent chaining** - pipeline your operations like Laravel Collections - **Fully generic** (`Collection[T]`) - no reflection, no `interface{}` -- **Zero dependencies** - pure Go, fast, lightweight -- **Minimal allocations** - avoids unnecessary copies; most operations reuse the underlying slice +- **Minimal dependencies** - small footprint (godump for debugging) +- **Minimal allocations** - slice views where possible; in-place ops reuse backing storage when semantics allow - **Map / Filter / Reduce** - clean functional transforms -- **First / Last / Find / Contains** helpers +- **First / Last / FirstWhere / IndexWhere / Contains** helpers - **Sort, GroupBy, Chunk**, and more -- **Safe-by-default** - defensive copies where appropriate +- **Borrow-by-default** - no defensive copies unless you ask for them - **Built-in JSON helpers** (`ToJSON()`, `ToPrettyJSON()`) - **Developer-friendly debug helpers** (`Dump()`, `Dd()`, `DumpStr()`) - **Works with any Go type**, including structs, pointers, and deeply nested composites @@ -43,7 +42,7 @@ Many methods return the collection itself, allowing for fluent method chaining. -Some methods maybe limited to due to go's generic constraints. +Some methods may be limited due to Go's generic constraints. > **Fluent example:** > [`examples/chaining/main.go`](./examples/chaining/main.go) @@ -75,70 +74,88 @@ collection. // ] ``` - + ### Performance Benchmarks -[lo](https://github.com/samber/lo) is a fantastic library and a major inspiration for this project. Our focus differs: `collection` is built for fluent chaining with explicit mutability, which lets hot paths avoid intermediate allocations. That shows up most in chained pipelines and in-place operations where we can keep work on the same backing slice while still being explicit about behavior. - -| Op | ns/op (vs lo) | × | bytes/op (vs lo) | × | allocs/op (vs lo) | -|---:|----------------|:--:|------------------|:--:|--------------------| -| **All** | 232ns / 230ns | ≈ | 0B / 0B | ≈ | 0 / 0 | -| **Any** | 232ns / 234ns | ≈ | 0B / 0B | ≈ | 0 / 0 | -| **Chunk** | 128ns / 1.1µs | **8.30x** | 1.3KB / 9.3KB | **7.25x less** | 1 / 51 | -| **Contains** | 238ns / 250ns | **1.05x** | 0B / 0B | ≈ | 0 / 0 | -| **CountBy** | 8.1µs / 8.2µs | ≈ | 9.4KB / 9.4KB | ≈ | 11 / 11 | -| **CountByValue** | 8.1µs / 8.1µs | ≈ | 9.4KB / 9.4KB | ≈ | 11 / 11 | -| **Difference** | 19.4µs / 44.5µs | **2.29x** | 82.1KB / 108.8KB | **1.33x less** | 12 / 43 | -| **Each** | 235ns / 230ns | ≈ | 0B / 0B | ≈ | 0 / 0 | -| **Filter** | 647ns / 1.1µs | **1.67x** | 0B / 8.2KB | **∞x less** | 0 / 1 | -| **Find** | 239ns / 235ns | ≈ | 0B / 0B | ≈ | 0 / 0 | -| **First** | 0ns / 0ns | ∞ | 0B / 0B | ≈ | 0 / 0 | -| **GroupBySlice** | 8.2µs / 8.3µs | ≈ | 21.0KB / 21.0KB | ≈ | 83 / 83 | -| **IndexWhere** | 232ns / 231ns | ≈ | 0B / 0B | ≈ | 0 / 0 | -| **Intersect** | 11.0µs / 10.8µs | ≈ | 11.4KB / 11.4KB | ≈ | 19 / 19 | -| **Last** | 0ns / 0ns | ∞ | 0B / 0B | ≈ | 0 / 0 | -| **Map** | 347ns / 821ns | **2.37x** | 0B / 8.2KB | **∞x less** | 0 / 1 | -| **Max** | 230ns / 231ns | ≈ | 0B / 0B | ≈ | 0 / 0 | -| **Min** | 232ns / 229ns | ≈ | 0B / 0B | ≈ | 0 / 0 | -| **None** | 232ns / 232ns | ≈ | 0B / 0B | ≈ | 0 / 0 | -| **Pipeline F→M→T→R** | 496ns / 1.3µs | **2.62x** | 0B / 12.3KB | **∞x less** | 0 / 2 | -| **Reduce (sum)** | 230ns / 231ns | ≈ | 0B / 0B | ≈ | 0 / 0 | -| **Reverse** | 216ns / 230ns | **1.06x** | 0B / 0B | ≈ | 0 / 0 | -| **Shuffle** | 3.6µs / 5.3µs | **1.49x** | 0B / 0B | ≈ | 0 / 0 | -| **Skip** | 0ns / 721ns | ∞ | 0B / 8.2KB | **∞x less** | 0 / 1 | -| **SkipLast** | 0ns / 730ns | ∞ | 0B / 8.2KB | **∞x less** | 0 / 1 | -| **Sum** | 232ns / 233ns | ≈ | 0B / 0B | ≈ | 0 / 0 | -| **Take** | 0ns / 0ns | ∞ | 0B / 0B | ≈ | 0 / 0 | -| **ToMap** | 7.7µs / 7.8µs | ≈ | 36.9KB / 37.0KB | ≈ | 5 / 6 | -| **Union** | 17.4µs / 17.7µs | ≈ | 90.3KB / 90.3KB | ≈ | 11 / 10 | -| **Unique** | 6.4µs / 6.5µs | ≈ | 45.1KB / 45.1KB | ≈ | 6 / 6 | -| **UniqueBy** | 6.9µs / 6.7µs | ≈ | 45.2KB / 45.1KB | ≈ | 7 / 6 | -| **Zip** | 1.4µs / 3.2µs | **2.27x** | 16.4KB / 16.4KB | ≈ | 1 / 1 | -| **ZipWith** | 1.0µs / 3.1µs | **3.07x** | 8.2KB / 8.2KB | ≈ | 1 / 1 | +> **tl;dr**: *lo* is excellent. We solve a different problem - and in chained pipelines, that difference matters. + +`lo` is a fantastic library and a major inspiration for this project. It is battle-tested, idiomatic, and often the right choice when you want small, standalone helpers that operate on slices in isolation. + +`collection` takes a different approach. + +Rather than treating each operation as an independent transformation, `collection` is built around **explicit, fluent pipelines**. Many operations are designed to **mutate the same backing slice intentionally**, allowing chained workflows to avoid intermediate allocations and unnecessary copying - while still making that behavior visible and documented. + +That design choice doesn't matter much for some single operations. It matters a *lot* once you start chaining and especially in hot paths. + + + +Full raw tables: see `BENCHMARKS.md`. + +#### Read-only scalar ops (wrapper overhead only) + +| Op | Speed vs lo | Memory | Allocs | +|---:|:-----------:|:------:|:------:| +| **All** | ≈ | +24B | +1 | +| **Any** | ≈ | +24B | +1 | +| **None** | ≈ | +24B | +1 | +| **First** | ≈ | +24B | +1 | +| **Last** | ≈ | +24B | +1 | +| **FirstWhere** | ≈ | +24B | +1 | +| **IndexWhere** | ≈ | +24B | +1 | +| **Contains** | ≈ | +24B | +1 | +| **Reduce (sum)** | ≈ | +24B | +1 | +| **Sum** | ≈ | +32B | +2 | +| **Min** | ≈ | +32B | +2 | +| **Max** | ≈ | +32B | +2 | +| **Each** | ≈ | +24B | +1 | + +#### Transforming ops + +| Op | Speed vs lo | Memory | Allocs | +|---:|:-----------:|:------:|:------:| +| **Chunk** | **7.64x** | -8.0KB | -49 | +| **Take** | ≈ | +48B | +2 | +| **Skip** | **71.40x** | -8.2KB | ≈ | +| **SkipLast** | **71.90x** | -8.2KB | ≈ | +| **Zip** | **2.35x** | +48B | +2 | +| **ZipWith** | **3.15x** | +48B | +2 | +| **Unique** | ≈ | +24B | +1 | +| **UniqueBy** | ≈ | +48B | +2 | +| **Union** | ≈ | +72B | +3 | +| **Intersect** | ≈ | +72B | +3 | +| **Difference** | **2.33x** | -26.7KB | -29 | +| **GroupBySlice** | ≈ | +24B | +1 | +| **CountBy** | ≈ | +24B | +1 | +| **CountByValue** | ≈ | +24B | +1 | +| **ToMap** | ≈ | -24B | ≈ | + +#### Pipelines + +| Op | Speed vs lo | Memory | Allocs | +|---:|:-----------:|:------:|:------:| +| **Pipeline F→M→T→R** | **1.79x** | -12.2KB | ≈ | + +#### Mutating ops + +| Op | Speed vs lo | Memory | Allocs | +|---:|:-----------:|:------:|:------:| +| **Map** | **2.21x** | -8.2KB | ≈ | +| **Filter** | **1.41x** | -8.2KB | ≈ | +| **Reverse** | ≈ | +24B | +1 | +| **Shuffle** | **1.57x** | +24B | +1 | ## How to read the benchmarks * **≈** means the two libraries are effectively equivalent -* **∞x less** means one side allocates while the other allocates nothing +* Explicit memory deltas show fixed wrapper overhead vs avoided allocations * Single-operation helpers are intentionally close in performance if not exceeds * Multi-step pipelines highlight the architectural difference If you prefer immutable, one-off helpers - `lo` is outstanding. If you write **expressive, chained data pipelines** and care about hot-path performance - `collection` is built for that job. -## Performance Philosophy - -> **tl;dr**: *lo* is excellent. We solve a different problem - and in chained pipelines, that difference matters. - -`lo` is a fantastic library and a major inspiration for this project. It is battle-tested, idiomatic, and often the right choice when you want small, standalone helpers that operate on slices in isolation. - -`collection` takes a different approach. - -Rather than treating each operation as an independent transformation, `collection` is built around **explicit, fluent pipelines**. Many operations are designed to **mutate the same backing slice intentionally**, allowing chained workflows to avoid intermediate allocations and unnecessary copying - while still making that behavior visible and documented. - -That design choice doesn't matter much for some single operations. It matters a *lot* once you start chaining and especially in hot paths. ## Why chaining changes the performance story @@ -153,7 +170,7 @@ That model is simple and safe - but each step typically allocates. `collection` pipelines are designed to look more like this: ``` -input → Filter (in place) → Sort (in place) → Take (slice view) +input → Filter (in place) → Map (in place) → Take (slice view) ``` When you opt into mutation, **the pipeline stays on the same backing array** unless an operation explicitly documents that it allocates. The result is: @@ -178,6 +195,9 @@ In these cases, `collection` can be **2×–30× faster** and often reduce alloc Fluent pipelines don't mean you're locked into mutation. +This library borrows slices by default. It does not perform defensive copies. +Use `Clone()` or `ItemsCopy()` to explicitly copy. + When you want to branch a pipeline or preserve the original data, `Clone()` creates a shallow copy of the collection so subsequent operations are isolated and predictable. ```go @@ -214,6 +234,7 @@ No hidden copies. No surprises. - Not concurrency-aware - Not immutable-by-default - Not a replacement for idiomatic loops in simple cases +- Not designed to hide allocation, mutation, or ordering semantics ## Working with maps @@ -259,25 +280,25 @@ go get github.com/goforj/collection | Group | Functions | |------:|-----------| -| **Access** | [Items](#items) | +| **Access** | [Items](#items) [ItemsCopy](#itemscopy) | | **Aggregation** | [Avg](#avg) [Count](#count) [CountBy](#countby) [CountByValue](#countbyvalue) [Max](#max) [MaxBy](#maxby) [Median](#median) [Min](#min) [MinBy](#minby) [Mode](#mode) [Reduce](#reduce) [Sum](#sum) | | **Construction** | [Clone](#clone) [New](#new) [NewNumeric](#newnumeric) | | **Debugging** | [Dd](#dd) [Dump](#dump) [DumpStr](#dumpstr) | | **Grouping** | [GroupBy](#groupby) [GroupBySlice](#groupbyslice) | | **Maps** | [FromMap](#frommap) [ToMap](#tomap) [ToMapKV](#tomapkv) | | **Ordering** | [After](#after) [Before](#before) [Reverse](#reverse) [Shuffle](#shuffle) [Sort](#sort) | -| **Querying** | [All](#all) [Any](#any) [At](#at) [Contains](#contains) [FindWhere](#findwhere) [First](#first) [FirstWhere](#firstwhere) [IndexWhere](#indexwhere) [IsEmpty](#isempty) [Last](#last) [LastWhere](#lastwhere) [None](#none) | +| **Querying** | [All](#all) [Any](#any) [At](#at) [Contains](#contains) [First](#first) [FirstWhere](#firstwhere) [IndexWhere](#indexwhere) [IsEmpty](#isempty) [Last](#last) [LastWhere](#lastwhere) [None](#none) | | **Serialization** | [ToJSON](#tojson) [ToPrettyJSON](#toprettyjson) | | **Set Operations** | [Difference](#difference) [Intersect](#intersect) [SymmetricDifference](#symmetricdifference) [Union](#union) [Unique](#unique) [UniqueBy](#uniqueby) [UniqueComparable](#uniquecomparable) | -| **Slicing** | [Chunk](#chunk) [Filter](#filter) [Partition](#partition) [Pop](#pop) [PopN](#popn) [Skip](#skip) [SkipLast](#skiplast) [Take](#take) [TakeLast](#takelast) [TakeUntil](#takeuntil) [TakeUntilFn](#takeuntilfn) [Where](#where) [Window](#window) | -| **Transformation** | [Append](#append) [Concat](#concat) [Each](#each) [Map](#map) [MapTo](#mapto) [Merge](#merge) [Multiply](#multiply) [Pipe](#pipe) [Pluck](#pluck) [Prepend](#prepend) [Push](#push) [Tap](#tap) [Times](#times) [Transform](#transform) [Zip](#zip) [ZipWith](#zipwith) | +| **Slicing** | [Chunk](#chunk) [Filter](#filter) [Partition](#partition) [Pop](#pop) [PopN](#popn) [Skip](#skip) [SkipLast](#skiplast) [Take](#take) [TakeLast](#takelast) [TakeUntil](#takeuntil) [TakeUntilFn](#takeuntilfn) [Window](#window) | +| **Transformation** | [Append](#append) [Concat](#concat) [Each](#each) [Map](#map) [MapTo](#mapto) [Merge](#merge) [Multiply](#multiply) [Pipe](#pipe) [Prepend](#prepend) [Tap](#tap) [Times](#times) [Transform](#transform) [Zip](#zip) [ZipWith](#zipwith) | ## Access -### Items · readonly · fluent +### Items · readonly · terminal -Items returns the underlying slice of items. +Items returns the backing slice of items. _Example: integers_ @@ -331,9 +352,24 @@ collection.Dump(out) // ] ``` +### ItemsCopy · readonly · terminal + +ItemsCopy returns a copy of the collection's items. + +```go +c := collection.New([]int{1, 2, 3}) +items := c.ItemsCopy() +collection.Dump(items) +// #[]int [ +// 0 => 1 #int +// 1 => 2 #int +// 2 => 3 #int +// ] +``` + ## Aggregation -### Avg · readonly +### Avg · readonly · terminal Avg returns the average of the collection values as a float64. If the collection is empty, Avg returns 0. @@ -354,7 +390,7 @@ collection.Dump(c2.Avg()) // 2.333333 #float64 ``` -### Count · readonly · fluent +### Count · readonly · terminal Count returns the total number of items in the collection. @@ -364,7 +400,7 @@ collection.Dump(count) // 4 #int ``` -### CountBy · readonly +### CountBy · readonly · terminal CountBy returns a map of keys extracted by fn to their occurrence counts. K must be comparable. @@ -426,7 +462,7 @@ collection.Dump(roleCounts) // } ``` -### CountByValue · readonly +### CountByValue · readonly · terminal CountByValue returns a map where each distinct item in the collection is mapped to the number of times it appears. @@ -478,7 +514,7 @@ collection.Dump(counts3) // ] ``` -### Max · readonly +### Max · readonly · terminal Max returns the largest numeric item in the collection. The second return value is false if the collection is empty. @@ -516,7 +552,7 @@ collection.Dump(max3, ok3) // false #bool ``` -### MaxBy · readonly +### MaxBy · readonly · terminal MaxBy returns the item whose key (produced by keyFn) is the largest. The second return value is false if the collection is empty. @@ -571,7 +607,7 @@ collection.Dump(maxVal, ok) // false #bool ``` -### Median · readonly +### Median · readonly · terminal Median returns the statistical median of the numeric collection as float64. Returns (0, false) if the collection is empty. @@ -620,7 +656,7 @@ collection.Dump(median4, ok4) // false #bool ``` -### Min · readonly +### Min · readonly · terminal Min returns the smallest numeric item in the collection. The second return value is false if the collection is empty. @@ -655,7 +691,7 @@ collection.Dump(min3, ok3) // false #bool ``` -### MinBy · readonly +### MinBy · readonly · terminal MinBy returns the item whose key (produced by keyFn) is the smallest. The second return value is false if the collection is empty. @@ -710,7 +746,7 @@ collection.Dump(minVal, ok) // false #bool ``` -### Mode · readonly +### Mode · readonly · terminal Mode returns the most frequent numeric value(s) in the collection. If multiple values tie for highest frequency, all are returned @@ -759,7 +795,7 @@ collection.Dump(mode4) // ``` -### Reduce · readonly · fluent +### Reduce · readonly · terminal Reduce collapses the collection into a single accumulated value. The accumulator has the same type T as the collection's elements. @@ -811,7 +847,7 @@ collection.Dump(total) // ] ``` -### Sum · readonly +### Sum · readonly · terminal Sum returns the sum of all numeric items in the NumericCollection. If the collection is empty, Sum returns the zero value of T. @@ -845,9 +881,9 @@ collection.Dump(total3) ## Construction -### Clone · allocates · fluent +### Clone · immutable · chainable -Clone returns a shallow copy of the collection. +Clone returns a copy of the collection. _Example: basic cloning_ @@ -855,7 +891,7 @@ _Example: basic cloning_ c := collection.New([]int{1, 2, 3}) clone := c.Clone() -clone.Push(4) +clone.Append(4) collection.Dump(c.Items()) // #[]int [ @@ -909,18 +945,17 @@ collection.Dump(odds.Items()) // ] ``` -### New · immutable · fluent +### New · immutable · chainable -New creates a new Collection from the provided slice. +New creates a new Collection from the provided slice and borrows it. -### NewNumeric · immutable · fluent +### NewNumeric · immutable · chainable -NewNumeric wraps a slice of numeric types in a NumericCollection. -A shallow copy is made so that further operations don't mutate the original slice. +NewNumeric wraps a slice of numeric types in a NumericCollection and borrows it. ## Debugging -### Dd · fluent +### Dd · terminal Dd prints items then terminates execution. Like Laravel's dd(), this is intended for debugging and @@ -936,7 +971,7 @@ c.Dd() // Process finished with the exit code 1 ``` -### Dump · readonly · fluent +### Dump · readonly · chainable Dump prints items with godump and returns the same collection. This is a no-op on the collection itself and never panics. @@ -977,7 +1012,7 @@ collection.Dump(c2.Items()) // ] ``` -### DumpStr · readonly · fluent +### DumpStr · readonly · terminal DumpStr returns the pretty-printed dump of the items as a string, without printing or exiting. @@ -995,7 +1030,7 @@ fmt.Println(s) ## Grouping -### GroupBy · readonly +### GroupBy · readonly · terminal GroupBy partitions the collection into groups keyed by the value returned from keyFn. @@ -1067,7 +1102,7 @@ collection.Dump(groups2["user"].Items()) // ] ``` -### GroupBySlice · readonly +### GroupBySlice · readonly · terminal GroupBySlice partitions the collection into groups keyed by the value returned from keyFn. @@ -1141,7 +1176,7 @@ collection.Dump(groups2["user"]) ## Maps -### FromMap · immutable · fluent +### FromMap · immutable · chainable FromMap materializes a map into a collection of key/value pairs. @@ -1212,7 +1247,7 @@ collection.Dump(out2) // ] ``` -### ToMap · readonly +### ToMap · readonly · terminal ToMap reduces a collection into a map using the provided key and value selector functions. @@ -1253,7 +1288,7 @@ byID := collection.ToMap( collection.Dump(byID) ``` -### ToMapKV · readonly +### ToMapKV · readonly · terminal ToMapKV converts a collection of key/value pairs into a map. @@ -1310,7 +1345,7 @@ collection.Dump(out2) ## Ordering -### After · immutable · fluent +### After · immutable · chainable After returns all items after the first element for which pred returns true. If no element matches, an empty collection is returned. @@ -1324,7 +1359,7 @@ c.After(func(v int) bool { return v == 3 }).Dump() // ] ``` -### Before · immutable · fluent +### Before · immutable · chainable Before returns a new collection containing all items that appear *before* the first element for which pred returns true. @@ -1377,7 +1412,7 @@ collection.Dump(out3.Items()) // ] ``` -### Reverse · mutable · fluent +### Reverse · mutable · chainable Reverse reverses the order of items in the collection in place and returns the same collection for chaining. @@ -1435,10 +1470,9 @@ collection.Dump(users.Items()) // ] ``` -### Shuffle · mutable · fluent +### Shuffle · mutable · chainable -Shuffle randomly shuffles the items in the collection in place -and returns the same collection for chaining. +Shuffle shuffles the collection in place and returns the same collection. _Example: integers_ @@ -1451,12 +1485,12 @@ collection.Dump(c.Items()) _Example: strings – chaining_ ```go -out := collection.New([]string{"a", "b", "c"}). +out2 := collection.New([]string{"a", "b", "c"}). Shuffle(). Append("d"). Items() -collection.Dump(out) +collection.Dump(out2) ``` _Example: structs_ @@ -1477,7 +1511,7 @@ users.Shuffle() collection.Dump(users.Items()) ``` -### Sort · mutable · fluent +### Sort · mutable · chainable Sort sorts the collection in place using the provided comparison function and returns the same collection for chaining. @@ -1546,7 +1580,7 @@ collection.Dump(users.Items()) ## Querying -### All · readonly · fluent +### All · readonly · terminal All returns true if fn returns true for every item in the collection. If the collection is empty, All returns true (vacuously true). @@ -1587,7 +1621,7 @@ collection.Dump(all) // true #bool ``` -### Any · readonly · fluent +### Any · readonly · terminal Any returns true if at least one item satisfies fn. @@ -1598,7 +1632,7 @@ collection.Dump(has) // true #bool ``` -### At · readonly · fluent +### At · readonly · terminal At returns the item at the given index and a boolean indicating whether the index was within bounds. @@ -1638,18 +1672,16 @@ collection.Dump(u, ok3) // {ID:1 Name:"Alice"} true ``` -### Contains · readonly · fluent +### Contains · readonly · terminal -Contains returns true if any item satisfies the predicate. +Contains returns true if the collection contains the given value. _Example: integers_ ```go c := collection.New([]int{1, 2, 3, 4, 5}) -hasEven := c.Contains(func(v int) bool { - return v%2 == 0 -}) -collection.Dump(hasEven) +hasTwo := collection.Contains(c, 2) +collection.Dump(hasTwo) // true #bool ``` @@ -1657,102 +1689,12 @@ _Example: strings_ ```go c2 := collection.New([]string{"apple", "banana", "cherry"}) -hasBanana := c2.Contains(func(v string) bool { - return v == "banana" -}) +hasBanana := collection.Contains(c2, "banana") collection.Dump(hasBanana) // true #bool ``` -_Example: structs_ - -```go -type User struct { - ID int - Name string -} - -users := collection.New([]User{ - {ID: 1, Name: "Alice"}, - {ID: 2, Name: "Bob"}, - {ID: 3, Name: "Carol"}, -}) - -hasBob := users.Contains(func(u User) bool { - return u.Name == "Bob" -}) -collection.Dump(hasBob) -// true #bool -``` - -### FindWhere · readonly · fluent - -FindWhere returns the first item in the collection for which the provided -predicate function returns true. This is an alias for FirstWhere(fn) and -exists for ergonomic parity with functional languages (JavaScript, Rust, -C#, Python) where developers expect a “find” helper. - -_Example: integers_ - -```go -nums := collection.New([]int{1, 2, 3, 4, 5}) - -v1, ok1 := nums.FindWhere(func(n int) bool { - return n == 3 -}) -collection.Dump(v1, ok1) -// 3 #int -// true #bool -``` - -_Example: no match_ - -```go -v2, ok2 := nums.FindWhere(func(n int) bool { - return n > 10 -}) -collection.Dump(v2, ok2) -// 0 #int -// false #bool -``` - -_Example: structs_ - -```go -type User struct { - ID int - Name string -} - -users := collection.New([]User{ - {ID: 1, Name: "Alice"}, - {ID: 2, Name: "Bob"}, - {ID: 3, Name: "Charlie"}, -}) - -u, ok3 := users.FindWhere(func(u User) bool { - return u.ID == 2 -}) -collection.Dump(u, ok3) -// #collection.User { -// +ID => 2 #int -// +Name => "Bob" #string -// } -// true #bool -``` - -_Example: integers - empty collection_ - -```go -empty := collection.New([]int{}) - -v4, ok4 := empty.FindWhere(func(n int) bool { return n == 1 }) -collection.Dump(v4, ok4) -// 0 #int -// false #bool -``` - -### First · readonly · fluent +### First · readonly · terminal First returns the first element in the collection. If the collection is empty, ok will be false. @@ -1811,7 +1753,7 @@ collection.Dump(v3, ok4) // false #bool ``` -### FirstWhere · readonly · fluent +### FirstWhere · readonly · terminal FirstWhere returns the first item in the collection for which the provided predicate function returns true. If no items match, ok=false is returned @@ -1834,7 +1776,7 @@ collection.Dump(v, ok) // false #bool ``` -### IndexWhere · readonly · fluent +### IndexWhere · readonly · terminal IndexWhere returns the index of the first item in the collection for which the provided predicate function returns true. @@ -1879,7 +1821,7 @@ collection.Dump(idx3, ok3) // 1 true ``` -### IsEmpty · readonly · fluent +### IsEmpty · readonly · terminal IsEmpty returns true if the collection has no items. @@ -1930,7 +1872,7 @@ collection.Dump(empty4) // true #bool ``` -### Last · readonly · fluent +### Last · readonly · terminal Last returns the last element in the collection. If the collection is empty, ok will be false. @@ -1991,7 +1933,7 @@ collection.Dump(v3, ok4) // false #bool ``` -### LastWhere · readonly · fluent +### LastWhere · readonly · terminal LastWhere returns the last element in the collection that satisfies the predicate fn. If fn is nil, LastWhere returns the final element in the underlying slice. @@ -2084,7 +2026,7 @@ collection.Dump(v5, ok6) // false #bool ``` -### None · readonly · fluent +### None · readonly · terminal None returns true if fn returns false for every item in the collection. If the collection is empty, None returns true. @@ -2118,7 +2060,7 @@ collection.Dump(none) ## Serialization -### ToJSON · readonly · fluent +### ToJSON · readonly · terminal ToJSON converts the collection's items into a compact JSON string. @@ -2129,7 +2071,7 @@ fmt.Println(out1) // ["a","b"] ``` -### ToPrettyJSON · readonly · fluent +### ToPrettyJSON · readonly · terminal ToPrettyJSON converts the collection's items into a human-readable, indented JSON string. @@ -2146,7 +2088,7 @@ fmt.Println(out1) ## Set Operations -### Difference · immutable · fluent +### Difference · immutable · chainable Difference returns a new collection containing elements from the first collection that are not present in the second. Order follows the first collection, and @@ -2212,7 +2154,7 @@ collection.Dump(out3.Items()) // ] ``` -### Intersect · immutable · fluent +### Intersect · immutable · chainable Intersect returns a new collection containing elements from the second collection that are also present in the first. @@ -2281,7 +2223,7 @@ collection.Dump(out3.Items()) // ] ``` -### SymmetricDifference · immutable · fluent +### SymmetricDifference · immutable · chainable SymmetricDifference returns a new collection containing elements that appear in exactly one of the two collections. Order follows the first collection for @@ -2349,7 +2291,7 @@ collection.Dump(out3.Items()) // ] ``` -### Union · immutable · fluent +### Union · immutable · chainable Union returns a new collection containing the unique elements from both collections. Items from the first collection are kept in order, followed by items from the second @@ -2423,7 +2365,7 @@ collection.Dump(out3.Items()) // ] ``` -### Unique · immutable · fluent +### Unique · immutable · chainable Unique returns a new collection with duplicate items removed, based on the equality function `eq`. The first occurrence of each unique value is kept, @@ -2483,7 +2425,7 @@ collection.Dump(out3.Items()) // ] ``` -### UniqueBy · immutable · fluent +### UniqueBy · immutable · chainable UniqueBy returns a new collection containing only the first occurrence of each element as determined by keyFn. @@ -2540,7 +2482,7 @@ collection.Dump(out3.Items()) // ] ``` -### UniqueComparable · immutable · fluent +### UniqueComparable · immutable · chainable UniqueComparable returns a new collection with duplicate comparable items removed. The first occurrence of each value is kept, and order is preserved. @@ -2576,7 +2518,7 @@ collection.Dump(out2.Items()) ## Slicing -### Chunk · readonly · fluent +### Chunk · readonly · terminal Chunk splits the collection into chunks of the given size. The final chunk may be smaller if len(items) is not divisible by size. @@ -2645,7 +2587,7 @@ collection.Dump(userChunks) //] ``` -### Filter · mutable · fluent +### Filter · mutable · chainable Filter keeps only the elements for which fn returns true. This method mutates the collection in place and returns the same instance. @@ -2710,7 +2652,7 @@ collection.Dump(users.Items()) // ] ``` -### Partition · immutable · fluent +### Partition · immutable · terminal Partition splits the collection into two new collections based on predicate fn. The first collection contains items where fn returns true; the second contains @@ -2790,18 +2732,18 @@ collection.Dump(active.Items(), inactive.Items()) // ] ``` -### Pop · mutable · fluent +### Pop · mutable · terminal -Pop returns the last item and a new collection with that item removed. -The original collection remains unchanged. +Pop removes and returns the last item in the collection. _Example: integers_ ```go c := collection.New([]int{1, 2, 3}) -item, rest := c.Pop() -collection.Dump(item, rest.Items()) +item, ok := c.Pop() +collection.Dump(item, ok, c.Items()) // 3 #int +// true #bool // #[]int [ // 0 => 1 #int // 1 => 2 #int @@ -2812,9 +2754,10 @@ _Example: strings_ ```go c2 := collection.New([]string{"a", "b", "c"}) -item2, rest2 := c2.Pop() -collection.Dump(item2, rest2.Items()) +item2, ok2 := c2.Pop() +collection.Dump(item2, ok2, c2.Items()) // "c" #string +// true #bool // #[]string [ // 0 => "a" #string // 1 => "b" #string @@ -2834,12 +2777,13 @@ users := collection.New([]User{ {ID: 2, Name: "Bob"}, }) -item3, rest3 := users.Pop() -collection.Dump(item3, rest3.Items()) +item3, ok3 := users.Pop() +collection.Dump(item3, ok3, users.Items()) // #main.User { // +ID => 2 #int // +Name => "Bob" #string // } +// true #bool // #[]main.User [ // 0 => #main.User { // +ID => 1 #int @@ -2852,27 +2796,27 @@ _Example: empty collection_ ```go empty := collection.New([]int{}) -item4, rest4 := empty.Pop() -collection.Dump(item4, rest4.Items()) +item4, ok4 := empty.Pop() +collection.Dump(item4, ok4, empty.Items()) // 0 #int +// false #bool // #[]int [ // ] ``` -### PopN · mutable · fluent +### PopN · mutable · terminal -PopN removes and returns the last n items as a new collection, -and returns a second collection containing the remaining items. +PopN removes and returns the last n items in original order. _Example: integers – pop 2_ ```go c := collection.New([]int{1, 2, 3, 4}) -popped, rest := c.PopN(2) -collection.Dump(popped.Items(), rest.Items()) +popped := c.PopN(2) +collection.Dump(popped, c.Items()) // #[]int [ -// 0 => 4 #int -// 1 => 3 #int +// 0 => 3 #int +// 1 => 4 #int // ] // #[]int [ // 0 => 1 #int @@ -2884,8 +2828,8 @@ _Example: strings – pop 1_ ```go c2 := collection.New([]string{"a", "b", "c"}) -popped2, rest2 := c2.PopN(1) -collection.Dump(popped2.Items(), rest2.Items()) +popped2 := c2.PopN(1) +collection.Dump(popped2, c2.Items()) // #[]string [ // 0 => "c" #string // ] @@ -2909,17 +2853,17 @@ users := collection.New([]User{ {ID: 3, Name: "Carol"}, }) -popped3, rest3 := users.PopN(2) -collection.Dump(popped3.Items(), rest3.Items()) +popped3 := users.PopN(2) +collection.Dump(popped3, users.Items()) // #[]main.User [ // 0 => #main.User { -// +ID => 3 #int -// +Name => "Carol" #string -// } -// 1 => #main.User { // +ID => 2 #int // +Name => "Bob" #string // } +// 1 => #main.User { +// +ID => 3 #int +// +Name => "Carol" #string +// } // ] // #[]main.User [ // 0 => #main.User { @@ -2929,14 +2873,13 @@ collection.Dump(popped3.Items(), rest3.Items()) // ] ``` -_Example: integers - n <= 0 → returns empty popped + original collection_ +_Example: integers - n <= 0 → returns nil, no change_ ```go c3 := collection.New([]int{1, 2, 3}) -popped4, rest4 := c3.PopN(0) -collection.Dump(popped4.Items(), rest4.Items()) -// #[]int [ -// ] +popped4 := c3.PopN(0) +collection.Dump(popped4, c3.Items()) +// // #[]int [ // 0 => 1 #int // 1 => 2 #int @@ -2948,17 +2891,17 @@ _Example: strings - n exceeds length → all items popped, rest empty_ ```go c4 := collection.New([]string{"x", "y"}) -popped5, rest5 := c4.PopN(10) -collection.Dump(popped5.Items(), rest5.Items()) +popped5 := c4.PopN(10) +collection.Dump(popped5, c4.Items()) // #[]string [ -// 0 => "y" #string -// 1 => "x" #string +// 0 => "x" #string +// 1 => "y" #string // ] // #[]string [ // ] ``` -### Skip · immutable · fluent +### Skip · immutable · chainable Skip returns a new collection with the first n items skipped. If n is less than or equal to zero, Skip returns the full collection. @@ -3025,7 +2968,7 @@ collection.Dump(out4.Items()) // ] ``` -### SkipLast · immutable · fluent +### SkipLast · immutable · chainable SkipLast returns a new collection with the last n items skipped. If n is less than or equal to zero, SkipLast returns the full collection. @@ -3088,7 +3031,7 @@ collection.Dump(out4.Items()) // ] ``` -### Take · immutable · fluent +### Take · immutable · chainable Take returns a new collection containing the first `n` items when n > 0, or the last `|n|` items when n < 0. @@ -3140,7 +3083,7 @@ collection.Dump(out4.Items()) // ] ``` -### TakeLast · immutable · fluent +### TakeLast · immutable · chainable TakeLast returns a new collection containing the last n items. If n is less than or equal to zero, TakeLast returns an empty collection. @@ -3201,7 +3144,7 @@ collection.Dump(out4.Items()) // ] ``` -### TakeUntil · immutable · fluent +### TakeUntil · immutable · chainable TakeUntil returns items until the first element equals `value`. The matching item is NOT included. @@ -3241,7 +3184,7 @@ collection.Dump(out6.Items()) // ] ``` -### TakeUntilFn · immutable · fluent +### TakeUntilFn · immutable · chainable TakeUntilFn returns items until the predicate function returns true. The matching item is NOT included. @@ -3281,58 +3224,7 @@ collection.Dump(out3.Items()) // ] ``` -### Where · mutable · fluent - -Where keeps only the elements for which fn returns true. -This is an alias for Filter(fn) for SQL-style ergonomics. -This method mutates the collection in place and returns the same instance. - -_Example: integers_ - -```go -nums := collection.New([]int{1, 2, 3, 4}) -nums.Where(func(v int) bool { - return v%2 == 0 -}) -collection.Dump(nums.Items()) -// #[]int [ -// 0 => 2 #int -// 1 => 4 #int -// ] -``` - -_Example: structs_ - -```go -type User struct { - ID int - Name string -} - -users := collection.New([]User{ - {ID: 1, Name: "Alice"}, - {ID: 2, Name: "Bob"}, - {ID: 3, Name: "Carol"}, -}) - -users.Where(func(u User) bool { - return u.ID >= 2 -}) - -collection.Dump(users.Items()) -// #[]main.User [ -// 0 => #main.User { -// +ID => 2 #int -// +Name => "Bob" #string -// } -// 1 => #main.User { -// +ID => 3 #int -// +Name => "Carol" #string -// } -// ] -``` - -### Window · allocates · fluent +### Window · allocates · chainable Window returns overlapping (or stepped) windows of the collection. Each window is a slice of length size; iteration advances by step (default 1 if step <= 0). @@ -3434,7 +3326,7 @@ collection.Dump(win3.Items()) ## Transformation -### Append · immutable · fluent +### Append · immutable · chainable Append returns a new collection with the given values appended. @@ -3489,7 +3381,7 @@ users.Append( // ] ``` -### Concat · mutable · fluent +### Concat · mutable · chainable Concat appends the values from the given slice onto the end of the collection, @@ -3508,7 +3400,7 @@ collection.Dump(concatenated) // ] ``` -### Each · immutable · fluent +### Each · readonly · chainable Each runs fn for every item in the collection and returns the same collection, so it can be used in chains for side effects (logging, debugging, etc.). @@ -3572,9 +3464,9 @@ collection.Dump(names) // ] ``` -### Map · immutable · fluent +### Map · mutable · chainable -Map applies a same-type transformation and returns a new collection. +Map applies a same-type transformation in place and returns the same collection. _Example: integers_ @@ -3641,7 +3533,7 @@ collection.Dump(updated.Items()) // ] ``` -### MapTo · immutable · fluent +### MapTo · immutable · chainable MapTo maps a Collection[T] to a Collection[R] using fn(T) R. @@ -3703,9 +3595,9 @@ collection.Dump(names.Items()) // ] ``` -### Merge · mutable · fluent +### Merge · immutable · chainable -Merge merges the given data into the current collection. +Merge merges the given data into a new collection. _Example: integers - merging slices_ @@ -3779,7 +3671,7 @@ collection.Dump(merged3.Items()) // ] ``` -### Multiply · mutable · fluent +### Multiply · immutable · chainable Multiply creates `n` copies of all items in the collection and returns a new collection. @@ -3849,7 +3741,7 @@ collection.Dump(none.Items()) // ] ``` -### Pipe · readonly · fluent +### Pipe · readonly · terminal Pipe passes the entire collection into the given function and returns the function's result. @@ -3858,7 +3750,7 @@ _Example: integers – computing a sum_ ```go c := collection.New([]int{1, 2, 3}) -sum := c.Pipe(func(col *collection.Collection[int]) any { +sum := collection.Pipe(c, func(col *collection.Collection[int]) int { total := 0 for _, v := range col.Items() { total += v @@ -3873,7 +3765,7 @@ _Example: strings – joining values_ ```go c2 := collection.New([]string{"a", "b", "c"}) -joined := c2.Pipe(func(col *collection.Collection[string]) any { +joined := collection.Pipe(c2, func(col *collection.Collection[string]) string { out := "" for _, v := range col.Items() { out += v @@ -3897,7 +3789,7 @@ users := collection.New([]User{ {ID: 2, Name: "Bob"}, }) -names := users.Pipe(func(col *collection.Collection[User]) any { +names := collection.Pipe(users, func(col *collection.Collection[User]) []string { result := make([]string, 0, len(col.Items())) for _, u := range col.Items() { result = append(result, u.Name) @@ -3912,81 +3804,16 @@ collection.Dump(names) // ] ``` -### Pluck · immutable · fluent - -Pluck is an alias for MapTo with a more semantic name when projecting fields. -It extracts a single field or computed value from every element and returns a -new typed collection. - -_Example: integers - extract parity label_ - -```go -nums := collection.New([]int{1, 2, 3, 4}) -parity := collection.Pluck(nums, func(n int) string { - if n%2 == 0 { - return "even" - } - return "odd" -}) -collection.Dump(parity.Items()) -// #[]string [ -// 0 => "odd" #string -// 1 => "even" #string -// 2 => "odd" #string -// 3 => "even" #string -// ] -``` +### Prepend · mutable · chainable -_Example: strings - length of each value_ - -```go -words := collection.New([]string{"go", "forj", "rocks"}) -lengths := collection.Pluck(words, func(s string) int { - return len(s) -}) -collection.Dump(lengths.Items()) -// #[]int [ -// 0 => 2 #int -// 1 => 4 #int -// 2 => 5 #int -// ] -``` - -_Example: structs - pluck a field_ - -```go -type User struct { - ID int - Name string -} - -users := collection.New([]User{ - {ID: 1, Name: "Alice"}, - {ID: 2, Name: "Bob"}, -}) - -names := collection.Pluck(users, func(u User) string { - return u.Name -}) - -collection.Dump(names.Items()) -// #[]string [ -// 0 => "Alice" #string -// 1 => "Bob" #string -// ] -``` - -### Prepend · mutable · fluent - -Prepend returns a new collection with the given values added -to the *beginning* of the collection. +Prepend adds the given values to the beginning of the collection. _Example: integers_ ```go c := collection.New([]int{3, 4}) -newC := c.Prepend(1, 2) -collection.Dump(newC.Items()) +c.Prepend(1, 2) +collection.Dump(c.Items()) // #[]int [ // 0 => 1 #int // 1 => 2 #int @@ -3999,8 +3826,8 @@ _Example: strings_ ```go letters := collection.New([]string{"c", "d"}) -out := letters.Prepend("a", "b") -collection.Dump(out.Items()) +letters.Prepend("a", "b") +collection.Dump(letters.Items()) // #[]string [ // 0 => "a" #string // 1 => "b" #string @@ -4021,8 +3848,8 @@ users := collection.New([]User{ {ID: 2, Name: "Bob"}, }) -out2 := users.Prepend(User{ID: 1, Name: "Alice"}) -collection.Dump(out2.Items()) +users.Prepend(User{ID: 1, Name: "Alice"}) +collection.Dump(users.Items()) // #[]main.User [ // 0 => #main.User { // +ID => 1 #int @@ -4039,75 +3866,27 @@ _Example: integers - Prepending into an empty collection_ ```go empty := collection.New([]int{}) -out3 := empty.Prepend(9, 8) -collection.Dump(out3.Items()) +empty.Prepend(9, 8) +collection.Dump(empty.Items()) // #[]int [ // 0 => 9 #int // 1 => 8 #int // ] ``` -_Example: integers - Prepending no values → returns a copy of original_ +_Example: integers - Prepending no values → no change_ ```go c2 := collection.New([]int{1, 2}) -out4 := c2.Prepend() -collection.Dump(out4.Items()) +c2.Prepend() +collection.Dump(c2.Items()) // #[]int [ // 0 => 1 #int // 1 => 2 #int // ] ``` -### Push · immutable · fluent - -Push returns a new collection with the given values appended. - -```go -nums := collection.New([]int{1, 2}).Push(3, 4) -nums.Dump() -// #[]int [ -// 0 => 1 #int -// 1 => 2 #int -// 2 => 3 #int -// 3 => 4 #int -// ] - -// Complex type (structs) -type User struct { - Name string - Age int -} - -users := collection.New([]User{ - {Name: "Alice", Age: 30}, - {Name: "Bob", Age: 25}, -}).Push( - User{Name: "Carol", Age: 40}, - User{Name: "Dave", Age: 20}, -) -users.Dump() -// #[]main.User [ -// 0 => #main.User { -// +Name => "Alice" #string -// +Age => 30 #int -// } -// 1 => #main.User { -// +Name => "Bob" #string -// +Age => 25 #int -// } -// 2 => #main.User { -// +Name => "Carol" #string -// +Age => 40 #int -// } -// 3 => #main.User { -// +Name => "Dave" #string -// +Age => 20 #int -// } -// ] -``` - -### Tap · immutable · fluent +### Tap · immutable · chainable Tap invokes fn with the collection pointer for side effects (logging, debugging, inspection) and returns the same collection to allow chaining. @@ -4167,7 +3946,7 @@ users2 := users.Tap(func(col *collection.Collection[User]) { collection.Dump(users2.Items()) // ensures users2 is used ``` -### Times · immutable · fluent +### Times · immutable · chainable Times creates a new collection by calling fn(i) for i = 1..count. This mirrors Laravel's Collection::times(), which is 1-indexed. @@ -4234,7 +4013,7 @@ collection.Dump(cTimes3.Items()) // ] ``` -### Transform · mutable · fluent +### Transform · mutable · terminal Transform applies fn to every item *in place*, mutating the collection. @@ -4289,7 +4068,7 @@ collection.Dump(c3.Items()) // ] ``` -### Zip · immutable · fluent +### Zip · immutable · chainable Zip combines two collections element-wise into a collection of tuples. The resulting length is the smaller of the two inputs. @@ -4349,7 +4128,7 @@ collection.Dump(out2.Items()) // ] ``` -### ZipWith · immutable · fluent +### ZipWith · immutable · chainable ZipWith combines two collections element-wise using combiner fn. The resulting length is the smaller of the two inputs. diff --git a/after.go b/after.go index c2c0bfb..7be389b 100644 --- a/after.go +++ b/after.go @@ -4,7 +4,10 @@ package collection // If no element matches, an empty collection is returned. // @group Ordering // @behavior immutable -// @fluent true +// @chainable true +// @terminal false +// +// NOTE: returns a view (shares backing array). Use Clone() to detach. // // Example: integers // @@ -25,10 +28,8 @@ func (c *Collection[T]) After(pred func(T) bool) *Collection[T] { // If no match found → empty collection if idx == -1 || idx+1 >= len(c.items) { - return &Collection[T]{items: []T{}} + return New(c.items[:0]) } - out := make([]T, len(c.items)-(idx+1)) - copy(out, c.items[idx+1:]) - return &Collection[T]{items: out} + return New(c.items[idx+1:]) } diff --git a/all.go b/all.go index ca1a398..720cbf2 100644 --- a/all.go +++ b/all.go @@ -4,7 +4,8 @@ package collection // If the collection is empty, All returns true (vacuously true). // @group Querying // @behavior readonly -// @fluent true +// @chainable false +// @terminal true // // Example: integers – all even // diff --git a/any.go b/any.go index ef93ab3..3e7e5ec 100644 --- a/any.go +++ b/any.go @@ -3,7 +3,8 @@ package collection // Any returns true if at least one item satisfies fn. // @group Querying // @behavior readonly -// @fluent true +// @chainable false +// @terminal true // Example: integers // // c := collection.New([]int{1, 2, 3, 4}) diff --git a/append.go b/append.go index 18dd7d2..8102269 100644 --- a/append.go +++ b/append.go @@ -3,7 +3,8 @@ package collection // Append returns a new collection with the given values appended. // @group Transformation // @behavior immutable -// @fluent true +// @chainable true +// @terminal false // Example: integers // // c := collection.New([]int{1, 2}) @@ -54,57 +55,5 @@ func (c *Collection[T]) Append(values ...T) *Collection[T] { out := make([]T, 0, len(c.items)+len(values)) out = append(out, c.items...) out = append(out, values...) - return &Collection[T]{items: out} -} - -// Push returns a new collection with the given values appended. -// @group Transformation -// @behavior immutable -// @fluent true -// -// Example: integers -// -// nums := collection.New([]int{1, 2}).Push(3, 4) -// nums.Dump() -// // #[]int [ -// // 0 => 1 #int -// // 1 => 2 #int -// // 2 => 3 #int -// // 3 => 4 #int -// // ] -// -// // Complex type (structs) -// type User struct { -// Name string -// Age int -// } -// -// users := collection.New([]User{ -// {Name: "Alice", Age: 30}, -// {Name: "Bob", Age: 25}, -// }).Push( -// User{Name: "Carol", Age: 40}, -// User{Name: "Dave", Age: 20}, -// ) -// users.Dump() -// // #[]main.User [ -// // 0 => #main.User { -// // +Name => "Alice" #string -// // +Age => 30 #int -// // } -// // 1 => #main.User { -// // +Name => "Bob" #string -// // +Age => 25 #int -// // } -// // 2 => #main.User { -// // +Name => "Carol" #string -// // +Age => 40 #int -// // } -// // 3 => #main.User { -// // +Name => "Dave" #string -// // +Age => 20 #int -// // } -// // ] -func (c *Collection[T]) Push(values ...T) *Collection[T] { - return c.Append(values...) + return New(out) } diff --git a/append_test.go b/append_test.go index 935832e..afbc9f2 100644 --- a/append_test.go +++ b/append_test.go @@ -5,98 +5,76 @@ import ( "testing" ) -func TestAppendAndPush(t *testing.T) { - ops := []struct { - name string - fn func(*Collection[int], ...int) *Collection[int] - }{ - {"Append", (*Collection[int]).Append}, - {"Push", (*Collection[int]).Push}, // alias - } - - for _, op := range ops { - t.Run(op.name+"_Basic", func(t *testing.T) { - c := New([]int{1, 2}) +func TestAppend(t *testing.T) { + t.Run("Basic", func(t *testing.T) { + c := New([]int{1, 2}) - out := op.fn(c, 3, 4) - expected := []int{1, 2, 3, 4} + out := c.Append(3, 4) + expected := []int{1, 2, 3, 4} - if !reflect.DeepEqual(out.items, expected) { - t.Fatalf("%s basic expected %v, got %v", op.name, expected, out.items) - } - }) + if !reflect.DeepEqual(out.items, expected) { + t.Fatalf("Append basic expected %v, got %v", expected, out.items) + } + }) - t.Run(op.name+"_EmptyCollection", func(t *testing.T) { - c := New([]int{}) + t.Run("EmptyCollection", func(t *testing.T) { + c := New([]int{}) - out := op.fn(c, 5, 6) - expected := []int{5, 6} + out := c.Append(5, 6) + expected := []int{5, 6} - if !reflect.DeepEqual(out.items, expected) { - t.Fatalf("%s empty expected %v, got %v", op.name, expected, out.items) - } - }) + if !reflect.DeepEqual(out.items, expected) { + t.Fatalf("Append empty expected %v, got %v", expected, out.items) + } + }) - t.Run(op.name+"_NoValues", func(t *testing.T) { - c := New([]int{10, 20, 30}) + t.Run("NoValues", func(t *testing.T) { + c := New([]int{10, 20, 30}) - out := op.fn(c) // no-op - expected := []int{10, 20, 30} + out := c.Append() // no-op + expected := []int{10, 20, 30} - if !reflect.DeepEqual(out.items, expected) { - t.Fatalf("%s no-values expected %v, got %v", op.name, expected, out.items) - } - }) + if !reflect.DeepEqual(out.items, expected) { + t.Fatalf("Append no-values expected %v, got %v", expected, out.items) + } + }) - t.Run(op.name+"_NoMutation", func(t *testing.T) { - orig := []int{1, 2, 3} - c := New(orig) + t.Run("NoMutation", func(t *testing.T) { + orig := []int{1, 2, 3} + c := New(orig) - _ = op.fn(c, 4, 5) + _ = c.Append(4, 5) - if !reflect.DeepEqual(c.items, orig) { - t.Fatalf("%s mutated original %v", op.name, c.items) - } - }) - } + if !reflect.DeepEqual(c.items, orig) { + t.Fatalf("Append mutated original %v", c.items) + } + }) } -func TestAppendAndPush_Structs(t *testing.T) { +func TestAppend_Structs(t *testing.T) { type User struct { ID int Name string } - ops := []struct { - name string - fn func(*Collection[User], ...User) *Collection[User] - }{ - {"Append", (*Collection[User]).Append}, - {"Push", (*Collection[User]).Push}, // alias + c := New([]User{ + {1, "Chris"}, + {2, "Van"}, + }) + + out := c.Append( + User{3, "Shawn"}, + User{4, "Matt"}, + ) + + expected := []User{ + {1, "Chris"}, + {2, "Van"}, + {3, "Shawn"}, + {4, "Matt"}, } - for _, op := range ops { - t.Run(op.name+"_Structs", func(t *testing.T) { - c := New([]User{ - {1, "Chris"}, - {2, "Van"}, - }) - - out := op.fn(c, - User{3, "Shawn"}, - User{4, "Matt"}, - ) - - expected := []User{ - {1, "Chris"}, - {2, "Van"}, - {3, "Shawn"}, - {4, "Matt"}, - } - - if !reflect.DeepEqual(out.items, expected) { - t.Fatalf("%s structs expected %v, got %v", op.name, expected, out.items) - } - }) + if !reflect.DeepEqual(out.items, expected) { + t.Fatalf("Append structs expected %v, got %v", expected, out.items) } } diff --git a/at.go b/at.go index 5f07d4a..b5064d3 100644 --- a/at.go +++ b/at.go @@ -4,7 +4,8 @@ package collection // whether the index was within bounds. // @group Querying // @behavior readonly -// @fluent true +// @chainable false +// @terminal true // // This method is safe and does not panic for out-of-range indices. // diff --git a/avg.go b/avg.go index b402d94..b02860d 100644 --- a/avg.go +++ b/avg.go @@ -4,7 +4,8 @@ package collection // If the collection is empty, Avg returns 0. // @group Aggregation // @behavior readonly -// @fluent false +// @chainable false +// @terminal true // // Example: integers // diff --git a/before.go b/before.go index 8cb0ac0..ebc865b 100644 --- a/before.go +++ b/before.go @@ -4,7 +4,10 @@ package collection // *before* the first element for which pred returns true. // @group Ordering // @behavior immutable -// @fluent true +// @chainable true +// @terminal false +// +// NOTE: returns a view (shares backing array). Use Clone() to detach. // // If no element matches the predicate, the entire collection is returned. // @@ -58,7 +61,5 @@ func (c *Collection[T]) Before(pred func(T) bool) *Collection[T] { } } - out := make([]T, idx) - copy(out, c.items[:idx]) - return &Collection[T]{items: out} + return New(c.items[:idx]) } diff --git a/chunk.go b/chunk.go index 7a9f352..48a2689 100644 --- a/chunk.go +++ b/chunk.go @@ -4,9 +4,12 @@ package collection // The final chunk may be smaller if len(items) is not divisible by size. // @group Slicing // @behavior readonly -// @fluent true +// @chainable false +// @terminal true // // If size <= 0, nil is returned. +// +// NOTE: chunks share the backing array with the source collection. // Example: integers // // c := collection.New([]int{1, 2, 3, 4, 5}).Chunk(2) diff --git a/clone.go b/clone.go index 91b547b..cdcaa60 100644 --- a/clone.go +++ b/clone.go @@ -1,7 +1,8 @@ package collection -// Clone returns a shallow copy of the collection. -// @fluent true +// Clone returns a copy of the collection. +// @chainable true +// @terminal false // // The returned collection has its own backing slice, so subsequent mutations // do not affect the original collection. @@ -10,14 +11,14 @@ package collection // the original collection. // // @group Construction -// @behavior allocates +// @behavior immutable // // Example: basic cloning // // c := collection.New([]int{1, 2, 3}) // clone := c.Clone() // -// clone.Push(4) +// clone.Append(4) // // collection.Dump(c.Items()) // // #[]int [ diff --git a/collection.go b/collection.go index 6dc3ef8..eb2fcda 100644 --- a/collection.go +++ b/collection.go @@ -21,18 +21,15 @@ type Pair[K comparable, V any] struct { Value V } -// New creates a new Collection from the provided slice. +// New creates a new Collection from the provided slice and borrows it. // @group Construction // @behavior immutable -// @fluent true +// @chainable true +// @terminal false // // The returned Collection is a lightweight, strongly-typed wrapper // around the slice, enabling fluent, chainable operations such as // filtering, mapping, reducing, sorting, and more. -// -// The underlying slice is stored as-is (no copy is made), allowing -// New to be both fast and allocation-friendly. Callers should clone -// the input beforehand if they need to prevent shared mutation. func New[T any](items []T) *Collection[T] { return &Collection[T]{items: items} } @@ -42,21 +39,25 @@ type NumericCollection[T Number] struct { *Collection[T] } -// NewNumeric wraps a slice of numeric types in a NumericCollection. -// A shallow copy is made so that further operations don't mutate the original slice. +// NewNumeric wraps a slice of numeric types in a NumericCollection and borrows it. // @group Construction // @behavior immutable -// @fluent true +// @chainable true +// @terminal false func NewNumeric[T Number](items []T) *NumericCollection[T] { return &NumericCollection[T]{ Collection: &Collection[T]{items: items}, } } -// Items returns the underlying slice of items. +// Items returns the backing slice of items. // @group Access // @behavior readonly -// @fluent true +// @chainable false +// @terminal true +// +// Items shares the backing array with the collection. Mutating the returned +// slice will mutate the collection. // // Example: integers // @@ -106,3 +107,27 @@ func NewNumeric[T Number](items []T) *NumericCollection[T] { func (c *Collection[T]) Items() []T { return c.items } + +// ItemsCopy returns a copy of the collection's items. +// @group Access +// @behavior readonly +// @chainable false +// @terminal true +// +// ItemsCopy allocates a new slice. +// +// Example: integers +// +// c := collection.New([]int{1, 2, 3}) +// items := c.ItemsCopy() +// collection.Dump(items) +// // #[]int [ +// // 0 => 1 #int +// // 1 => 2 #int +// // 2 => 3 #int +// // ] +func (c *Collection[T]) ItemsCopy() []T { + out := make([]T, len(c.items)) + copy(out, c.items) + return out +} diff --git a/collection_test.go b/collection_test.go index 232d46b..ffeef5a 100644 --- a/collection_test.go +++ b/collection_test.go @@ -22,8 +22,8 @@ func TestFluentChainWithStructs(t *testing.T) { Filter(func(u User) bool { return u.Age >= 35 }). Sort(func(a, b User) bool { return a.Age < b.Age }) - // Type change happens at the edge using MapTo/Pluck. - names := Pluck(filteredAndSorted, func(u User) string { + // Type change happens at the edge using MapTo. + names := MapTo(filteredAndSorted, func(u User) string { return u.Name }).Items() diff --git a/concat.go b/concat.go index ce9f8fd..9ca186b 100644 --- a/concat.go +++ b/concat.go @@ -3,7 +3,8 @@ package collection // Concat appends the values from the given slice onto the end of the collection, // @group Transformation // @behavior mutable -// @fluent true +// @chainable true +// @terminal false // // Example: strings // diff --git a/concat_test.go b/concat_test.go index 62936b1..3070754 100644 --- a/concat_test.go +++ b/concat_test.go @@ -116,3 +116,24 @@ func TestConcat_NoAllocationWhenCapacityAllows(t *testing.T) { t.Fatalf("expected %v, got %v", expected, c.items) } } + +func TestConcat_PreservesNilSliceWhenEmptyValues(t *testing.T) { + c := New([]int(nil)) + + c.Concat([]int{}) + + if c.Items() != nil { + t.Fatalf("expected nil slice to remain nil, got %v", c.Items()) + } +} + +func TestConcat_NilSliceWithValues(t *testing.T) { + c := New([]int(nil)) + + c.Concat([]int{1, 2}) + + expected := []int{1, 2} + if !reflect.DeepEqual(c.Items(), expected) { + t.Fatalf("expected %v, got %v", expected, c.Items()) + } +} diff --git a/constructor_test.go b/constructor_test.go new file mode 100644 index 0000000..0761105 --- /dev/null +++ b/constructor_test.go @@ -0,0 +1,67 @@ +package collection + +import "testing" + +func TestNew_BorrowsInputSlice(t *testing.T) { + items := []int{1, 2, 3} + c := New(items) + + items[0] = 9 + + if c.Items()[0] != 9 { + t.Fatalf("New should borrow input slice") + } +} + +func TestNew_PreservesNilSlice(t *testing.T) { + var items []int + c := New(items) + + if c.Items() != nil { + t.Fatalf("New should preserve nil slice") + } +} + +func TestNewNumeric_PreservesNilSlice(t *testing.T) { + var items []int + c := NewNumeric(items) + + if c.Items() != nil { + t.Fatalf("NewNumeric should preserve nil slice") + } +} + +func TestNewNumeric_BorrowsInputSlice(t *testing.T) { + items := []int{1, 2, 3} + c := NewNumeric(items) + + items[0] = 9 + + if c.Items()[0] != 9 { + t.Fatalf("NewNumeric should borrow input slice") + } +} + +func TestSelectionOps_ShareBackingSlice(t *testing.T) { + items := []int{1, 2, 3, 4} + c := New(items) + + view := c.Take(2) + items[0] = 9 + + if view.Items()[0] != 9 { + t.Fatalf("selection ops should return views") + } +} + +func TestItemsCopy_ReturnsCopy(t *testing.T) { + items := []int{1, 2, 3} + c := New(items) + + copyItems := c.ItemsCopy() + copyItems[0] = 9 + + if c.Items()[0] == 9 { + t.Fatalf("ItemsCopy should return a copy") + } +} diff --git a/contains.go b/contains.go index 40354ff..3340c78 100644 --- a/contains.go +++ b/contains.go @@ -1,48 +1,29 @@ package collection -// Contains returns true if any item satisfies the predicate. +// Contains returns true if the collection contains the given value. // @group Querying // @behavior readonly -// @fluent true +// @chainable false +// @terminal true +// +// Similar to: Any (differs by using equality on a value). +// // Example: integers // // c := collection.New([]int{1, 2, 3, 4, 5}) -// hasEven := c.Contains(func(v int) bool { -// return v%2 == 0 -// }) -// collection.Dump(hasEven) +// hasTwo := collection.Contains(c, 2) +// collection.Dump(hasTwo) // // true #bool // // Example: strings // // c2 := collection.New([]string{"apple", "banana", "cherry"}) -// hasBanana := c2.Contains(func(v string) bool { -// return v == "banana" -// }) +// hasBanana := collection.Contains(c2, "banana") // collection.Dump(hasBanana) // // true #bool -// -// Example: structs -// -// type User struct { -// ID int -// Name string -// } -// -// users := collection.New([]User{ -// {ID: 1, Name: "Alice"}, -// {ID: 2, Name: "Bob"}, -// {ID: 3, Name: "Carol"}, -// }) -// -// hasBob := users.Contains(func(u User) bool { -// return u.Name == "Bob" -// }) -// collection.Dump(hasBob) -// // true #bool -func (c *Collection[T]) Contains(pred func(T) bool) bool { +func Contains[T comparable](c *Collection[T], value T) bool { for _, v := range c.items { - if pred(v) { + if v == value { return true } } diff --git a/contains_test.go b/contains_test.go index f2f51db..9fcfdff 100644 --- a/contains_test.go +++ b/contains_test.go @@ -5,7 +5,7 @@ import "testing" func TestContains_ValueMatch(t *testing.T) { nums := New([]int{1, 2, 3}) - if !nums.Contains(func(v int) bool { return v == 2 }) { + if !Contains(nums, 2) { t.Fatalf("expected true, got false") } } @@ -13,7 +13,7 @@ func TestContains_ValueMatch(t *testing.T) { func TestContains_NoMatch(t *testing.T) { nums := New([]int{1, 2, 3}) - if nums.Contains(func(v int) bool { return v == 99 }) { + if Contains(nums, 99) { t.Fatalf("expected false, got true") } } @@ -21,7 +21,7 @@ func TestContains_NoMatch(t *testing.T) { func TestContains_EmptyCollection(t *testing.T) { nums := New([]int{}) - if nums.Contains(func(v int) bool { return true }) { + if Contains(nums, 1) { t.Fatalf("expected false for empty collection") } } @@ -38,11 +38,11 @@ func TestContains_Structs(t *testing.T) { {3, "Shawn"}, }) - if !users.Contains(func(u User) bool { return u.Name == "Van" }) { + if !Contains(users, User{ID: 2, Name: "Van"}) { t.Fatalf("expected true, got false") } - if users.Contains(func(u User) bool { return u.Name == "Zach" }) { + if Contains(users, User{ID: 99, Name: "Zach"}) { t.Fatalf("expected false, got true") } } diff --git a/count.go b/count.go index 164476b..4d08484 100644 --- a/count.go +++ b/count.go @@ -3,7 +3,8 @@ package collection // Count returns the total number of items in the collection. // @group Aggregation // @behavior readonly -// @fluent true +// @chainable false +// @terminal true // Example: integers // // count := collection.New([]int{1, 2, 3, 4}).Count() diff --git a/count_by.go b/count_by.go index 2d2c966..d282f3a 100644 --- a/count_by.go +++ b/count_by.go @@ -4,7 +4,8 @@ package collection // K must be comparable. // @group Aggregation // @behavior readonly -// @fluent false +// @chainable false +// @terminal true // // Example: integers // @@ -69,7 +70,8 @@ func CountBy[T any, K comparable](c *Collection[T], fn func(T) K) map[K]int { // is mapped to the number of times it appears. // @group Aggregation // @behavior readonly -// @fluent false +// @chainable false +// @terminal true // // T must be comparable. // diff --git a/difference.go b/difference.go index 7781c15..d59a3f9 100644 --- a/difference.go +++ b/difference.go @@ -5,7 +5,8 @@ package collection // duplicates are removed. // @group Set Operations // @behavior immutable -// @fluent true +// @chainable true +// @terminal false // // Example: integers // @@ -84,5 +85,5 @@ func Difference[T comparable](a, b *Collection[T]) *Collection[T] { out = append(out, v) } - return &Collection[T]{items: out} + return New(out) } diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..6e0f8fd --- /dev/null +++ b/doc.go @@ -0,0 +1,5 @@ +// Package collection provides fluent, explicit pipelines over slices. +// +// This library borrows slices by default. It does not perform defensive copies. +// Use Clone() or ItemsCopy() to explicitly copy. +package collection diff --git a/docs/backfill/main.go b/docs/backfill/main.go index 6286db4..09b1cb7 100644 --- a/docs/backfill/main.go +++ b/docs/backfill/main.go @@ -19,7 +19,7 @@ func main() { fmt.Println("Error:", err) os.Exit(1) } - fmt.Println("✔ @behavior and @fluent annotations backfilled") + fmt.Println("✔ @behavior, @chainable, and @terminal annotations backfilled") } func run() error { @@ -56,10 +56,12 @@ func run() error { continue } - // Fluent backfill (boolean) – always refresh to keep canonical. + // Chainable/terminal backfill (boolean) – always refresh to keep canonical. stripChainable(fn.Doc) // legacy cleanup stripFluent(fn.Doc) - insertFluent(fn.Doc, inferFluent(fn)) + stripTerminal(fn.Doc) + insertChainable(fn.Doc, inferChainable(fn)) + insertTerminal(fn.Doc, inferTerminal(fn)) changed = true // Respect explicit annotation @@ -116,21 +118,25 @@ func inferBehavior(fn *ast.FuncDecl) (string, bool) { return "", false } -func inferFluent(fn *ast.FuncDecl) string { - if knownFluentTrue[fn.Name.Name] { - return "true" +func inferChainable(fn *ast.FuncDecl) string { + if fn.Type.Results == nil || len(fn.Type.Results.List) != 1 { + return "false" } - if isCollectionMethod(fn) { + if returnsCollectionLike(fn) { return "true" } - if returnsCollectionLike(fn) && len(fn.Type.Results.List) == 1 { - return "true" - } return "false" } +func inferTerminal(fn *ast.FuncDecl) string { + if inferChainable(fn) == "true" { + return "false" + } + return "true" +} + var knownMutators = map[string]bool{ "Push": true, "Pop": true, @@ -145,33 +151,6 @@ var knownMutators = map[string]bool{ "Transform": true, } -var knownFluentTrue = map[string]bool{ - "Dd": true, -} - -func isCollectionMethod(fn *ast.FuncDecl) bool { - if fn.Recv == nil || len(fn.Recv.List) == 0 { - return false - } - - typ := fn.Recv.List[0].Type - star, ok := typ.(*ast.StarExpr) - if !ok { - return false - } - - switch t := star.X.(type) { - case *ast.IndexExpr: - if ident, ok := t.X.(*ast.Ident); ok { - return ident.Name == "Collection" - } - case *ast.Ident: - return t.Name == "NumericCollection" - } - - return false -} - func returnsCollection(fn *ast.FuncDecl) bool { if fn.Type.Results == nil || len(fn.Type.Results.List) != 1 { return false @@ -309,23 +288,33 @@ func hasBehavior(doc *ast.CommentGroup) bool { return false } -func hasFluent(doc *ast.CommentGroup) bool { - for _, c := range doc.List { - if strings.Contains(c.Text, "@fluent") { - return true +func insertBehavior(doc *ast.CommentGroup, behavior string) { + for i, c := range doc.List { + text := strings.TrimSpace(strings.TrimPrefix(c.Text, "//")) + if strings.HasPrefix(text, "Example:") || text == "" { + doc.List = append( + doc.List[:i], + append([]*ast.Comment{ + {Text: fmt.Sprintf("// @behavior %s", behavior)}, + }, doc.List[i:]...)..., + ) + return } } - return false + + doc.List = append(doc.List, &ast.Comment{ + Text: fmt.Sprintf("// @behavior %s", behavior), + }) } -func insertBehavior(doc *ast.CommentGroup, behavior string) { +func insertChainable(doc *ast.CommentGroup, val string) { for i, c := range doc.List { text := strings.TrimSpace(strings.TrimPrefix(c.Text, "//")) if strings.HasPrefix(text, "Example:") || text == "" { doc.List = append( doc.List[:i], append([]*ast.Comment{ - {Text: fmt.Sprintf("// @behavior %s", behavior)}, + {Text: fmt.Sprintf("// @chainable %s", val)}, }, doc.List[i:]...)..., ) return @@ -333,18 +322,18 @@ func insertBehavior(doc *ast.CommentGroup, behavior string) { } doc.List = append(doc.List, &ast.Comment{ - Text: fmt.Sprintf("// @behavior %s", behavior), + Text: fmt.Sprintf("// @chainable %s", val), }) } -func insertFluent(doc *ast.CommentGroup, val string) { +func insertTerminal(doc *ast.CommentGroup, val string) { for i, c := range doc.List { text := strings.TrimSpace(strings.TrimPrefix(c.Text, "//")) if strings.HasPrefix(text, "Example:") || text == "" { doc.List = append( doc.List[:i], append([]*ast.Comment{ - {Text: fmt.Sprintf("// @fluent %s", val)}, + {Text: fmt.Sprintf("// @terminal %s", val)}, }, doc.List[i:]...)..., ) return @@ -352,7 +341,7 @@ func insertFluent(doc *ast.CommentGroup, val string) { } doc.List = append(doc.List, &ast.Comment{ - Text: fmt.Sprintf("// @fluent %s", val), + Text: fmt.Sprintf("// @terminal %s", val), }) } @@ -378,6 +367,17 @@ func stripFluent(doc *ast.CommentGroup) { doc.List = out } +func stripTerminal(doc *ast.CommentGroup) { + out := doc.List[:0] + for _, c := range doc.List { + if strings.Contains(c.Text, "@terminal") { + continue + } + out = append(out, c) + } + doc.List = out +} + // // ------------------------------------------------------------ // File IO diff --git a/docs/bench/main.go b/docs/bench/main.go index 3341ac2..4aea988 100644 --- a/docs/bench/main.go +++ b/docs/bench/main.go @@ -35,10 +35,17 @@ func main() { flag.Parse() start := time.Now() - results := runBenches(parseOnly(*onlyFlag)) - table := renderTable(results) + only := parseOnly(*onlyFlag) + borrowResults := runBenches(only, benchBorrow) + copyResults := runBenches(only, benchCopy) + condensed := renderCondensedTables(borrowResults) + rawBorrow := renderTable(borrowResults) - if err := updateReadme(table); err != nil { + if err := updateReadme(condensed); err != nil { + fmt.Println("Error:", err) + os.Exit(1) + } + if err := updateBenchmarksFile(rawBorrow, renderTable(copyResults)); err != nil { fmt.Println("Error:", err) os.Exit(1) } @@ -53,7 +60,38 @@ func main() { // Benchmark runner // ---------------------------------------------------------------------------- -func runBenches(only map[string]struct{}) []benchResult { +type benchMode string + +const ( + benchBorrow benchMode = "borrow" + benchCopy benchMode = "copy" +) + +var ( + ctorInts func([]int) *collection.Collection[int] + ctorNumericInt func([]int) *collection.NumericCollection[int] + currentMode benchMode +) + +func setBenchMode(mode benchMode) { + currentMode = mode + switch mode { + case benchCopy: + ctorInts = func(items []int) *collection.Collection[int] { + return collection.New(items).Clone() + } + ctorNumericInt = func(items []int) *collection.NumericCollection[int] { + base := collection.NewNumeric(items) + return &collection.NumericCollection[int]{Collection: base.Collection.Clone()} + } + default: + ctorInts = collection.New[int] + ctorNumericInt = collection.NewNumeric[int] + } +} + +func runBenches(only map[string]struct{}, mode benchMode) []benchResult { + setBenchMode(mode) cases := []struct { name string col func(*testing.B) @@ -73,7 +111,7 @@ func runBenches(only map[string]struct{}) []benchResult { {"Chunk", benchChunkCollection, benchChunkLo}, {"Take", benchTakeCollection, benchTakeLo}, {"Contains", benchContainsCollection, benchContainsLo}, - {"Find", benchFindCollection, benchFindLo}, + {"FirstWhere", benchFindCollection, benchFindLo}, {"GroupBySlice", benchGroupByCollection, benchGroupByLo}, {"CountBy", benchCountByCollection, benchCountByLo}, {"CountByValue", benchCountByValueCollection, benchCountByValueLo}, @@ -129,12 +167,16 @@ func measure(name, impl string, fn func(*testing.B)) benchResult { fn(b) }) + nsPerOp := float64(res.NsPerOp()) + bytesPerOp := res.AllocedBytesPerOp() + allocsPerOp := res.AllocsPerOp() + return benchResult{ name: name, impl: impl, - nsPerOp: float64(res.NsPerOp()), - bytesPerOp: res.AllocedBytesPerOp(), - allocsPerOp: res.AllocsPerOp(), + nsPerOp: nsPerOp, + bytesPerOp: bytesPerOp, + allocsPerOp: allocsPerOp, } } @@ -190,32 +232,34 @@ func init() { func benchPipelineCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - copy(workA, benchInts) - - _ = collection.New(workA). + input := collectionInputForMutating(benchInts) + _ = ctorInts(input). Filter(func(v int) bool { return v%2 == 0 }). Map(func(v int) int { return v * v }). Take(benchPipelineLen). Reduce(0, func(acc, v int) int { return acc + v }) + } } func benchPipelineLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - copy(workB, benchInts) + input := benchInts - out := lo.Filter(workB, func(v int, _ int) bool { return v%2 == 0 }) + out := lo.Filter(input, func(v int, _ int) bool { return v%2 == 0 }) out2 := lo.Map(out, func(v int, _ int) int { return v * v }) out3 := lo.Subset(out2, 0, benchPipelineLen) _ = lo.Reduce(out3, func(acc int, v int, _ int) int { return acc + v }, 0) + } } func benchAllCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = collection.New(benchInts).All(func(v int) bool { return v < benchSize+1 }) + _ = ctorInts(benchInts).All(func(v int) bool { return v < benchSize+1 }) + } } @@ -223,13 +267,15 @@ func benchAllLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { _ = lo.EveryBy(benchInts, func(v int) bool { return v < benchSize+1 }) + } } func benchAnyCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = collection.New(benchInts).Any(func(v int) bool { return v == benchSize-1 }) + _ = ctorInts(benchInts).Any(func(v int) bool { return v == benchSize-1 }) + } } @@ -237,13 +283,15 @@ func benchAnyLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { _ = lo.SomeBy(benchInts, func(v int) bool { return v == benchSize-1 }) + } } func benchNoneCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = collection.New(benchInts).None(func(v int) bool { return v < 0 }) + _ = ctorInts(benchInts).None(func(v int) bool { return v < 0 }) + } } @@ -251,13 +299,15 @@ func benchNoneLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { _ = lo.NoneBy(benchInts, func(v int) bool { return v < 0 }) + } } func benchFirstCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _, _ = collection.New(benchInts).First() + _, _ = ctorInts(benchInts).First() + } } @@ -265,13 +315,15 @@ func benchFirstLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = lo.First(benchInts) + } } func benchLastCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _, _ = collection.New(benchInts).Last() + _, _ = ctorInts(benchInts).Last() + } } @@ -279,13 +331,15 @@ func benchLastLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = lo.Last(benchInts) + } } func benchIndexWhereCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _, _ = collection.New(benchInts).IndexWhere(func(v int) bool { return v == benchSize-1 }) + _, _ = ctorInts(benchInts).IndexWhere(func(v int) bool { return v == benchSize-1 }) + } } @@ -293,6 +347,7 @@ func benchIndexWhereLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { _, _, _ = lo.FindIndexOf(benchInts, func(v int) bool { return v == benchSize-1 }) + } } @@ -300,7 +355,8 @@ func benchEachCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { sum := 0 - collection.New(benchInts).Each(func(v int) { sum += v }) + ctorInts(benchInts).Each(func(v int) { sum += v }) + } } @@ -309,31 +365,33 @@ func benchEachLo(b *testing.B) { for i := 0; i < b.N; i++ { sum := 0 lo.ForEach(benchInts, func(v int, _ int) { sum += v }) + } } func benchMapCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - copy(workA, benchInts) + input := collectionInputForMutating(benchInts) + _ = ctorInts(input).Map(func(v int) int { return v * 3 }) - _ = collection.New(workA).Map(func(v int) int { return v * 3 }) } } func benchMapLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - copy(workB, benchInts) + input := benchInts + _ = lo.Map(input, func(v int, _ int) int { return v * 3 }) - _ = lo.Map(workB, func(v int, _ int) int { return v * 3 }) } } func benchReduceCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = collection.New(benchInts).Reduce(0, func(acc, v int) int { return acc + v }) + _ = ctorInts(benchInts).Reduce(0, func(acc, v int) int { return acc + v }) + } } @@ -341,31 +399,33 @@ func benchReduceLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { _ = lo.Reduce(benchInts, func(acc int, v int, _ int) int { return acc + v }, 0) + } } func benchFilterCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - copy(workA, benchInts) + input := collectionInputForMutating(benchInts) + _ = ctorInts(input).Filter(func(v int) bool { return v%3 == 0 }) - _ = collection.New(workA).Filter(func(v int) bool { return v%3 == 0 }) } } func benchFilterLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - copy(workB, benchInts) + input := benchInts + _ = lo.Filter(input, func(v int, _ int) bool { return v%3 == 0 }) - _ = lo.Filter(workB, func(v int, _ int) bool { return v%3 == 0 }) } } func benchChunkCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = collection.New(benchInts).Chunk(benchChunkSize) + _ = ctorInts(benchInts).Chunk(benchChunkSize) + } } @@ -373,13 +433,15 @@ func benchChunkLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { _ = lo.Chunk(benchInts, benchChunkSize) + } } func benchTakeCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = collection.New(benchInts).Take(benchTakeN) + _ = ctorInts(benchInts).Take(benchTakeN) + } } @@ -387,13 +449,15 @@ func benchTakeLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { _ = lo.Subset(benchInts, 0, uint(benchTakeN)) + } } func benchContainsCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = collection.New(benchInts).Contains(func(v int) bool { return v == benchSize-1 }) + _ = collection.Contains(ctorInts(benchInts), benchSize-1) + } } @@ -401,13 +465,15 @@ func benchContainsLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { _ = lo.ContainsBy(benchInts, func(v int) bool { return v == benchSize-1 }) + } } func benchFindCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _, _ = collection.New(benchInts).FirstWhere(func(v int) bool { return v == benchSize-1 }) + _, _ = ctorInts(benchInts).FirstWhere(func(v int) bool { return v == benchSize-1 }) + } } @@ -415,13 +481,15 @@ func benchFindLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = lo.Find(benchInts, func(v int) bool { return v == benchSize-1 }) + } } func benchGroupByCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = collection.GroupBySlice(collection.New(benchInts), func(v int) int { return v % benchGroupByMod }) + _ = collection.GroupBySlice(ctorInts(benchInts), func(v int) int { return v % benchGroupByMod }) + } } @@ -429,13 +497,15 @@ func benchGroupByLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { _ = lo.GroupBy(benchInts, func(v int) int { return v % benchGroupByMod }) + } } func benchCountByCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = collection.CountBy(collection.New(benchIntsDup), func(v int) int { return v }) + _ = collection.CountBy(ctorInts(benchIntsDup), func(v int) int { return v }) + } } @@ -443,13 +513,15 @@ func benchCountByLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { _ = lo.CountValuesBy(benchIntsDup, func(v int) int { return v }) + } } func benchCountByValueCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = collection.CountByValue(collection.New(benchIntsDup)) + _ = collection.CountByValue(ctorInts(benchIntsDup)) + } } @@ -457,13 +529,15 @@ func benchCountByValueLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { _ = lo.CountValues(benchIntsDup) + } } func benchSkipCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = collection.New(benchInts).Skip(benchSkipN) + _ = ctorInts(benchInts).Skip(benchSkipN) + } } @@ -471,13 +545,15 @@ func benchSkipLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { _ = lo.Drop(benchInts, benchSkipN) + } } func benchSkipLastCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = collection.New(benchInts).SkipLast(benchSkipN) + _ = ctorInts(benchInts).SkipLast(benchSkipN) + } } @@ -485,15 +561,16 @@ func benchSkipLastLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { _ = lo.DropRight(benchInts, benchSkipN) + } } func benchReverseCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - copy(workA, benchInts) + input := collectionInputForMutating(benchInts) + _ = ctorInts(input).Reverse() - _ = collection.New(workA).Reverse() } } @@ -501,17 +578,17 @@ func benchReverseLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { copy(workB, benchInts) - _ = lo.Reverse(workB) + } } func benchShuffleCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - copy(workA, benchInts) + input := collectionInputForMutating(benchInts) + _ = ctorInts(input).Shuffle() - _ = collection.New(workA).Shuffle() } } @@ -519,15 +596,16 @@ func benchShuffleLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { copy(workB, benchInts) - _ = lo.Shuffle(workB) + } } func benchZipCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = collection.Zip(collection.New(benchInts), collection.New(benchIntsDup)) + _ = collection.Zip(ctorInts(benchInts), ctorInts(benchIntsDup)) + } } @@ -535,15 +613,17 @@ func benchZipLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { _ = lo.Zip2(benchInts, benchIntsDup) + } } func benchZipWithCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = collection.ZipWith(collection.New(benchInts), collection.New(benchIntsDup), func(a, b int) int { + _ = collection.ZipWith(ctorInts(benchInts), ctorInts(benchIntsDup), func(a, b int) int { return a + b }) + } } @@ -553,13 +633,15 @@ func benchZipWithLo(b *testing.B) { _ = lo.ZipBy2(benchInts, benchIntsDup, func(a, b int) int { return a + b }) + } } func benchUniqueCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = collection.UniqueComparable(collection.New(benchIntsDup)) + _ = collection.UniqueComparable(ctorInts(benchIntsDup)) + } } @@ -567,13 +649,15 @@ func benchUniqueLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { _ = lo.Uniq(benchIntsDup) + } } func benchUniqueByCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = collection.UniqueBy(collection.New(benchIntsDup), func(v int) int { return v }) + _ = collection.UniqueBy(ctorInts(benchIntsDup), func(v int) int { return v }) + } } @@ -581,13 +665,15 @@ func benchUniqueByLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { _ = lo.UniqBy(benchIntsDup, func(v int) int { return v }) + } } func benchUnionCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = collection.Union(collection.New(unionLeft), collection.New(unionRight)) + _ = collection.Union(ctorInts(unionLeft), ctorInts(unionRight)) + } } @@ -595,13 +681,15 @@ func benchUnionLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { _ = lo.Union(unionLeft, unionRight) + } } func benchIntersectCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = collection.Intersect(collection.New(intersectLeft), collection.New(intersectRight)) + _ = collection.Intersect(ctorInts(intersectLeft), ctorInts(intersectRight)) + } } @@ -609,13 +697,15 @@ func benchIntersectLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { _ = lo.Intersect(intersectLeft, intersectRight) + } } func benchDifferenceCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = collection.Difference(collection.New(differenceLeft), collection.New(differenceRight)) + _ = collection.Difference(ctorInts(differenceLeft), ctorInts(differenceRight)) + } } @@ -623,13 +713,15 @@ func benchDifferenceLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = lo.Difference(differenceLeft, differenceRight) + } } func benchToMapCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = collection.ToMap(collection.New(benchInts), func(v int) int { return v }, func(v int) int { return v }) + _ = collection.ToMap(ctorInts(benchInts), func(v int) int { return v }, func(v int) int { return v }) + } } @@ -637,13 +729,15 @@ func benchToMapLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { _ = lo.SliceToMap(benchInts, func(v int) (int, int) { return v, v }) + } } func benchSumCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = collection.NewNumeric(benchInts).Sum() + _ = ctorNumericInt(benchInts).Sum() + } } @@ -651,13 +745,15 @@ func benchSumLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { _ = lo.Sum(benchInts) + } } func benchMinCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _, _ = collection.NewNumeric(benchInts).Min() + _, _ = ctorNumericInt(benchInts).Min() + } } @@ -665,13 +761,15 @@ func benchMinLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { _ = lo.Min(benchInts) + } } func benchMaxCollection(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _, _ = collection.NewNumeric(benchInts).Max() + _, _ = ctorNumericInt(benchInts).Max() + } } @@ -679,6 +777,7 @@ func benchMaxLo(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { _ = lo.Max(benchInts) + } } @@ -696,8 +795,7 @@ func renderTable(results []benchResult) string { } var buf bytes.Buffer - buf.WriteString("### Performance Benchmarks\n\n") - buf.WriteString("| Op | ns/op (vs lo) | × | bytes/op (vs lo) | × | allocs/op (vs lo) |\n") + buf.WriteString("| Op | ns/op (vs lo) | × (faster) | bytes/op (vs lo) | × (less memory) | allocs/op (vs lo) |\n") buf.WriteString("|---:|----------------|:--:|------------------|:--:|--------------------|\n") names := make([]string, 0, len(byName)) @@ -740,8 +838,115 @@ func renderTable(results []benchResult) string { return strings.TrimSpace(buf.String()) } +type benchGroup struct { + name string + ops []string +} + +func renderCondensedTables(results []benchResult) string { + groups := []benchGroup{ + { + name: "Read-only scalar ops (wrapper overhead only)", + ops: []string{ + "All", + "Any", + "None", + "First", + "Last", + "FirstWhere", + "IndexWhere", + "Contains", + "Reduce (sum)", + "Sum", + "Min", + "Max", + "Each", + }, + }, + { + name: "Transforming ops", + ops: []string{ + "Chunk", + "Take", + "Skip", + "SkipLast", + "Zip", + "ZipWith", + "Unique", + "UniqueBy", + "Union", + "Intersect", + "Difference", + "GroupBySlice", + "CountBy", + "CountByValue", + "ToMap", + }, + }, + { + name: "Pipelines", + ops: []string{ + "Pipeline F→M→T→R", + }, + }, + { + name: "Mutating ops", + ops: []string{ + "Map", + "Filter", + "Reverse", + "Shuffle", + }, + }, + } + + var buf bytes.Buffer + buf.WriteString("Full raw tables: see `BENCHMARKS.md`.\n\n") + byName := map[string]map[string]benchResult{} + for _, r := range results { + if _, ok := byName[r.name]; !ok { + byName[r.name] = map[string]benchResult{} + } + byName[r.name][r.impl] = r + } + + for _, group := range groups { + rows := make([]string, 0, len(group.ops)) + for _, name := range group.ops { + entry, ok := byName[name] + if !ok { + continue + } + col := entry["collection"] + loRes := entry["lo"] + + wrapperOnly := group.name == "Read-only scalar ops (wrapper overhead only)" + allowBold := group.name == "Pipelines" || group.name == "Transforming ops" || group.name == "Mutating ops" + speed := formatSpeed(loRes.nsPerOp, col.nsPerOp, allowBold, wrapperOnly) + mem := formatDeltaBytes(col.bytesPerOp, loRes.bytesPerOp) + allocs := formatDeltaAllocs(col.allocsPerOp, loRes.allocsPerOp) + + rows = append(rows, fmt.Sprintf("| **%s** | %s | %s | %s |", name, speed, mem, allocs)) + } + + if len(rows) == 0 { + continue + } + + buf.WriteString(fmt.Sprintf("#### %s\n\n", group.name)) + buf.WriteString("| Op | Speed vs lo | Memory | Allocs |\n") + buf.WriteString("|---:|:-----------:|:------:|:------:|\n") + buf.WriteString(strings.Join(rows, "\n")) + buf.WriteString("\n\n") + } + + return strings.TrimSpace(buf.String()) +} + func formatNs(ns float64) string { switch { + case ns < 1: + return "<1ns" case ns >= 1e6: return fmt.Sprintf("%.1fms", ns/1e6) case ns >= 1e3: @@ -775,14 +980,27 @@ func formatDurationNs(ns float64) string { } } +const ( + wrapperEpsilon = 0.10 // ±10% wrapper overhead tolerance + benchRatioNoiseNs = 50.0 + wrapperOnlyEpsilon = 0.15 +) + func formatRatio(lo, col float64) string { + if lo < benchRatioNoiseNs && col < benchRatioNoiseNs { + return "≈" + } if col == 0 { return "∞" } + ratio := lo / col - if ratio >= 0.95 && ratio <= 1.05 { + + // Treat small deltas as equivalent (wrapper overhead, measurement noise) + if ratio >= 1-wrapperEpsilon && ratio <= 1+wrapperEpsilon { return "≈" } + out := fmt.Sprintf("%.2fx", ratio) if ratio > 1 { return fmt.Sprintf("**%s**", out) @@ -790,6 +1008,29 @@ func formatRatio(lo, col float64) string { return out } +func formatSpeed(lo, col float64, allowBold bool, wrapperOnly bool) string { + if lo < benchRatioNoiseNs && col < benchRatioNoiseNs { + return "≈" + } + if col == 0 { + return "∞" + } + + ratio := lo / col + if wrapperOnly && ratio >= 1-wrapperOnlyEpsilon && ratio <= 1+wrapperOnlyEpsilon { + return "≈" + } + if ratio >= 1-wrapperEpsilon && ratio <= 1+wrapperEpsilon { + return "≈" + } + + out := fmt.Sprintf("%.2fx", ratio) + if ratio > 1 && allowBold { + return fmt.Sprintf("**%s**", out) + } + return out +} + func formatRatioBytes(lo, col int64) string { switch { case lo == 0 && col == 0: @@ -801,7 +1042,7 @@ func formatRatioBytes(lo, col int64) string { } ratio := float64(lo) / float64(col) - if ratio >= 0.95 && ratio <= 1.05 { + if ratio >= 0.90 && ratio <= 1.10 { return "≈" } @@ -826,11 +1067,41 @@ func formatInt(v int64) string { } } +func formatDeltaBytes(col, lo int64) string { + if col == lo { + return "≈" + } + diff := col - lo + if diff > 0 { + return fmt.Sprintf("+%s", formatBytes(diff)) + } + return fmt.Sprintf("-%s", formatBytes(-diff)) +} + +func formatDeltaAllocs(col, lo int64) string { + if col == lo { + return "≈" + } + diff := col - lo + if diff > 0 { + return fmt.Sprintf("+%d", diff) + } + return fmt.Sprintf("%d", diff) +} + +func collectionInputForMutating(src []int) []int { + if currentMode == benchBorrow { + copy(workA, src) + return workA + } + return src +} + // ---------------------------------------------------------------------------- // README injection // ---------------------------------------------------------------------------- -func updateReadme(table string) error { +func updateReadme(condensed string) error { root, err := findRoot() if err != nil { return err @@ -842,7 +1113,7 @@ func updateReadme(table string) error { return err } - out, err := replaceSection(string(data), table) + out, err := replaceSection(string(data), condensed) if err != nil { return err } @@ -850,22 +1121,52 @@ func updateReadme(table string) error { return os.WriteFile(readmePath, []byte(out), 0o644) } -func replaceSection(readme, content string) (string, error) { +func replaceSection(readme, condensed string) (string, error) { start := strings.Index(readme, benchStart) end := strings.Index(readme, benchEnd) if start == -1 || end == -1 || end < start { return "", fmt.Errorf("benchmark anchors not found or malformed") } + section := readme[start+len(benchStart) : end] + updated, err := replaceBenchTable(section, condensed) + if err != nil { + return "", err + } + var buf bytes.Buffer buf.WriteString(readme[:start+len(benchStart)]) - buf.WriteString("\n\n") - buf.WriteString(content) - buf.WriteString("\n") + buf.WriteString(updated) buf.WriteString(readme[end:]) return buf.String(), nil } +func replaceBenchTable(section, condensed string) (string, error) { + trimmed := strings.TrimSpace(condensed) + if trimmed == "" { + return "", fmt.Errorf("condensed benchmark content is empty") + } + return "\n\n" + trimmed + "\n", nil +} + +func updateBenchmarksFile(rawBorrowTable, rawCopyTable string) error { + root, err := findRoot() + if err != nil { + return err + } + + path := filepath.Join(root, "BENCHMARKS.md") + var buf bytes.Buffer + buf.WriteString("# Benchmarks\n\n") + buf.WriteString("Raw results for `collection.New` (borrowed) vs `lo`.\n\n") + buf.WriteString(rawBorrowTable) + buf.WriteString("\n\n") + buf.WriteString("Raw results for `collection.New().Clone()` (explicit copy) vs `lo`.\n\n") + buf.WriteString(rawCopyTable) + buf.WriteString("\n") + return os.WriteFile(path, buf.Bytes(), 0o644) +} + // ---------------------------------------------------------------------------- // Helpers // ---------------------------------------------------------------------------- diff --git a/docs/readme/main.go b/docs/readme/main.go index b4d4948..04857ad 100644 --- a/docs/readme/main.go +++ b/docs/readme/main.go @@ -80,7 +80,8 @@ type FuncDoc struct { Name string Group string Behavior string - Fluent string + Chainable string + Terminal string Description string Examples []Example } @@ -100,7 +101,8 @@ type Example struct { var ( groupHeader = regexp.MustCompile(`(?i)^\s*@group\s+(.+)$`) behaviorHeader = regexp.MustCompile(`(?i)^\s*@behavior\s+(.+)$`) - fluentHeader = regexp.MustCompile(`(?i)^\s*@fluent\s+(.+)$`) + chainableHeader = regexp.MustCompile(`(?i)^\s*@chainable\s+(.+)$`) + terminalHeader = regexp.MustCompile(`(?i)^\s*@terminal\s+(.+)$`) exampleHeader = regexp.MustCompile(`(?i)^\s*Example:\s*(.*)$`) ) @@ -146,7 +148,8 @@ func parseFuncs(root string) ([]*FuncDoc, error) { Name: fn.Name.Name, Group: extractGroup(fn.Doc), Behavior: extractBehavior(fn.Doc), - Fluent: extractFluent(fn.Doc), + Chainable: extractChainable(fn.Doc), + Terminal: extractTerminal(fn.Doc), Description: extractDescription(fn.Doc), Examples: extractExamples(fset, fn), } @@ -190,10 +193,20 @@ func extractBehavior(group *ast.CommentGroup) string { return "" } -func extractFluent(group *ast.CommentGroup) string { +func extractChainable(group *ast.CommentGroup) string { for _, c := range group.List { line := strings.TrimSpace(strings.TrimPrefix(c.Text, "//")) - if m := fluentHeader.FindStringSubmatch(line); m != nil { + if m := chainableHeader.FindStringSubmatch(line); m != nil { + return strings.ToLower(strings.TrimSpace(m[1])) + } + } + return "" +} + +func extractTerminal(group *ast.CommentGroup) string { + for _, c := range group.List { + line := strings.TrimSpace(strings.TrimPrefix(c.Text, "//")) + if m := terminalHeader.FindStringSubmatch(line); m != nil { return strings.ToLower(strings.TrimSpace(m[1])) } } @@ -209,7 +222,8 @@ func extractDescription(group *ast.CommentGroup) string { if exampleHeader.MatchString(line) || groupHeader.MatchString(line) || behaviorHeader.MatchString(line) || - fluentHeader.MatchString(line) { + chainableHeader.MatchString(line) || + terminalHeader.MatchString(line) { break } @@ -369,8 +383,11 @@ func renderAPI(funcs []*FuncDoc) string { if fn.Behavior != "" { header += " · " + fn.Behavior } - if fn.Fluent == "true" { - header += " · fluent" + if fn.Chainable == "true" { + header += " · chainable" + } + if fn.Terminal == "true" { + header += " · terminal" } buf.WriteString(fmt.Sprintf("### %s\n\n", anchor, header)) diff --git a/dump.go b/dump.go index 5915672..48a548d 100644 --- a/dump.go +++ b/dump.go @@ -13,7 +13,8 @@ var exitFunc = func(v interface{}) { godump.Dd(v) } // This is a no-op on the collection itself and never panics. // @group Debugging // @behavior readonly -// @fluent true +// @chainable true +// @terminal false // // Example: integers // @@ -43,7 +44,8 @@ func (c *Collection[T]) Dump() *Collection[T] { // Like Laravel's dd(), this is intended for debugging and // should not be used in production control flow. // @group Debugging -// @fluent true +// @chainable false +// @terminal true // // This method never returns. // @@ -65,7 +67,8 @@ func (c *Collection[T]) Dd() { // Useful for logging, snapshot testing, and non-interactive debugging. // @group Debugging // @behavior readonly -// @fluent true +// @chainable false +// @terminal true // // Example: integers // @@ -90,7 +93,8 @@ func setDumpWriter(w io.Writer) { // Dump is a convenience function that calls godump.Dump. // @group Debugging -// @fluent false +// @chainable false +// @terminal true // // Example: integers // diff --git a/each.go b/each.go index 3197480..fe5b7af 100644 --- a/each.go +++ b/each.go @@ -3,8 +3,9 @@ package collection // Each runs fn for every item in the collection and returns the same collection, // so it can be used in chains for side effects (logging, debugging, etc.). // @group Transformation -// @behavior immutable -// @fluent true +// @behavior readonly +// @chainable true +// @terminal false // // Example: integers // diff --git a/each_test.go b/each_test.go index e621230..6fa249c 100644 --- a/each_test.go +++ b/each_test.go @@ -23,15 +23,8 @@ func TestEach_ReturnsSameCollection(t *testing.T) { out := c.Each(func(v int) {}) - // They should hold identical items - if !reflect.DeepEqual(out.items, c.items) { - t.Fatalf("Each should return the same items: %v vs %v", out.items, c.items) - } - - // But ensure it's the same collection struct (value semantics) - // Structs compare equal by fields, so this is fine. - if out.Count() != c.Count() { - t.Fatalf("collection count mismatch") + if out != c { + t.Fatalf("Each should return the same collection instance") } } diff --git a/examples/clone/main.go b/examples/clone/main.go index d1e348b..e4321c6 100644 --- a/examples/clone/main.go +++ b/examples/clone/main.go @@ -6,8 +6,9 @@ package main import "github.com/goforj/collection" func main() { - // Clone returns a shallow copy of the collection. - // @fluent true + // Clone returns a copy of the collection. + // @chainable true + // @terminal false // // The returned collection has its own backing slice, so subsequent mutations // do not affect the original collection. @@ -19,7 +20,7 @@ func main() { c := collection.New([]int{1, 2, 3}) clone := c.Clone() - clone.Push(4) + clone.Append(4) collection.Dump(c.Items()) // #[]int [ diff --git a/examples/contains/main.go b/examples/contains/main.go index 311b3cd..3c40a74 100644 --- a/examples/contains/main.go +++ b/examples/contains/main.go @@ -6,39 +6,17 @@ package main import "github.com/goforj/collection" func main() { - // Contains returns true if any item satisfies the predicate. + // Contains returns true if the collection contains the given value. // Example: integers c := collection.New([]int{1, 2, 3, 4, 5}) - hasEven := c.Contains(func(v int) bool { - return v%2 == 0 - }) - collection.Dump(hasEven) + hasTwo := collection.Contains(c, 2) + collection.Dump(hasTwo) // true #bool // Example: strings c2 := collection.New([]string{"apple", "banana", "cherry"}) - hasBanana := c2.Contains(func(v string) bool { - return v == "banana" - }) + hasBanana := collection.Contains(c2, "banana") collection.Dump(hasBanana) // true #bool - - // Example: structs - type User struct { - ID int - Name string - } - - users := collection.New([]User{ - {ID: 1, Name: "Alice"}, - {ID: 2, Name: "Bob"}, - {ID: 3, Name: "Carol"}, - }) - - hasBob := users.Contains(func(u User) bool { - return u.Name == "Bob" - }) - collection.Dump(hasBob) - // true #bool } diff --git a/examples/findwhere/main.go b/examples/findwhere/main.go deleted file mode 100644 index 907dc91..0000000 --- a/examples/findwhere/main.go +++ /dev/null @@ -1,61 +0,0 @@ -//go:build ignore -// +build ignore - -package main - -import "github.com/goforj/collection" - -func main() { - // FindWhere returns the first item in the collection for which the provided - // predicate function returns true. This is an alias for FirstWhere(fn) and - // exists for ergonomic parity with functional languages (JavaScript, Rust, - // C#, Python) where developers expect a “find” helper. - - // Example: integers - nums := collection.New([]int{1, 2, 3, 4, 5}) - - v1, ok1 := nums.FindWhere(func(n int) bool { - return n == 3 - }) - collection.Dump(v1, ok1) - // 3 #int - // true #bool - - // Example: no match - v2, ok2 := nums.FindWhere(func(n int) bool { - return n > 10 - }) - collection.Dump(v2, ok2) - // 0 #int - // false #bool - - // Example: structs - type User struct { - ID int - Name string - } - - users := collection.New([]User{ - {ID: 1, Name: "Alice"}, - {ID: 2, Name: "Bob"}, - {ID: 3, Name: "Charlie"}, - }) - - u, ok3 := users.FindWhere(func(u User) bool { - return u.ID == 2 - }) - collection.Dump(u, ok3) - // #collection.User { - // +ID => 2 #int - // +Name => "Bob" #string - // } - // true #bool - - // Example: integers - empty collection - empty := collection.New([]int{}) - - v4, ok4 := empty.FindWhere(func(n int) bool { return n == 1 }) - collection.Dump(v4, ok4) - // 0 #int - // false #bool -} diff --git a/examples/items/main.go b/examples/items/main.go index f86803a..7ef77b4 100644 --- a/examples/items/main.go +++ b/examples/items/main.go @@ -6,7 +6,7 @@ package main import "github.com/goforj/collection" func main() { - // Items returns the underlying slice of items. + // Items returns the backing slice of items. // Example: integers c := collection.New([]int{1, 2, 3}) diff --git a/examples/itemscopy/main.go b/examples/itemscopy/main.go new file mode 100644 index 0000000..69bb103 --- /dev/null +++ b/examples/itemscopy/main.go @@ -0,0 +1,20 @@ +//go:build ignore +// +build ignore + +package main + +import "github.com/goforj/collection" + +func main() { + // ItemsCopy returns a copy of the collection's items. + + // Example: integers + c := collection.New([]int{1, 2, 3}) + items := c.ItemsCopy() + collection.Dump(items) + // #[]int [ + // 0 => 1 #int + // 1 => 2 #int + // 2 => 3 #int + // ] +} diff --git a/examples/map/main.go b/examples/map/main.go index ee94baa..8bd8986 100644 --- a/examples/map/main.go +++ b/examples/map/main.go @@ -9,7 +9,7 @@ import ( ) func main() { - // Map applies a same-type transformation and returns a new collection. + // Map applies a same-type transformation in place and returns the same collection. // Example: integers c := collection.New([]int{1, 2, 3}) diff --git a/examples/merge/main.go b/examples/merge/main.go index 9fdae34..2909dd5 100644 --- a/examples/merge/main.go +++ b/examples/merge/main.go @@ -6,7 +6,7 @@ package main import "github.com/goforj/collection" func main() { - // Merge merges the given data into the current collection. + // Merge merges the given data into a new collection. // Example: integers - merging slices ints := collection.New([]int{1, 2}) diff --git a/examples/pipe/main.go b/examples/pipe/main.go index 99e7af5..8782f09 100644 --- a/examples/pipe/main.go +++ b/examples/pipe/main.go @@ -11,7 +11,7 @@ func main() { // Example: integers – computing a sum c := collection.New([]int{1, 2, 3}) - sum := c.Pipe(func(col *collection.Collection[int]) any { + sum := collection.Pipe(c, func(col *collection.Collection[int]) int { total := 0 for _, v := range col.Items() { total += v @@ -23,7 +23,7 @@ func main() { // Example: strings – joining values c2 := collection.New([]string{"a", "b", "c"}) - joined := c2.Pipe(func(col *collection.Collection[string]) any { + joined := collection.Pipe(c2, func(col *collection.Collection[string]) string { out := "" for _, v := range col.Items() { out += v @@ -44,7 +44,7 @@ func main() { {ID: 2, Name: "Bob"}, }) - names := users.Pipe(func(col *collection.Collection[User]) any { + names := collection.Pipe(users, func(col *collection.Collection[User]) []string { result := make([]string, 0, len(col.Items())) for _, u := range col.Items() { result = append(result, u.Name) diff --git a/examples/pluck/main.go b/examples/pluck/main.go deleted file mode 100644 index 2d18368..0000000 --- a/examples/pluck/main.go +++ /dev/null @@ -1,61 +0,0 @@ -//go:build ignore -// +build ignore - -package main - -import "github.com/goforj/collection" - -func main() { - // Pluck is an alias for MapTo with a more semantic name when projecting fields. - // It extracts a single field or computed value from every element and returns a - // new typed collection. - - // Example: integers - extract parity label - nums := collection.New([]int{1, 2, 3, 4}) - parity := collection.Pluck(nums, func(n int) string { - if n%2 == 0 { - return "even" - } - return "odd" - }) - collection.Dump(parity.Items()) - // #[]string [ - // 0 => "odd" #string - // 1 => "even" #string - // 2 => "odd" #string - // 3 => "even" #string - // ] - - // Example: strings - length of each value - words := collection.New([]string{"go", "forj", "rocks"}) - lengths := collection.Pluck(words, func(s string) int { - return len(s) - }) - collection.Dump(lengths.Items()) - // #[]int [ - // 0 => 2 #int - // 1 => 4 #int - // 2 => 5 #int - // ] - - // Example: structs - pluck a field - type User struct { - ID int - Name string - } - - users := collection.New([]User{ - {ID: 1, Name: "Alice"}, - {ID: 2, Name: "Bob"}, - }) - - names := collection.Pluck(users, func(u User) string { - return u.Name - }) - - collection.Dump(names.Items()) - // #[]string [ - // 0 => "Alice" #string - // 1 => "Bob" #string - // ] -} diff --git a/examples/pop/main.go b/examples/pop/main.go index c7a2b9b..a3b2134 100644 --- a/examples/pop/main.go +++ b/examples/pop/main.go @@ -6,14 +6,14 @@ package main import "github.com/goforj/collection" func main() { - // Pop returns the last item and a new collection with that item removed. - // The original collection remains unchanged. + // Pop removes and returns the last item in the collection. // Example: integers c := collection.New([]int{1, 2, 3}) - item, rest := c.Pop() - collection.Dump(item, rest.Items()) + item, ok := c.Pop() + collection.Dump(item, ok, c.Items()) // 3 #int + // true #bool // #[]int [ // 0 => 1 #int // 1 => 2 #int @@ -21,9 +21,10 @@ func main() { // Example: strings c2 := collection.New([]string{"a", "b", "c"}) - item2, rest2 := c2.Pop() - collection.Dump(item2, rest2.Items()) + item2, ok2 := c2.Pop() + collection.Dump(item2, ok2, c2.Items()) // "c" #string + // true #bool // #[]string [ // 0 => "a" #string // 1 => "b" #string @@ -40,12 +41,13 @@ func main() { {ID: 2, Name: "Bob"}, }) - item3, rest3 := users.Pop() - collection.Dump(item3, rest3.Items()) + item3, ok3 := users.Pop() + collection.Dump(item3, ok3, users.Items()) // #main.User { // +ID => 2 #int // +Name => "Bob" #string // } + // true #bool // #[]main.User [ // 0 => #main.User { // +ID => 1 #int @@ -55,9 +57,10 @@ func main() { // Example: empty collection empty := collection.New([]int{}) - item4, rest4 := empty.Pop() - collection.Dump(item4, rest4.Items()) + item4, ok4 := empty.Pop() + collection.Dump(item4, ok4, empty.Items()) // 0 #int + // false #bool // #[]int [ // ] } diff --git a/examples/popn/main.go b/examples/popn/main.go index 12b2eaa..a0fe99f 100644 --- a/examples/popn/main.go +++ b/examples/popn/main.go @@ -6,16 +6,15 @@ package main import "github.com/goforj/collection" func main() { - // PopN removes and returns the last n items as a new collection, - // and returns a second collection containing the remaining items. + // PopN removes and returns the last n items in original order. // Example: integers – pop 2 c := collection.New([]int{1, 2, 3, 4}) - popped, rest := c.PopN(2) - collection.Dump(popped.Items(), rest.Items()) + popped := c.PopN(2) + collection.Dump(popped, c.Items()) // #[]int [ - // 0 => 4 #int - // 1 => 3 #int + // 0 => 3 #int + // 1 => 4 #int // ] // #[]int [ // 0 => 1 #int @@ -24,8 +23,8 @@ func main() { // Example: strings – pop 1 c2 := collection.New([]string{"a", "b", "c"}) - popped2, rest2 := c2.PopN(1) - collection.Dump(popped2.Items(), rest2.Items()) + popped2 := c2.PopN(1) + collection.Dump(popped2, c2.Items()) // #[]string [ // 0 => "c" #string // ] @@ -46,17 +45,17 @@ func main() { {ID: 3, Name: "Carol"}, }) - popped3, rest3 := users.PopN(2) - collection.Dump(popped3.Items(), rest3.Items()) + popped3 := users.PopN(2) + collection.Dump(popped3, users.Items()) // #[]main.User [ // 0 => #main.User { - // +ID => 3 #int - // +Name => "Carol" #string - // } - // 1 => #main.User { // +ID => 2 #int // +Name => "Bob" #string // } + // 1 => #main.User { + // +ID => 3 #int + // +Name => "Carol" #string + // } // ] // #[]main.User [ // 0 => #main.User { @@ -65,12 +64,11 @@ func main() { // } // ] - // Example: integers - n <= 0 → returns empty popped + original collection + // Example: integers - n <= 0 → returns nil, no change c3 := collection.New([]int{1, 2, 3}) - popped4, rest4 := c3.PopN(0) - collection.Dump(popped4.Items(), rest4.Items()) - // #[]int [ - // ] + popped4 := c3.PopN(0) + collection.Dump(popped4, c3.Items()) + // // #[]int [ // 0 => 1 #int // 1 => 2 #int @@ -79,11 +77,11 @@ func main() { // Example: strings - n exceeds length → all items popped, rest empty c4 := collection.New([]string{"x", "y"}) - popped5, rest5 := c4.PopN(10) - collection.Dump(popped5.Items(), rest5.Items()) + popped5 := c4.PopN(10) + collection.Dump(popped5, c4.Items()) // #[]string [ - // 0 => "y" #string - // 1 => "x" #string + // 0 => "x" #string + // 1 => "y" #string // ] // #[]string [ // ] diff --git a/examples/prepend/main.go b/examples/prepend/main.go index 9ed4620..d1926b6 100644 --- a/examples/prepend/main.go +++ b/examples/prepend/main.go @@ -6,13 +6,12 @@ package main import "github.com/goforj/collection" func main() { - // Prepend returns a new collection with the given values added - // to the *beginning* of the collection. + // Prepend adds the given values to the beginning of the collection. // Example: integers c := collection.New([]int{3, 4}) - newC := c.Prepend(1, 2) - collection.Dump(newC.Items()) + c.Prepend(1, 2) + collection.Dump(c.Items()) // #[]int [ // 0 => 1 #int // 1 => 2 #int @@ -22,8 +21,8 @@ func main() { // Example: strings letters := collection.New([]string{"c", "d"}) - out := letters.Prepend("a", "b") - collection.Dump(out.Items()) + letters.Prepend("a", "b") + collection.Dump(letters.Items()) // #[]string [ // 0 => "a" #string // 1 => "b" #string @@ -41,8 +40,8 @@ func main() { {ID: 2, Name: "Bob"}, }) - out2 := users.Prepend(User{ID: 1, Name: "Alice"}) - collection.Dump(out2.Items()) + users.Prepend(User{ID: 1, Name: "Alice"}) + collection.Dump(users.Items()) // #[]main.User [ // 0 => #main.User { // +ID => 1 #int @@ -56,17 +55,17 @@ func main() { // Example: integers - Prepending into an empty collection empty := collection.New([]int{}) - out3 := empty.Prepend(9, 8) - collection.Dump(out3.Items()) + empty.Prepend(9, 8) + collection.Dump(empty.Items()) // #[]int [ // 0 => 9 #int // 1 => 8 #int // ] - // Example: integers - Prepending no values → returns a copy of original + // Example: integers - Prepending no values → no change c2 := collection.New([]int{1, 2}) - out4 := c2.Prepend() - collection.Dump(out4.Items()) + c2.Prepend() + collection.Dump(c2.Items()) // #[]int [ // 0 => 1 #int // 1 => 2 #int diff --git a/examples/push/main.go b/examples/push/main.go deleted file mode 100644 index b92656c..0000000 --- a/examples/push/main.go +++ /dev/null @@ -1,53 +0,0 @@ -//go:build ignore -// +build ignore - -package main - -import "github.com/goforj/collection" - -func main() { - // Push returns a new collection with the given values appended. - - // Example: integers - nums := collection.New([]int{1, 2}).Push(3, 4) - nums.Dump() - // #[]int [ - // 0 => 1 #int - // 1 => 2 #int - // 2 => 3 #int - // 3 => 4 #int - // ] - - // Complex type (structs) - type User struct { - Name string - Age int - } - - users := collection.New([]User{ - {Name: "Alice", Age: 30}, - {Name: "Bob", Age: 25}, - }).Push( - User{Name: "Carol", Age: 40}, - User{Name: "Dave", Age: 20}, - ) - users.Dump() - // #[]main.User [ - // 0 => #main.User { - // +Name => "Alice" #string - // +Age => 30 #int - // } - // 1 => #main.User { - // +Name => "Bob" #string - // +Age => 25 #int - // } - // 2 => #main.User { - // +Name => "Carol" #string - // +Age => 40 #int - // } - // 3 => #main.User { - // +Name => "Dave" #string - // +Age => 20 #int - // } - // ] -} diff --git a/examples/shuffle/main.go b/examples/shuffle/main.go index 846983a..7735c4a 100644 --- a/examples/shuffle/main.go +++ b/examples/shuffle/main.go @@ -6,8 +6,7 @@ package main import "github.com/goforj/collection" func main() { - // Shuffle randomly shuffles the items in the collection in place - // and returns the same collection for chaining. + // Shuffle shuffles the collection in place and returns the same collection. // Example: integers c := collection.New([]int{1, 2, 3, 4, 5}) @@ -15,12 +14,12 @@ func main() { collection.Dump(c.Items()) // Example: strings – chaining - out := collection.New([]string{"a", "b", "c"}). + out2 := collection.New([]string{"a", "b", "c"}). Shuffle(). Append("d"). Items() - collection.Dump(out) + collection.Dump(out2) // Example: structs type User struct { diff --git a/examples/take/main.go b/examples/take/main.go index ab2a928..f9ed168 100644 --- a/examples/take/main.go +++ b/examples/take/main.go @@ -8,12 +8,15 @@ import "github.com/goforj/collection" func main() { // Take returns a new collection containing the first `n` items when n > 0, // or the last `|n|` items when n < 0. - // @fluent true + // @chainable true + // @terminal false // // If n exceeds the collection length, the entire collection is returned. // If n == 0, an empty collection is returned. // // Mirrors Laravel's take() semantics. + // + // NOTE: returns a view (shares backing array). Use Clone() to detach. // Example: integers - take first 3 c1 := collection.New([]int{0, 1, 2, 3, 4, 5}) diff --git a/examples/takelast/main.go b/examples/takelast/main.go index c52d6ea..ee4b812 100644 --- a/examples/takelast/main.go +++ b/examples/takelast/main.go @@ -10,10 +10,13 @@ func main() { // If n is less than or equal to zero, TakeLast returns an empty collection. // If n is greater than or equal to the collection length, TakeLast returns // the full collection. - // @fluent true + // @chainable true + // @terminal false // // This operation performs no element allocations; it re-slices the // underlying slice. + // + // NOTE: returns a view (shares backing array). Use Clone() to detach. // Example: integers c := collection.New([]int{1, 2, 3, 4, 5}) diff --git a/examples/takeuntil/main.go b/examples/takeuntil/main.go index f017c7c..27edd10 100644 --- a/examples/takeuntil/main.go +++ b/examples/takeuntil/main.go @@ -8,7 +8,8 @@ import "github.com/goforj/collection" func main() { // TakeUntil returns items until the first element equals `value`. // The matching item is NOT included. - // @fluent true + // @chainable true + // @terminal false // // Uses == comparison, so T must be comparable. diff --git a/examples/where/main.go b/examples/where/main.go deleted file mode 100644 index 0f82e91..0000000 --- a/examples/where/main.go +++ /dev/null @@ -1,51 +0,0 @@ -//go:build ignore -// +build ignore - -package main - -import "github.com/goforj/collection" - -func main() { - // Where keeps only the elements for which fn returns true. - // This is an alias for Filter(fn) for SQL-style ergonomics. - // This method mutates the collection in place and returns the same instance. - - // Example: integers - nums := collection.New([]int{1, 2, 3, 4}) - nums.Where(func(v int) bool { - return v%2 == 0 - }) - collection.Dump(nums.Items()) - // #[]int [ - // 0 => 2 #int - // 1 => 4 #int - // ] - - // Example: structs - type User struct { - ID int - Name string - } - - users := collection.New([]User{ - {ID: 1, Name: "Alice"}, - {ID: 2, Name: "Bob"}, - {ID: 3, Name: "Carol"}, - }) - - users.Where(func(u User) bool { - return u.ID >= 2 - }) - - collection.Dump(users.Items()) - // #[]main.User [ - // 0 => #main.User { - // +ID => 2 #int - // +Name => "Bob" #string - // } - // 1 => #main.User { - // +ID => 3 #int - // +Name => "Carol" #string - // } - // ] -} diff --git a/filter.go b/filter.go index 11157b4..13caacc 100644 --- a/filter.go +++ b/filter.go @@ -4,7 +4,8 @@ package collection // This method mutates the collection in place and returns the same instance. // @group Slicing // @behavior mutable -// @fluent true +// @chainable true +// @terminal false // Example: integers // // c := collection.New([]int{1, 2, 3, 4}) diff --git a/filter_test.go b/filter_test.go index ea9901f..0e04b54 100644 --- a/filter_test.go +++ b/filter_test.go @@ -90,3 +90,29 @@ func TestFilter_Chaining(t *testing.T) { t.Fatalf("expected %v, got %v", expected, result.items) } } + +func TestFilter_PreservesNilSlice(t *testing.T) { + c := New([]int(nil)) + + c.Filter(func(v int) bool { return v%2 == 0 }) + + if c.Items() != nil { + t.Fatalf("expected nil slice to remain nil, got %v", c.Items()) + } +} + +func TestFilter_WritesThroughSourceSlice(t *testing.T) { + items := []int{1, 2, 3, 4} + c := New(items) + + c.Filter(func(v int) bool { return v%2 == 0 }) + + want := []int{2, 4} + if !reflect.DeepEqual(items[:len(want)], want) { + t.Fatalf("expected source prefix %v, got %v", want, items[:len(want)]) + } + + if len(c.Items()) != len(want) { + t.Fatalf("expected filtered length %d, got %d", len(want), len(c.Items())) + } +} diff --git a/find_where.go b/find_where.go deleted file mode 100644 index 7d4bcca..0000000 --- a/find_where.go +++ /dev/null @@ -1,64 +0,0 @@ -package collection - -// FindWhere returns the first item in the collection for which the provided -// predicate function returns true. This is an alias for FirstWhere(fn) and -// exists for ergonomic parity with functional languages (JavaScript, Rust, -// C#, Python) where developers expect a “find” helper. -// @group Querying -// @behavior readonly -// @fluent true -// -// Example: integers -// -// nums := collection.New([]int{1, 2, 3, 4, 5}) -// -// v1, ok1 := nums.FindWhere(func(n int) bool { -// return n == 3 -// }) -// collection.Dump(v1, ok1) -// // 3 #int -// // true #bool -// -// Example: no match -// -// v2, ok2 := nums.FindWhere(func(n int) bool { -// return n > 10 -// }) -// collection.Dump(v2, ok2) -// // 0 #int -// // false #bool -// -// Example: structs -// -// type User struct { -// ID int -// Name string -// } -// -// users := collection.New([]User{ -// {ID: 1, Name: "Alice"}, -// {ID: 2, Name: "Bob"}, -// {ID: 3, Name: "Charlie"}, -// }) -// -// u, ok3 := users.FindWhere(func(u User) bool { -// return u.ID == 2 -// }) -// collection.Dump(u, ok3) -// // #collection.User { -// // +ID => 2 #int -// // +Name => "Bob" #string -// // } -// // true #bool -// -// Example: integers - empty collection -// -// empty := collection.New([]int{}) -// -// v4, ok4 := empty.FindWhere(func(n int) bool { return n == 1 }) -// collection.Dump(v4, ok4) -// // 0 #int -// // false #bool -func (c *Collection[T]) FindWhere(fn func(T) bool) (T, bool) { - return c.FirstWhere(fn) -} diff --git a/find_where_test.go b/find_where_test.go deleted file mode 100644 index 216ee1b..0000000 --- a/find_where_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package collection - -import ( - "reflect" - "testing" -) - -func TestFindWhere_FindsMatchingValue(t *testing.T) { - c := New([]int{1, 2, 3, 4, 5}) - - v, ok := c.FindWhere(func(n int) bool { return n%2 == 0 }) - - if !ok { - t.Fatalf("FindWhere should return ok=true when match exists") - } - - if v != 2 { - t.Fatalf("FindWhere returned wrong value. expected 2, got %v", v) - } -} - -func TestFindWhere_NoMatchReturnsZeroValue(t *testing.T) { - c := New([]int{1, 3, 5}) - - v, ok := c.FindWhere(func(n int) bool { return n%2 == 0 }) - - if ok { - t.Fatalf("FindWhere should return ok=false when no match exists") - } - - if v != 0 { - t.Fatalf("FindWhere should return zero-value when no match exists; got %v", v) - } -} - -func TestFindWhere_AliasToFirst(t *testing.T) { - c := New([]int{1, 2, 3, 4, 5}) - - v1, ok1 := c.FirstWhere(func(n int) bool { return n > 3 }) - v2, ok2 := c.FindWhere(func(n int) bool { return n > 3 }) - - if v1 != v2 || ok1 != ok2 { - t.Fatalf("FindWhere should behave exactly the same as FirstWhere(fn)") - } -} - -func TestFindWhere_WorksWithStructs(t *testing.T) { - type User struct { - ID int - Name string - } - - c := New([]User{ - {ID: 1, Name: "A"}, - {ID: 2, Name: "B"}, - {ID: 3, Name: "C"}, - }) - - expected := User{ID: 2, Name: "B"} - - v, ok := c.FindWhere(func(u User) bool { - return u.ID == 2 - }) - - if !ok { - t.Fatalf("FindWhere should locate struct matching predicate") - } - - if !reflect.DeepEqual(v, expected) { - t.Fatalf("FindWhere returned wrong struct. expected %#v, got %#v", expected, v) - } -} - -func TestFindWhere_NoStructMatch(t *testing.T) { - type User struct { - ID int - Name string - } - - c := New([]User{ - {ID: 1, Name: "A"}, - {ID: 2, Name: "B"}, - }) - - v, ok := c.FindWhere(func(u User) bool { - return u.ID == 999 - }) - - if ok { - t.Fatalf("FindWhere should return ok=false when no struct matches") - } - - if (v != User{}) { - t.Fatalf("FindWhere should return zero-value struct when no match") - } -} diff --git a/first.go b/first.go index 2ddf27e..31fb1a8 100644 --- a/first.go +++ b/first.go @@ -4,7 +4,8 @@ package collection // If the collection is empty, ok will be false. // @group Querying // @behavior readonly -// @fluent true +// @chainable false +// @terminal true // // Example: integers // diff --git a/first_where.go b/first_where.go index 2c98e42..50f098a 100644 --- a/first_where.go +++ b/first_where.go @@ -5,7 +5,8 @@ package collection // along with the zero value of T. // @group Querying // @behavior readonly -// @fluent true +// @chainable false +// @terminal true // // This method is equivalent to Laravel's collection->first(fn) and mirrors // the behavior found in functional collections in other languages. diff --git a/from_map.go b/from_map.go index a414572..a4dee8b 100644 --- a/from_map.go +++ b/from_map.go @@ -3,7 +3,8 @@ package collection // FromMap materializes a map into a collection of key/value pairs. // @group Maps // @behavior immutable -// @fluent true +// @chainable true +// @terminal false // // The iteration order of the resulting collection is unspecified, // matching Go's map iteration semantics. @@ -78,5 +79,5 @@ func FromMap[K comparable, V any](m map[K]V) *Collection[Pair[K, V]] { Value: v, }) } - return &Collection[Pair[K, V]]{items: items} + return New(items) } diff --git a/group_by.go b/group_by.go index 96bf2aa..4e591a3 100644 --- a/group_by.go +++ b/group_by.go @@ -4,7 +4,8 @@ package collection // returned from keyFn. // @group Grouping // @behavior readonly -// @fluent false +// @chainable false +// @terminal true // // The order of items within each group is preserved. // The order of the groups themselves is unspecified. diff --git a/group_by_slice.go b/group_by_slice.go index 092d44e..be8d5b4 100644 --- a/group_by_slice.go +++ b/group_by_slice.go @@ -4,7 +4,8 @@ package collection // returned from keyFn. // @group Grouping // @behavior readonly -// @fluent false +// @chainable false +// @terminal true // // The order of items within each group is preserved. // The order of the groups themselves is unspecified. diff --git a/index_where.go b/index_where.go index 02be1c7..36810f5 100644 --- a/index_where.go +++ b/index_where.go @@ -5,7 +5,8 @@ package collection // If no item matches, it returns (0, false). // @group Querying // @behavior readonly -// @fluent true +// @chainable false +// @terminal true // // This operation performs no allocations and short-circuits // on the first match. diff --git a/intersect.go b/intersect.go index 8367544..6fd8db9 100644 --- a/intersect.go +++ b/intersect.go @@ -4,7 +4,8 @@ package collection // collection that are also present in the first. // @group Set Operations // @behavior immutable -// @fluent true +// @chainable true +// @terminal false // // Order follows the second collection. // Duplicates are preserved based on the second collection. @@ -83,5 +84,5 @@ func Intersect[T comparable](a, b *Collection[T]) *Collection[T] { } } - return &Collection[T]{items: out} + return New(out) } diff --git a/is_empty.go b/is_empty.go index f060e42..e17fa9a 100644 --- a/is_empty.go +++ b/is_empty.go @@ -3,7 +3,8 @@ package collection // IsEmpty returns true if the collection has no items. // @group Querying // @behavior readonly -// @fluent true +// @chainable false +// @terminal true // // Example: integers (non-empty) // diff --git a/last.go b/last.go index 2cf5665..25dbd36 100644 --- a/last.go +++ b/last.go @@ -4,7 +4,8 @@ package collection // If the collection is empty, ok will be false. // @group Querying // @behavior readonly -// @fluent true +// @chainable false +// @terminal true // // Example: integers // diff --git a/last_where.go b/last_where.go index baef909..e9afcd2 100644 --- a/last_where.go +++ b/last_where.go @@ -5,7 +5,8 @@ package collection // If the collection is empty or no element matches, ok will be false. // @group Querying // @behavior readonly -// @fluent true +// @chainable false +// @terminal true // // Example: integers // diff --git a/map.go b/map.go index 1320a20..b540788 100644 --- a/map.go +++ b/map.go @@ -1,11 +1,13 @@ package collection -// Map applies a same-type transformation and returns a new collection. +// Map applies a same-type transformation in place and returns the same collection. // @group Transformation -// @behavior immutable -// @fluent true +// @behavior mutable +// @chainable true +// @terminal false // // Use this when you're transforming T -> T (e.g., enrichment, normalization). +// Map mutates the receiver's backing slice. Use Clone() if you need isolation. // // Example: integers // @@ -66,8 +68,8 @@ package collection // // } // // ] func (c *Collection[T]) Map(fn func(T) T) *Collection[T] { - for i := range c.items { - c.items[i] = fn(c.items[i]) + for i, v := range c.items { + c.items[i] = fn(v) } return c } diff --git a/map_test.go b/map_test.go index af65df5..4d6365a 100644 --- a/map_test.go +++ b/map_test.go @@ -17,6 +17,14 @@ func TestMap_Ints(t *testing.T) { if !reflect.DeepEqual(mapped.items, expected) { t.Fatalf("expected %v, got %v", expected, mapped.items) } + + if mapped != c { + t.Fatalf("Map should return the same collection") + } + + if !reflect.DeepEqual(c.Items(), expected) { + t.Fatalf("Map should mutate original collection") + } } func TestMap_Structs(t *testing.T) { @@ -43,6 +51,14 @@ func TestMap_Structs(t *testing.T) { if !reflect.DeepEqual(mapped.items, expected) { t.Fatalf("expected %v, got %v", expected, mapped.items) } + + if mapped != c { + t.Fatalf("Map should return the same collection") + } + + if !reflect.DeepEqual(c.Items(), expected) { + t.Fatalf("Map should mutate original collection") + } } func TestMap_Empty(t *testing.T) { @@ -55,4 +71,40 @@ func TestMap_Empty(t *testing.T) { if len(mapped.items) != 0 { t.Fatalf("expected empty slice, got %v", mapped.items) } + + if mapped != c { + t.Fatalf("Map should return the same collection") + } +} + +func TestMap_PreservesNilSlice(t *testing.T) { + c := New([]int(nil)) + + c.Map(func(v int) int { return v * 2 }) + + if c.Items() != nil { + t.Fatalf("expected nil slice to remain nil, got %v", c.Items()) + } +} + +func TestMap_WritesThroughSourceSlice(t *testing.T) { + items := []int{1, 2, 3} + c := New(items) + + c.Map(func(v int) int { return v * 2 }) + + want := []int{2, 4, 6} + if !reflect.DeepEqual(items, want) { + t.Fatalf("expected source slice %v, got %v", want, items) + } +} + +func TestMap_LengthUnchanged(t *testing.T) { + c := New([]int{1, 2, 3}) + + c.Map(func(v int) int { return v + 1 }) + + if len(c.Items()) != 3 { + t.Fatalf("expected length 3, got %d", len(c.Items())) + } } diff --git a/map_to_test.go b/map_to_test.go new file mode 100644 index 0000000..8685deb --- /dev/null +++ b/map_to_test.go @@ -0,0 +1,72 @@ +package collection + +import ( + "reflect" + "testing" +) + +func TestMapTo_Ints(t *testing.T) { + c := New([]int{1, 2, 3}) + + out := MapTo(c, func(v int) string { + if v%2 == 0 { + return "even" + } + return "odd" + }) + + expected := []string{"odd", "even", "odd"} + if !reflect.DeepEqual(out.Items(), expected) { + t.Fatalf("expected %v, got %v", expected, out.Items()) + } +} + +func TestMapTo_Strings(t *testing.T) { + c := New([]string{"go", "forj", "rocks"}) + + out := MapTo(c, func(s string) int { return len(s) }) + + expected := []int{2, 4, 5} + if !reflect.DeepEqual(out.Items(), expected) { + t.Fatalf("expected %v, got %v", expected, out.Items()) + } +} + +func TestMapTo_Structs(t *testing.T) { + type User struct { + ID int + Name string + } + + users := New([]User{ + {ID: 1, Name: "Alice"}, + {ID: 2, Name: "Bob"}, + }) + + out := MapTo(users, func(u User) string { return u.Name }) + + expected := []string{"Alice", "Bob"} + if !reflect.DeepEqual(out.Items(), expected) { + t.Fatalf("expected %v, got %v", expected, out.Items()) + } +} + +func TestMapTo_Empty(t *testing.T) { + c := New([]int{}) + + out := MapTo(c, func(v int) int { return v * 2 }) + + if len(out.Items()) != 0 { + t.Fatalf("expected empty slice, got %v", out.Items()) + } +} + +func TestMapTo_NoMutation(t *testing.T) { + c := New([]int{1, 2, 3}) + + _ = MapTo(c, func(v int) int { return v * 10 }) + + if !reflect.DeepEqual(c.Items(), []int{1, 2, 3}) { + t.Fatalf("MapTo should not mutate original collection") + } +} diff --git a/max.go b/max.go index 1a3ba78..c982952 100644 --- a/max.go +++ b/max.go @@ -4,7 +4,8 @@ package collection // The second return value is false if the collection is empty. // @group Aggregation // @behavior readonly -// @fluent false +// @chainable false +// @terminal true // // Example: integers // diff --git a/max_by.go b/max_by.go index ee78d92..562290c 100644 --- a/max_by.go +++ b/max_by.go @@ -4,7 +4,8 @@ package collection // The second return value is false if the collection is empty. // @group Aggregation // @behavior readonly -// @fluent false +// @chainable false +// @terminal true // // This cannot be a method because methods can't introduce a new type parameter K. // When multiple items share the same maximal key, the first such item is returned. diff --git a/median.go b/median.go index abde321..08c972b 100644 --- a/median.go +++ b/median.go @@ -6,7 +6,8 @@ import "sort" // Returns (0, false) if the collection is empty. // @group Aggregation // @behavior readonly -// @fluent false +// @chainable false +// @terminal true // // Odd count → middle value // Even count → average of the two middle values diff --git a/merge.go b/merge.go index 8717ad0..4c1b028 100644 --- a/merge.go +++ b/merge.go @@ -1,9 +1,10 @@ package collection -// Merge merges the given data into the current collection. +// Merge merges the given data into a new collection. // @group Transformation -// @behavior mutable -// @fluent true +// @behavior immutable +// @chainable true +// @terminal false // // Example: integers - merging slices // @@ -97,7 +98,7 @@ func (c *Collection[T]) mergeSlice(values []T) *Collection[T] { out := make([]T, len(c.items)+len(values)) copy(out, c.items) copy(out[len(c.items):], values) - return &Collection[T]{items: out} + return New(out) } /* @@ -150,7 +151,7 @@ func (c *Collection[T]) mergeMap(values map[string]T) *Collection[T] { } // IMPORTANT: return without copying out again - return &Collection[T]{items: out} + return New(out) } // fastParseInt is much lighter than strconv.Atoi diff --git a/merge_test.go b/merge_test.go index 8b38b5d..e2ea291 100644 --- a/merge_test.go +++ b/merge_test.go @@ -15,6 +15,10 @@ func TestMerge_AppendsSlice(t *testing.T) { if !reflect.DeepEqual(got, want) { t.Fatalf("Merge([]T) append failed.\nwant=%v\ngot=%v", want, got) } + + if !reflect.DeepEqual(c.Items(), []string{"Desk", "Chair"}) { + t.Fatalf("Merge should not mutate original collection") + } } func TestMerge_AppendsCollection(t *testing.T) { diff --git a/min.go b/min.go index 09360a5..bc32c7b 100644 --- a/min.go +++ b/min.go @@ -4,7 +4,8 @@ package collection // The second return value is false if the collection is empty. // @group Aggregation // @behavior readonly -// @fluent false +// @chainable false +// @terminal true // // Example: integers // diff --git a/min_by.go b/min_by.go index f3b69e2..271f808 100644 --- a/min_by.go +++ b/min_by.go @@ -4,7 +4,8 @@ package collection // The second return value is false if the collection is empty. // @group Aggregation // @behavior readonly -// @fluent false +// @chainable false +// @terminal true // // This cannot be a method because methods can't introduce a new type parameter K. // When multiple items share the same minimal key, the first such item is returned. diff --git a/mode.go b/mode.go index 2bc382b..e870db2 100644 --- a/mode.go +++ b/mode.go @@ -5,7 +5,8 @@ package collection // in first-seen order. // @group Aggregation // @behavior readonly -// @fluent false +// @chainable false +// @terminal true // // Example: integers – single mode // diff --git a/multiply.go b/multiply.go index 4382df0..b80f098 100644 --- a/multiply.go +++ b/multiply.go @@ -3,8 +3,9 @@ package collection // Multiply creates `n` copies of all items in the collection // and returns a new collection. // @group Transformation -// @behavior mutable -// @fluent true +// @behavior immutable +// @chainable true +// @terminal false // // Example: integers // diff --git a/none.go b/none.go index f89dbc1..ec398c5 100644 --- a/none.go +++ b/none.go @@ -4,7 +4,8 @@ package collection // If the collection is empty, None returns true. // @group Querying // @behavior readonly -// @fluent true +// @chainable false +// @terminal true // // Example: integers – none even // diff --git a/partition.go b/partition.go index 4107edb..c600049 100644 --- a/partition.go +++ b/partition.go @@ -5,7 +5,8 @@ package collection // items where fn returns false. Order is preserved within each partition. // @group Slicing // @behavior immutable -// @fluent true +// @chainable false +// @terminal true // // Example: integers - even/odd // diff --git a/pipe.go b/pipe.go index 7590c49..acc0394 100644 --- a/pipe.go +++ b/pipe.go @@ -4,15 +4,15 @@ package collection // and returns the function's result. // @group Transformation // @behavior readonly -// @fluent true +// @chainable false +// @terminal true // -// This is useful for inline transformations, aggregations, -// or "exiting" a chain with a non-collection value. +// This is a typed escape hatch for branching into arbitrary logic without `any`. // // Example: integers – computing a sum // // c := collection.New([]int{1, 2, 3}) -// sum := c.Pipe(func(col *collection.Collection[int]) any { +// sum := collection.Pipe(c, func(col *collection.Collection[int]) int { // total := 0 // for _, v := range col.Items() { // total += v @@ -25,7 +25,7 @@ package collection // Example: strings – joining values // // c2 := collection.New([]string{"a", "b", "c"}) -// joined := c2.Pipe(func(col *collection.Collection[string]) any { +// joined := collection.Pipe(c2, func(col *collection.Collection[string]) string { // out := "" // for _, v := range col.Items() { // out += v @@ -47,7 +47,7 @@ package collection // {ID: 2, Name: "Bob"}, // }) // -// names := users.Pipe(func(col *collection.Collection[User]) any { +// names := collection.Pipe(users, func(col *collection.Collection[User]) []string { // result := make([]string, 0, len(col.Items())) // for _, u := range col.Items() { // result = append(result, u.Name) @@ -60,6 +60,6 @@ package collection // // 0 => "Alice" #string // // 1 => "Bob" #string // // ] -func (c *Collection[T]) Pipe(fn func(*Collection[T]) any) any { +func Pipe[T any, R any](c *Collection[T], fn func(*Collection[T]) R) R { return fn(c) } diff --git a/pipe_test.go b/pipe_test.go index 521ca72..60c420c 100644 --- a/pipe_test.go +++ b/pipe_test.go @@ -8,7 +8,7 @@ import ( func TestPipe_ReturnsTransformedValue(t *testing.T) { c := New([]int{1, 2, 3}) - result := c.Pipe(func(col *Collection[int]) any { + result := Pipe(c, func(col *Collection[int]) int { sum := 0 for _, v := range col.Items() { sum += v @@ -16,7 +16,7 @@ func TestPipe_ReturnsTransformedValue(t *testing.T) { return sum }) - if result.(int) != 6 { + if result != 6 { t.Fatalf("Pipe() expected sum=6, got=%v", result) } } @@ -24,11 +24,11 @@ func TestPipe_ReturnsTransformedValue(t *testing.T) { func TestPipe_CanReturnCollection(t *testing.T) { c := New([]int{1, 2, 3}) - result := c.Pipe(func(col *Collection[int]) any { + result := Pipe(c, func(col *Collection[int]) *Collection[int] { return col.Filter(func(v int) bool { return v > 1 }) }) - out := result.(*Collection[int]).Items() + out := result.Items() want := []int{2, 3} if !reflect.DeepEqual(out, want) { @@ -39,9 +39,13 @@ func TestPipe_CanReturnCollection(t *testing.T) { func TestPipe_IsNonMutating(t *testing.T) { c := New([]int{1, 2, 3}) - _ = c.Pipe(func(col *Collection[int]) any { - return col.Map(func(v int) int { return v * 2 }) + _ = Pipe(c, func(col *Collection[int]) *Collection[int] { + return col }) + + if !reflect.DeepEqual(c.Items(), []int{1, 2, 3}) { + t.Fatalf("Pipe() should not mutate original collection") + } } func TestPipe_ReceivesCorrectCollection(t *testing.T) { @@ -49,9 +53,9 @@ func TestPipe_ReceivesCorrectCollection(t *testing.T) { calledWith := "" - c.Pipe(func(col *Collection[string]) any { + Pipe(c, func(col *Collection[string]) string { calledWith = col.Items()[0] - return nil + return "" }) if calledWith != "a" { diff --git a/pluck.go b/pluck.go index 2332fe7..ddd25fa 100644 --- a/pluck.go +++ b/pluck.go @@ -3,7 +3,8 @@ package collection // MapTo maps a Collection[T] to a Collection[R] using fn(T) R. // @group Transformation // @behavior immutable -// @fluent true +// @chainable true +// @terminal false // // This cannot be a method because methods can't introduce a new type parameter R. // @@ -64,67 +65,5 @@ func MapTo[T any, R any](c *Collection[T], fn func(T) R) *Collection[R] { for i, v := range items { out[i] = fn(v) } - return &Collection[R]{items: out} -} - -// Pluck is an alias for MapTo with a more semantic name when projecting fields. -// It extracts a single field or computed value from every element and returns a -// new typed collection. -// @group Transformation -// @behavior immutable -// @fluent true -// -// Example: integers - extract parity label -// -// nums := collection.New([]int{1, 2, 3, 4}) -// parity := collection.Pluck(nums, func(n int) string { -// if n%2 == 0 { -// return "even" -// } -// return "odd" -// }) -// collection.Dump(parity.Items()) -// // #[]string [ -// // 0 => "odd" #string -// // 1 => "even" #string -// // 2 => "odd" #string -// // 3 => "even" #string -// // ] -// -// Example: strings - length of each value -// -// words := collection.New([]string{"go", "forj", "rocks"}) -// lengths := collection.Pluck(words, func(s string) int { -// return len(s) -// }) -// collection.Dump(lengths.Items()) -// // #[]int [ -// // 0 => 2 #int -// // 1 => 4 #int -// // 2 => 5 #int -// // ] -// -// Example: structs - pluck a field -// -// type User struct { -// ID int -// Name string -// } -// -// users := collection.New([]User{ -// {ID: 1, Name: "Alice"}, -// {ID: 2, Name: "Bob"}, -// }) -// -// names := collection.Pluck(users, func(u User) string { -// return u.Name -// }) -// -// collection.Dump(names.Items()) -// // #[]string [ -// // 0 => "Alice" #string -// // 1 => "Bob" #string -// // ] -func Pluck[T any, R any](c *Collection[T], fn func(T) R) *Collection[R] { - return MapTo(c, fn) + return New(out) } diff --git a/pluck_test.go b/pluck_test.go deleted file mode 100644 index 6bf4d80..0000000 --- a/pluck_test.go +++ /dev/null @@ -1,139 +0,0 @@ -package collection - -import ( - "reflect" - "testing" -) - -func TestMapTo_Ints(t *testing.T) { - c := New([]int{1, 2, 3}) - - out := MapTo(c, func(v int) int { - return v * v - }) - - expected := []int{1, 4, 9} - - if !reflect.DeepEqual(out.items, expected) { - t.Fatalf("expected %v, got %v", expected, out.items) - } -} - -func TestMapTo_ChangeType(t *testing.T) { - c := New([]int{1, 2, 3}) - - out := MapTo(c, func(v int) string { - return "num:" + string(rune('0'+v)) - }) - - expected := []string{"num:1", "num:2", "num:3"} - - if !reflect.DeepEqual(out.items, expected) { - t.Fatalf("expected %v, got %v", expected, out.items) - } -} - -func TestMapTo_StructToInt(t *testing.T) { - type User struct { - ID int - Name string - } - - c := New([]User{ - {1, "Chris"}, - {2, "Van"}, - {3, "Shawn"}, - }) - - out := MapTo(c, func(u User) int { - return u.ID - }) - - expected := []int{1, 2, 3} - - if !reflect.DeepEqual(out.items, expected) { - t.Fatalf("expected %v, got %v", expected, out.items) - } -} - -func TestPluck_StructField(t *testing.T) { - type User struct { - ID int - Name string - } - - c := New([]User{ - {1, "Chris"}, - {2, "Van"}, - {3, "Shawn"}, - }) - - out := Pluck(c, func(u User) string { - return u.Name - }) - - expected := []string{"Chris", "Van", "Shawn"} - - if !reflect.DeepEqual(out.items, expected) { - t.Fatalf("expected %v, got %v", expected, out.items) - } -} - -func TestMapTo_Empty(t *testing.T) { - c := New([]int{}) - - out := MapTo(c, func(v int) int { - return v * 10 - }) - - if len(out.items) != 0 { - t.Fatalf("expected empty result, got %v", out.items) - } -} - -func TestPluck_Empty(t *testing.T) { - type User struct{ Name string } - - c := New([]User{}) - - out := Pluck(c, func(u User) string { - return u.Name - }) - - if len(out.items) != 0 { - t.Fatalf("expected empty result, got %v", out.items) - } -} - -func TestMapTo_NoMutation(t *testing.T) { - c := New([]int{1, 2, 3}) - orig := append([]int{}, c.items...) - - _ = MapTo(c, func(v int) int { - return v * 100 - }) - - if !reflect.DeepEqual(c.items, orig) { - t.Fatalf("MapTo mutated original collection: %v vs %v", c.items, orig) - } -} - -func TestPluck_NoMutation(t *testing.T) { - type User struct { - ID int - Name string - } - - c := New([]User{ - {1, "Chris"}, - {2, "Van"}, - }) - - orig := append([]User{}, c.items...) - - _ = Pluck(c, func(u User) string { return u.Name }) - - if !reflect.DeepEqual(c.items, orig) { - t.Fatalf("Pluck mutated original collection: %v vs %v", c.items, orig) - } -} diff --git a/pop.go b/pop.go index dccb10d..28df5c5 100644 --- a/pop.go +++ b/pop.go @@ -1,20 +1,20 @@ package collection -// Pop returns the last item and a new collection with that item removed. -// The original collection remains unchanged. +// Pop removes and returns the last item in the collection. // @group Slicing // @behavior mutable -// @fluent true +// @chainable false +// @terminal true // -// If the collection is empty, the zero value of T is returned along with -// an empty collection. +// If the collection is empty, the zero value of T is returned with ok=false. // // Example: integers // // c := collection.New([]int{1, 2, 3}) -// item, rest := c.Pop() -// collection.Dump(item, rest.Items()) +// item, ok := c.Pop() +// collection.Dump(item, ok, c.Items()) // // 3 #int +// // true #bool // // #[]int [ // // 0 => 1 #int // // 1 => 2 #int @@ -23,9 +23,10 @@ package collection // Example: strings // // c2 := collection.New([]string{"a", "b", "c"}) -// item2, rest2 := c2.Pop() -// collection.Dump(item2, rest2.Items()) +// item2, ok2 := c2.Pop() +// collection.Dump(item2, ok2, c2.Items()) // // "c" #string +// // true #bool // // #[]string [ // // 0 => "a" #string // // 1 => "b" #string @@ -43,12 +44,13 @@ package collection // {ID: 2, Name: "Bob"}, // }) // -// item3, rest3 := users.Pop() -// collection.Dump(item3, rest3.Items()) +// item3, ok3 := users.Pop() +// collection.Dump(item3, ok3, users.Items()) // // #main.User { // // +ID => 2 #int // // +Name => "Bob" #string // // } +// // true #bool // // #[]main.User [ // // 0 => #main.User { // // +ID => 1 #int @@ -59,42 +61,39 @@ package collection // Example: empty collection // // empty := collection.New([]int{}) -// item4, rest4 := empty.Pop() -// collection.Dump(item4, rest4.Items()) +// item4, ok4 := empty.Pop() +// collection.Dump(item4, ok4, empty.Items()) // // 0 #int +// // false #bool // // #[]int [ // // ] -func (c *Collection[T]) Pop() (T, *Collection[T]) { +func (c *Collection[T]) Pop() (T, bool) { n := len(c.items) if n == 0 { var zero T - return zero, New([]T{}) + return zero, false } item := c.items[n-1] - rest := c.items[:n-1] - - return item, New(rest) + c.items = c.items[:n-1] + return item, true } -// PopN removes and returns the last n items as a new collection, -// and returns a second collection containing the remaining items. +// PopN removes and returns the last n items in original order. // @group Slicing // @behavior mutable -// @fluent true -// -// The popped items are returned in reverse order, matching the behavior -// of repeated Pop() calls. +// @chainable false +// @terminal true // // Example: integers – pop 2 // // c := collection.New([]int{1, 2, 3, 4}) -// popped, rest := c.PopN(2) -// collection.Dump(popped.Items(), rest.Items()) +// popped := c.PopN(2) +// collection.Dump(popped, c.Items()) // // #[]int [ -// // 0 => 4 #int -// // 1 => 3 #int +// // 0 => 3 #int +// // 1 => 4 #int // // ] // // #[]int [ // // 0 => 1 #int @@ -104,8 +103,8 @@ func (c *Collection[T]) Pop() (T, *Collection[T]) { // Example: strings – pop 1 // // c2 := collection.New([]string{"a", "b", "c"}) -// popped2, rest2 := c2.PopN(1) -// collection.Dump(popped2.Items(), rest2.Items()) +// popped2 := c2.PopN(1) +// collection.Dump(popped2, c2.Items()) // // #[]string [ // // 0 => "c" #string // // ] @@ -127,17 +126,17 @@ func (c *Collection[T]) Pop() (T, *Collection[T]) { // {ID: 3, Name: "Carol"}, // }) // -// popped3, rest3 := users.PopN(2) -// collection.Dump(popped3.Items(), rest3.Items()) +// popped3 := users.PopN(2) +// collection.Dump(popped3, users.Items()) // // #[]main.User [ // // 0 => #main.User { -// // +ID => 3 #int -// // +Name => "Carol" #string -// // } -// // 1 => #main.User { // // +ID => 2 #int // // +Name => "Bob" #string // // } +// // 1 => #main.User { +// // +ID => 3 #int +// // +Name => "Carol" #string +// // } // // ] // // #[]main.User [ // // 0 => #main.User { @@ -146,13 +145,12 @@ func (c *Collection[T]) Pop() (T, *Collection[T]) { // // } // // ] // -// Example: integers - n <= 0 → returns empty popped + original collection +// Example: integers - n <= 0 → returns nil, no change // // c3 := collection.New([]int{1, 2, 3}) -// popped4, rest4 := c3.PopN(0) -// collection.Dump(popped4.Items(), rest4.Items()) -// // #[]int [ -// // ] +// popped4 := c3.PopN(0) +// collection.Dump(popped4, c3.Items()) +// // // // #[]int [ // // 0 => 1 #int // // 1 => 2 #int @@ -162,35 +160,25 @@ func (c *Collection[T]) Pop() (T, *Collection[T]) { // Example: strings - n exceeds length → all items popped, rest empty // // c4 := collection.New([]string{"x", "y"}) -// popped5, rest5 := c4.PopN(10) -// collection.Dump(popped5.Items(), rest5.Items()) +// popped5 := c4.PopN(10) +// collection.Dump(popped5, c4.Items()) // // #[]string [ -// // 0 => "y" #string -// // 1 => "x" #string +// // 0 => "x" #string +// // 1 => "y" #string // // ] // // #[]string [ // // ] -func (c *Collection[T]) PopN(n int) (*Collection[T], *Collection[T]) { +func (c *Collection[T]) PopN(n int) []T { if n <= 0 || len(c.items) == 0 { - return New([]T{}), c + return nil } total := len(c.items) - - if n >= total { - return New(reverseCopy(c.items)), New([]T{}) + if n > total { + n = total } - remain := c.items[:total-n] popped := c.items[total-n:] - - return New(reverseCopy(popped)), New(remain) -} - -func reverseCopy[T any](src []T) []T { - out := make([]T, len(src)) - for i := range src { - out[i] = src[len(src)-1-i] - } - return out + c.items = c.items[:total-n] + return popped } diff --git a/pop_test.go b/pop_test.go index 5d4816d..04a4295 100644 --- a/pop_test.go +++ b/pop_test.go @@ -8,101 +8,117 @@ import ( func TestPop_RemovesOne(t *testing.T) { c := New([]int{1, 2, 3}) - v, c2 := c.Pop() + v, ok := c.Pop() if v != 3 { t.Fatalf("Pop() expected 3, got %v", v) } - want := []int{1, 2} - if !reflect.DeepEqual(c2.Items(), want) { - t.Fatalf("Pop() expected remainder %v, got %v", want, c2.Items()) + if !ok { + t.Fatalf("Pop() expected ok=true") } - // original must remain unchanged - if !reflect.DeepEqual(c.Items(), []int{1, 2, 3}) { - t.Fatalf("Pop() mutated original collection") + want := []int{1, 2} + if !reflect.DeepEqual(c.Items(), want) { + t.Fatalf("Pop() expected remainder %v, got %v", want, c.Items()) } } func TestPop_OnEmptyReturnsZero(t *testing.T) { c := New([]int{}) - v, c2 := c.Pop() + v, ok := c.Pop() if v != 0 { t.Fatalf("Pop() on empty should return zero-value, got %v", v) } - if len(c2.Items()) != 0 { - t.Fatalf("Pop() on empty should return empty collection") + if ok { + t.Fatalf("Pop() on empty should return ok=false") + } + + if len(c.Items()) != 0 { + t.Fatalf("Pop() on empty should keep collection empty") } } func TestPopN_RemovesMultiple(t *testing.T) { c := New([]int{1, 2, 3, 4, 5}) - popped, remain := c.PopN(3) + popped := c.PopN(3) - wantPopped := []int{5, 4, 3} + wantPopped := []int{3, 4, 5} wantRemain := []int{1, 2} - if !reflect.DeepEqual(popped.Items(), wantPopped) { - t.Fatalf("PopN(3) wrong popped values. want=%v got=%v", wantPopped, popped.Items()) - } - - if !reflect.DeepEqual(remain.Items(), wantRemain) { - t.Fatalf("PopN(3) wrong remaining values. want=%v got=%v", wantRemain, remain.Items()) + if !reflect.DeepEqual(popped, wantPopped) { + t.Fatalf("PopN(3) wrong popped values. want=%v got=%v", wantPopped, popped) } - // original must remain unchanged - if !reflect.DeepEqual(c.Items(), []int{1, 2, 3, 4, 5}) { - t.Fatalf("PopN() mutated original collection") + if !reflect.DeepEqual(c.Items(), wantRemain) { + t.Fatalf("PopN(3) wrong remaining values. want=%v got=%v", wantRemain, c.Items()) } } func TestPopN_MoreThanLength(t *testing.T) { c := New([]int{1, 2}) - popped, remain := c.PopN(10) - - wantPopped := []int{2, 1} + popped := c.PopN(10) - if !reflect.DeepEqual(popped.Items(), wantPopped) { - t.Fatalf("PopN(>len) wrong popped. want=%v got=%v", wantPopped, popped.Items()) - } + wantPopped := []int{1, 2} - if len(remain.Items()) != 0 { - t.Fatalf("PopN(>len) should return empty remainder") + if !reflect.DeepEqual(popped, wantPopped) { + t.Fatalf("PopN(>len) wrong popped. want=%v got=%v", wantPopped, popped) } - // original unchanged - if !reflect.DeepEqual(c.Items(), []int{1, 2}) { - t.Fatalf("PopN() mutated original collection") + if len(c.Items()) != 0 { + t.Fatalf("PopN(>len) should leave empty remainder") } } func TestPopN_ZeroOrNegative(t *testing.T) { c := New([]int{1, 2, 3}) - popped, remain := c.PopN(0) - if len(popped.Items()) != 0 { - t.Fatalf("PopN(0) should return empty popped collection") + popped := c.PopN(0) + if popped != nil { + t.Fatalf("PopN(0) should return nil") } - if !reflect.DeepEqual(remain.Items(), []int{1, 2, 3}) { + if !reflect.DeepEqual(c.Items(), []int{1, 2, 3}) { t.Fatalf("PopN(0) should not modify original") } - popped, remain = c.PopN(-5) - if len(popped.Items()) != 0 { - t.Fatalf("PopN(-n) should return empty collection") + popped = c.PopN(-5) + if popped != nil { + t.Fatalf("PopN(-n) should return nil") } - if !reflect.DeepEqual(remain.Items(), []int{1, 2, 3}) { + if !reflect.DeepEqual(c.Items(), []int{1, 2, 3}) { t.Fatalf("PopN(-n) should not modify original") } +} + +func TestPop_PreservesNilSlice(t *testing.T) { + c := New([]int(nil)) + + v, ok := c.Pop() + + if v != 0 || ok { + t.Fatalf("Pop() on nil should return zero-value and ok=false") + } - // original still unchanged - if !reflect.DeepEqual(c.Items(), []int{1, 2, 3}) { - t.Fatalf("PopN zero/negative mutated original collection") + if c.Items() != nil { + t.Fatalf("expected nil slice to remain nil, got %v", c.Items()) + } +} + +func TestPopN_PreservesNilSlice(t *testing.T) { + c := New([]int(nil)) + + popped := c.PopN(2) + + if popped != nil { + t.Fatalf("PopN on nil should return nil") + } + + if c.Items() != nil { + t.Fatalf("expected nil slice to remain nil, got %v", c.Items()) } } diff --git a/prepend.go b/prepend.go index 7edb684..f4dd7e3 100644 --- a/prepend.go +++ b/prepend.go @@ -1,18 +1,19 @@ package collection -// Prepend returns a new collection with the given values added -// to the *beginning* of the collection. +// Prepend adds the given values to the beginning of the collection. // @group Transformation // @behavior mutable -// @fluent true +// @chainable true +// @terminal false // -// The original collection is not modified. +// This method mutates the collection in place and returns the same instance. +// It allocates a new backing slice to insert the values at the front. // // Example: integers // // c := collection.New([]int{3, 4}) -// newC := c.Prepend(1, 2) -// collection.Dump(newC.Items()) +// c.Prepend(1, 2) +// collection.Dump(c.Items()) // // #[]int [ // // 0 => 1 #int // // 1 => 2 #int @@ -23,8 +24,8 @@ package collection // Example: strings // // letters := collection.New([]string{"c", "d"}) -// out := letters.Prepend("a", "b") -// collection.Dump(out.Items()) +// letters.Prepend("a", "b") +// collection.Dump(letters.Items()) // // #[]string [ // // 0 => "a" #string // // 1 => "b" #string @@ -43,8 +44,8 @@ package collection // {ID: 2, Name: "Bob"}, // }) // -// out2 := users.Prepend(User{ID: 1, Name: "Alice"}) -// collection.Dump(out2.Items()) +// users.Prepend(User{ID: 1, Name: "Alice"}) +// collection.Dump(users.Items()) // // #[]main.User [ // // 0 => #main.User { // // +ID => 1 #int @@ -59,18 +60,18 @@ package collection // Example: integers - Prepending into an empty collection // // empty := collection.New([]int{}) -// out3 := empty.Prepend(9, 8) -// collection.Dump(out3.Items()) +// empty.Prepend(9, 8) +// collection.Dump(empty.Items()) // // #[]int [ // // 0 => 9 #int // // 1 => 8 #int // // ] // -// Example: integers - Prepending no values → returns a copy of original +// Example: integers - Prepending no values → no change // // c2 := collection.New([]int{1, 2}) -// out4 := c2.Prepend() -// collection.Dump(out4.Items()) +// c2.Prepend() +// collection.Dump(c2.Items()) // // #[]int [ // // 0 => 1 #int // // 1 => 2 #int @@ -79,5 +80,6 @@ func (c *Collection[T]) Prepend(values ...T) *Collection[T] { out := make([]T, 0, len(c.items)+len(values)) out = append(out, values...) out = append(out, c.items...) - return &Collection[T]{items: out} + c.items = out + return c } diff --git a/prepend_test.go b/prepend_test.go index ffe913f..3e74c7a 100644 --- a/prepend_test.go +++ b/prepend_test.go @@ -10,9 +10,13 @@ func TestPrepend_Basic(t *testing.T) { out := c.Prepend(1, 2) + if out != c { + t.Fatalf("expected Prepend to return same collection instance") + } + expected := []int{1, 2, 3, 4} - if !reflect.DeepEqual(out.items, expected) { - t.Fatalf("expected %v, got %v", expected, out.items) + if !reflect.DeepEqual(c.items, expected) { + t.Fatalf("expected %v, got %v", expected, c.items) } } @@ -21,9 +25,13 @@ func TestPrepend_EmptyCollection(t *testing.T) { out := c.Prepend(1, 2, 3) + if out != c { + t.Fatalf("expected Prepend to return same collection instance") + } + expected := []int{1, 2, 3} - if !reflect.DeepEqual(out.items, expected) { - t.Fatalf("expected %v, got %v", expected, out.items) + if !reflect.DeepEqual(c.items, expected) { + t.Fatalf("expected %v, got %v", expected, c.items) } } @@ -32,9 +40,13 @@ func TestPrepend_NoValues(t *testing.T) { out := c.Prepend() // no-op + if out != c { + t.Fatalf("expected Prepend to return same collection instance") + } + expected := []int{1, 2, 3} - if !reflect.DeepEqual(out.items, expected) { - t.Fatalf("expected %v, got %v", expected, out.items) + if !reflect.DeepEqual(c.items, expected) { + t.Fatalf("expected %v, got %v", expected, c.items) } } @@ -51,6 +63,10 @@ func TestPrepend_Structs(t *testing.T) { out := c.Prepend(User{1, "Chris"}, User{2, "Matt"}) + if out != c { + t.Fatalf("expected Prepend to return same collection instance") + } + expected := []User{ {1, "Chris"}, {2, "Matt"}, @@ -58,25 +74,51 @@ func TestPrepend_Structs(t *testing.T) { {4, "Van"}, } - if !reflect.DeepEqual(out.items, expected) { - t.Fatalf("expected %v, got %v", expected, out.items) + if !reflect.DeepEqual(c.items, expected) { + t.Fatalf("expected %v, got %v", expected, c.items) } } -func TestPrepend_NoMutation(t *testing.T) { +func TestPrepend_DoesNotMutateSourceSlice(t *testing.T) { orig := []int{10, 20, 30} c := New(orig) out := c.Prepend(5, 7) - // original must remain unchanged - if !reflect.DeepEqual(c.items, orig) { - t.Fatalf("Prepend mutated original collection: %v", c.items) + if out != c { + t.Fatalf("expected Prepend to return same collection instance") + } + + if !reflect.DeepEqual(orig, []int{10, 20, 30}) { + t.Fatalf("Prepend mutated source slice: %v", orig) } - // ensure new collection is different & correct expected := []int{5, 7, 10, 20, 30} - if !reflect.DeepEqual(out.items, expected) { - t.Fatalf("expected %v, got %v", expected, out.items) + if !reflect.DeepEqual(c.items, expected) { + t.Fatalf("expected %v, got %v", expected, c.items) + } +} + +func TestPrepend_NilSliceWithValues(t *testing.T) { + c := New([]int(nil)) + + c.Prepend(1, 2) + + expected := []int{1, 2} + if !reflect.DeepEqual(c.Items(), expected) { + t.Fatalf("expected %v, got %v", expected, c.Items()) + } +} + +func TestPrepend_NilSliceNoValuesBecomesEmpty(t *testing.T) { + c := New([]int(nil)) + + c.Prepend() + + if c.Items() == nil { + t.Fatalf("expected empty slice, got nil") + } + if len(c.Items()) != 0 { + t.Fatalf("expected empty slice, got %v", c.Items()) } } diff --git a/reduce.go b/reduce.go index 51ef0a1..bdfbf86 100644 --- a/reduce.go +++ b/reduce.go @@ -4,7 +4,8 @@ package collection // The accumulator has the same type T as the collection's elements. // @group Aggregation // @behavior readonly -// @fluent true +// @chainable false +// @terminal true // // This is useful for computing sums, concatenations, aggregates, // or any fold-style reduction. diff --git a/reverse.go b/reverse.go index 1003587..5007d02 100644 --- a/reverse.go +++ b/reverse.go @@ -4,7 +4,8 @@ package collection // and returns the same collection for chaining. // @group Ordering // @behavior mutable -// @fluent true +// @chainable true +// @terminal false // // This operation performs no allocations. // diff --git a/reverse_test.go b/reverse_test.go index 98eb50e..12ac3c4 100644 --- a/reverse_test.go +++ b/reverse_test.go @@ -96,3 +96,13 @@ func TestReverse_MutatesInPlace(t *testing.T) { t.Fatalf("expected underlying slice to be mutated, got %v", items) } } + +func TestReverse_PreservesNilSlice(t *testing.T) { + c := New([]int(nil)) + + c.Reverse() + + if c.Items() != nil { + t.Fatalf("expected nil slice to remain nil, got %v", c.Items()) + } +} diff --git a/shuffle.go b/shuffle.go index 7094855..3c021c7 100644 --- a/shuffle.go +++ b/shuffle.go @@ -15,13 +15,13 @@ func setShuffleRand(r *rand.Rand) { shuffleRand = r } -// Shuffle randomly shuffles the items in the collection in place -// and returns the same collection for chaining. +// Shuffle shuffles the collection in place and returns the same collection. // @group Ordering // @behavior mutable -// @fluent true +// @chainable true +// @terminal false // -// This operation performs no allocations. +// This operation mutates the receiver's backing slice. // // The shuffle uses an internal random source. Tests may override // this source to achieve deterministic behavior. @@ -34,12 +34,12 @@ func setShuffleRand(r *rand.Rand) { // // Example: strings – chaining // -// out := collection.New([]string{"a", "b", "c"}). +// out2 := collection.New([]string{"a", "b", "c"}). // Shuffle(). // Append("d"). // Items() // -// collection.Dump(out) +// collection.Dump(out2) // // Example: structs // @@ -57,13 +57,12 @@ func setShuffleRand(r *rand.Rand) { // users.Shuffle() // collection.Dump(users.Items()) func (c *Collection[T]) Shuffle() *Collection[T] { - items := c.items - n := len(items) + n := len(c.items) - // Fisher–Yates shuffle (in place, zero alloc) + // Fisher–Yates shuffle (in place) for i := n - 1; i > 0; i-- { j := shuffleRand.Intn(i + 1) - items[i], items[j] = items[j], items[i] + c.items[i], c.items[j] = c.items[j], c.items[i] } return c diff --git a/shuffle_test.go b/shuffle_test.go index 93d0eb4..843a30f 100644 --- a/shuffle_test.go +++ b/shuffle_test.go @@ -20,14 +20,12 @@ func TestShuffle_Deterministic(t *testing.T) { withDeterministicShuffle(t, 42, func() { c := New([]int{1, 2, 3, 4, 5}) - c.Shuffle() - first := append([]int(nil), c.Items()...) // snapshot + first := append([]int(nil), c.Shuffle().Items()...) // snapshot // Shuffle again with the same deterministic RNG reset setShuffleRand(rand.New(rand.NewSource(42))) c2 := New([]int{1, 2, 3, 4, 5}) - c2.Shuffle() - second := c2.Items() + second := c2.Shuffle().Items() if !reflect.DeepEqual(first, second) { t.Fatalf("expected deterministic shuffle within same RNG, got %v vs %v", first, second) @@ -40,8 +38,7 @@ func TestShuffle_PreservesAllElements(t *testing.T) { orig := []int{1, 2, 3, 4, 5} c := New(orig) - c.Shuffle() - out := c.Items() + out := c.Shuffle().Items() if len(out) != len(orig) { t.Fatalf("expected length %d, got %d", len(orig), len(out)) @@ -62,20 +59,20 @@ func TestShuffle_PreservesAllElements(t *testing.T) { func TestShuffle_Empty(t *testing.T) { c := New([]int{}) - c.Shuffle() + out := c.Shuffle() - if len(c.Items()) != 0 { - t.Fatalf("expected empty slice, got %v", c.Items()) + if len(out.Items()) != 0 { + t.Fatalf("expected empty slice, got %v", out.Items()) } } func TestShuffle_SingleElement(t *testing.T) { c := New([]int{42}) - c.Shuffle() + out := c.Shuffle() expect := []int{42} - if !reflect.DeepEqual(c.Items(), expect) { - t.Fatalf("expected %v, got %v", expect, c.Items()) + if !reflect.DeepEqual(out.Items(), expect) { + t.Fatalf("expected %v, got %v", expect, out.Items()) } } @@ -91,10 +88,10 @@ func TestShuffle_Structs(t *testing.T) { {ID: 3}, }) - c.Shuffle() + out := c.Shuffle() ids := map[int]bool{} - for _, u := range c.Items() { + for _, u := range out.Items() { ids[u.ID] = true } @@ -111,10 +108,24 @@ func TestShuffle_MutatesInPlace(t *testing.T) { items := []int{1, 2, 3, 4} c := New(items) - c.Shuffle() + out := c.Shuffle() + + if out != c { + t.Fatalf("Shuffle should return the same collection") + } if reflect.DeepEqual(items, []int{1, 2, 3, 4}) { - t.Fatalf("expected underlying slice to be shuffled in place") + t.Fatalf("expected source slice to be shuffled in place") } }) } + +func TestShuffle_PreservesNilSlice(t *testing.T) { + c := New([]int(nil)) + + c.Shuffle() + + if c.Items() != nil { + t.Fatalf("expected nil slice to remain nil, got %v", c.Items()) + } +} diff --git a/skip.go b/skip.go index 6c5f4ea..15cfdd7 100644 --- a/skip.go +++ b/skip.go @@ -6,11 +6,14 @@ package collection // an empty collection. // @group Slicing // @behavior immutable -// @fluent true +// @chainable true +// @terminal false // // This operation performs no element allocations; it re-slices the // underlying slice. // +// NOTE: returns a view (shares backing array). Use Clone() to detach. +// // Example: integers // // c := collection.New([]int{1, 2, 3, 4, 5}) @@ -67,12 +70,12 @@ func (c *Collection[T]) Skip(n int) *Collection[T] { l := len(items) if n <= 0 { - return &Collection[T]{items: items} + return New(items) } if n >= l { - return &Collection[T]{items: items[:0]} + return New(items[:0]) } - return &Collection[T]{items: items[n:]} + return New(items[n:]) } diff --git a/skip_last.go b/skip_last.go index 250da56..cba2ca6 100644 --- a/skip_last.go +++ b/skip_last.go @@ -6,11 +6,14 @@ package collection // an empty collection. // @group Slicing // @behavior immutable -// @fluent true +// @chainable true +// @terminal false // // This operation performs no element allocations; it re-slices the // underlying slice. // +// NOTE: returns a view (shares backing array). Use Clone() to detach. +// // Example: integers // // c := collection.New([]int{1, 2, 3, 4, 5}) @@ -63,12 +66,12 @@ func (c *Collection[T]) SkipLast(n int) *Collection[T] { l := len(items) if n <= 0 { - return &Collection[T]{items: items} + return New(items) } if n >= l { - return &Collection[T]{items: items[:0]} + return New(items[:0]) } - return &Collection[T]{items: items[:l-n]} + return New(items[:l-n]) } diff --git a/sort.go b/sort.go index 3a81cc5..2a5d83c 100644 --- a/sort.go +++ b/sort.go @@ -6,7 +6,8 @@ import "sort" // returns the same collection for chaining. // @group Ordering // @behavior mutable -// @fluent true +// @chainable true +// @terminal false // // The comparison function `less(a, b)` should return true if `a` should come // before `b` in the sorted order. diff --git a/sort_test.go b/sort_test.go index a359a34..0ed2b00 100644 --- a/sort_test.go +++ b/sort_test.go @@ -111,3 +111,35 @@ func TestSort_SingleElement(t *testing.T) { t.Fatalf("expected %v, got %v", expected, sorted.items) } } + +func TestSort_PreservesNilSlice(t *testing.T) { + c := New([]int(nil)) + + c.Sort(func(a, b int) bool { return a < b }) + + if c.Items() != nil { + t.Fatalf("expected nil slice to remain nil, got %v", c.Items()) + } +} + +func TestSort_WritesThroughSourceSlice(t *testing.T) { + items := []int{3, 1, 2} + c := New(items) + + c.Sort(func(a, b int) bool { return a < b }) + + want := []int{1, 2, 3} + if !reflect.DeepEqual(items, want) { + t.Fatalf("expected source slice %v, got %v", want, items) + } +} + +func TestSort_LengthUnchanged(t *testing.T) { + c := New([]int{3, 1, 2}) + + c.Sort(func(a, b int) bool { return a < b }) + + if len(c.Items()) != 3 { + t.Fatalf("expected length 3, got %d", len(c.Items())) + } +} diff --git a/sum.go b/sum.go index ac8b70b..2205f02 100644 --- a/sum.go +++ b/sum.go @@ -4,7 +4,8 @@ package collection // If the collection is empty, Sum returns the zero value of T. // @group Aggregation // @behavior readonly -// @fluent false +// @chainable false +// @terminal true // // Example: integers // diff --git a/symmetric_difference.go b/symmetric_difference.go index 71e7ca3..58d159a 100644 --- a/symmetric_difference.go +++ b/symmetric_difference.go @@ -5,7 +5,8 @@ package collection // its unique items, then the second for its unique items. Duplicates are removed. // @group Set Operations // @behavior immutable -// @fluent true +// @chainable true +// @terminal false // // Example: integers // @@ -98,5 +99,5 @@ func SymmetricDifference[T comparable](a, b *Collection[T]) *Collection[T] { out = append(out, v) } - return &Collection[T]{items: out} + return New(out) } diff --git a/take.go b/take.go index 5ad4ecf..0d23d4e 100644 --- a/take.go +++ b/take.go @@ -2,13 +2,16 @@ package collection // Take returns a new collection containing the first `n` items when n > 0, // or the last `|n|` items when n < 0. -// @fluent true +// @chainable true +// @terminal false // // If n exceeds the collection length, the entire collection is returned. // If n == 0, an empty collection is returned. // // Mirrors Laravel's take() semantics. // +// NOTE: returns a view (shares backing array). Use Clone() to detach. +// // @group Slicing // @behavior immutable // Example: integers - take first 3 @@ -61,17 +64,17 @@ func (c *Collection[T]) Take(n int) *Collection[T] { if n > 0 { if n >= length { // no need to allocate; just reuse original - return &Collection[T]{items: c.items} + return New(c.items) } - return &Collection[T]{items: c.items[:n]} + return New(c.items[:n]) } // Negative → take from end n = -n if n >= length { - return &Collection[T]{items: c.items} + return New(c.items) } start := length - n - return &Collection[T]{items: c.items[start:]} + return New(c.items[start:]) } diff --git a/take_last.go b/take_last.go index 2ae8656..e24feb8 100644 --- a/take_last.go +++ b/take_last.go @@ -4,10 +4,13 @@ package collection // If n is less than or equal to zero, TakeLast returns an empty collection. // If n is greater than or equal to the collection length, TakeLast returns // the full collection. -// @fluent true +// @chainable true +// @terminal false // // This operation performs no element allocations; it re-slices the // underlying slice. +// +// NOTE: returns a view (shares backing array). Use Clone() to detach. // @group Slicing // @behavior immutable // Example: integers @@ -60,12 +63,12 @@ func (c *Collection[T]) TakeLast(n int) *Collection[T] { l := len(items) if n <= 0 { - return &Collection[T]{items: items[:0]} + return New(items[:0]) } if n >= l { - return &Collection[T]{items: items} + return New(items) } - return &Collection[T]{items: items[l-n:]} + return New(items[l-n:]) } diff --git a/take_until.go b/take_until.go index 684a610..285e185 100644 --- a/take_until.go +++ b/take_until.go @@ -4,7 +4,10 @@ package collection // The matching item is NOT included. // @group Slicing // @behavior immutable -// @fluent true +// @chainable true +// @terminal false +// +// NOTE: returns a view (shares backing array). Use Clone() to detach. // Example: integers - stop when value >= 3 // // c1 := collection.New([]int{1, 2, 3, 4}) @@ -34,25 +37,27 @@ package collection // // 2 => 3 #int // // ] func (c *Collection[T]) TakeUntilFn(pred func(T) bool) *Collection[T] { - out := make([]T, 0, len(c.items)) - - for _, v := range c.items { + idx := len(c.items) + for i, v := range c.items { if pred(v) { + idx = i break } - out = append(out, v) } - return New(out) + return New(c.items[:idx]) } // TakeUntil returns items until the first element equals `value`. // The matching item is NOT included. -// @fluent true +// @chainable true +// @terminal false // // Uses == comparison, so T must be comparable. // @group Slicing // @behavior immutable +// +// NOTE: returns a view (shares backing array). Use Clone() to detach. // Example: integers - stop at value 3 // // c4 := collection.New([]int{1, 2, 3, 4}) @@ -82,14 +87,13 @@ func (c *Collection[T]) TakeUntilFn(pred func(T) bool) *Collection[T] { // // #[]int [ // // ] func TakeUntil[T comparable](c *Collection[T], value T) *Collection[T] { - out := make([]T, 0, len(c.items)) - - for _, v := range c.items { + idx := len(c.items) + for i, v := range c.items { if v == value { + idx = i break } - out = append(out, v) } - return New(out) + return New(c.items[:idx]) } diff --git a/tap.go b/tap.go index 7800eaa..c168bad 100644 --- a/tap.go +++ b/tap.go @@ -4,7 +4,8 @@ package collection // inspection) and returns the same collection to allow chaining. // @group Transformation // @behavior immutable -// @fluent true +// @chainable true +// @terminal false // // Tap does NOT modify the collection itself; it simply exposes the current state // during a fluent chain. diff --git a/terminal_allocs_test.go b/terminal_allocs_test.go new file mode 100644 index 0000000..4703c9e --- /dev/null +++ b/terminal_allocs_test.go @@ -0,0 +1,117 @@ +package collection + +import "testing" + +var ( + allocCollection *Collection[int] + allocNumeric *NumericCollection[int] + + allocBool bool + allocInt int + allocValue int +) + +func allocPred(v int) bool { + return v%2 == 0 +} + +func allocReduce(acc, v int) int { + return acc + v +} + +func allocAny() { + allocBool = allocCollection.Any(allocPred) +} + +func allocAll() { + allocBool = allocCollection.All(allocPred) +} + +func allocNone() { + allocBool = allocCollection.None(allocPred) +} + +func allocFirst() { + var ok bool + allocValue, ok = allocCollection.First() + allocBool = ok +} + +func allocLast() { + var ok bool + allocValue, ok = allocCollection.Last() + allocBool = ok +} + +func allocFirstWhere() { + var ok bool + allocValue, ok = allocCollection.FirstWhere(allocPred) + allocBool = ok +} + +func allocIndexWhere() { + var ok bool + allocInt, ok = allocCollection.IndexWhere(allocPred) + allocBool = ok +} + +func allocReduceSum() { + allocInt = allocCollection.Reduce(0, allocReduce) +} + +func allocSum() { + allocInt = allocNumeric.Sum() +} + +func allocMin() { + var ok bool + allocInt, ok = allocNumeric.Min() + allocBool = ok +} + +func allocMax() { + var ok bool + allocInt, ok = allocNumeric.Max() + allocBool = ok +} + +func allocContains() { + allocBool = Contains(allocCollection, 4) +} + +func allocIsEmpty() { + allocBool = allocCollection.IsEmpty() +} + +func TestTerminalOps_ZeroAlloc(t *testing.T) { + allocCollection = New([]int{1, 2, 3, 4, 5, 6}) + allocNumeric = NewNumeric([]int{1, 2, 3, 4, 5, 6}) + + cases := []struct { + name string + fn func() + }{ + {name: "Any", fn: allocAny}, + {name: "All", fn: allocAll}, + {name: "None", fn: allocNone}, + {name: "First", fn: allocFirst}, + {name: "Last", fn: allocLast}, + {name: "FirstWhere", fn: allocFirstWhere}, + {name: "IndexWhere", fn: allocIndexWhere}, + {name: "Reduce", fn: allocReduceSum}, + {name: "Sum", fn: allocSum}, + {name: "Min", fn: allocMin}, + {name: "Max", fn: allocMax}, + {name: "Contains", fn: allocContains}, + {name: "IsEmpty", fn: allocIsEmpty}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + allocs := testing.AllocsPerRun(1000, tc.fn) + if allocs != 0 { + t.Fatalf("%s should allocate 0 times; got %v", tc.name, allocs) + } + }) + } +} diff --git a/times.go b/times.go index e1f958b..253791e 100644 --- a/times.go +++ b/times.go @@ -4,7 +4,8 @@ package collection // This mirrors Laravel's Collection::times(), which is 1-indexed. // @group Transformation // @behavior immutable -// @fluent true +// @chainable true +// @terminal false // // If count <= 0, an empty collection is returned. // diff --git a/to_json.go b/to_json.go index bc899bb..c51650c 100644 --- a/to_json.go +++ b/to_json.go @@ -8,7 +8,8 @@ import ( // ToJSON converts the collection's items into a compact JSON string. // @group Serialization // @behavior readonly -// @fluent true +// @chainable false +// @terminal true // // If marshalling succeeds, a JSON-encoded string and a nil error are returned. // If marshalling fails, the method unwraps any json.Marshal wrapping so that @@ -36,7 +37,8 @@ func (c *Collection[T]) ToJSON() (string, error) { // indented JSON string. // @group Serialization // @behavior readonly -// @fluent true +// @chainable false +// @terminal true // // If marshalling succeeds, a formatted JSON string and nil error are returned. // If marshalling fails, the underlying error is unwrapped so user-defined diff --git a/to_map.go b/to_map.go index cfdde11..f197ab9 100644 --- a/to_map.go +++ b/to_map.go @@ -4,7 +4,8 @@ package collection // selector functions. // @group Maps // @behavior readonly -// @fluent false +// @chainable false +// @terminal true // // If multiple items produce the same key, the last value wins. // diff --git a/to_map_kv.go b/to_map_kv.go index 23c138a..0925a85 100644 --- a/to_map_kv.go +++ b/to_map_kv.go @@ -3,7 +3,8 @@ package collection // ToMapKV converts a collection of key/value pairs into a map. // @group Maps // @behavior readonly -// @fluent false +// @chainable false +// @terminal true // // If multiple pairs contain the same key, the last value wins. // diff --git a/transform.go b/transform.go index 6a1b788..d35cd50 100644 --- a/transform.go +++ b/transform.go @@ -3,7 +3,8 @@ package collection // Transform applies fn to every item *in place*, mutating the collection. // @group Transformation // @behavior mutable -// @fluent true +// @chainable false +// @terminal true // // This mirrors Laravel's transform(), which modifies the underlying values // instead of returning a new collection. diff --git a/transform_test.go b/transform_test.go index 11614d5..4ced533 100644 --- a/transform_test.go +++ b/transform_test.go @@ -92,3 +92,35 @@ func TestTransform_ChainingCompatibility(t *testing.T) { t.Fatalf("expected %v, got %v", expected, out.items) } } + +func TestTransform_PreservesNilSlice(t *testing.T) { + c := New([]int(nil)) + + c.Transform(func(v int) int { return v + 1 }) + + if c.Items() != nil { + t.Fatalf("expected nil slice to remain nil, got %v", c.Items()) + } +} + +func TestTransform_WritesThroughSourceSlice(t *testing.T) { + items := []int{1, 2, 3} + c := New(items) + + c.Transform(func(v int) int { return v + 10 }) + + want := []int{11, 12, 13} + if !reflect.DeepEqual(items, want) { + t.Fatalf("expected source slice %v, got %v", want, items) + } +} + +func TestTransform_LengthUnchanged(t *testing.T) { + c := New([]int{1, 2, 3}) + + c.Transform(func(v int) int { return v + 1 }) + + if len(c.Items()) != 3 { + t.Fatalf("expected length 3, got %d", len(c.Items())) + } +} diff --git a/union.go b/union.go index 13458bf..4e6a482 100644 --- a/union.go +++ b/union.go @@ -5,7 +5,8 @@ package collection // that were not already present. // @group Set Operations // @behavior immutable -// @fluent true +// @chainable true +// @terminal false // // Example: integers // @@ -88,5 +89,5 @@ func Union[T comparable](a, b *Collection[T]) *Collection[T] { out = append(out, v) } - return &Collection[T]{items: out} + return New(out) } diff --git a/unique.go b/unique.go index 127bde5..45587a0 100644 --- a/unique.go +++ b/unique.go @@ -5,7 +5,8 @@ package collection // and order is preserved. // @group Set Operations // @behavior immutable -// @fluent true +// @chainable true +// @terminal false // // The `eq` function should return true when two values are considered equal. // @@ -72,5 +73,5 @@ func (c *Collection[T]) Unique(eq func(a, b T) bool) *Collection[T] { } } - return &Collection[T]{items: out} + return New(out) } diff --git a/unique_by.go b/unique_by.go index 715076a..8310b56 100644 --- a/unique_by.go +++ b/unique_by.go @@ -4,7 +4,8 @@ package collection // of each element as determined by keyFn. // @group Set Operations // @behavior immutable -// @fluent true +// @chainable true +// @terminal false // // The key returned by keyFn must be comparable. // Order is preserved. @@ -73,5 +74,5 @@ func UniqueBy[T any, K comparable](c *Collection[T], keyFn func(T) K) *Collectio out = append(out, v) } - return &Collection[T]{items: out} + return New(out) } diff --git a/unique_comparable.go b/unique_comparable.go index fb126b0..00b4ee5 100644 --- a/unique_comparable.go +++ b/unique_comparable.go @@ -5,7 +5,8 @@ package collection // This is a faster, allocation-friendly path for comparable types. // @group Set Operations // @behavior immutable -// @fluent true +// @chainable true +// @terminal false // // Example: integers // @@ -48,5 +49,5 @@ func UniqueComparable[T comparable](c *Collection[T]) *Collection[T] { out = append(out, v) } - return &Collection[T]{items: out} + return New(out) } diff --git a/where.go b/where.go deleted file mode 100644 index 5a4e0ab..0000000 --- a/where.go +++ /dev/null @@ -1,52 +0,0 @@ -package collection - -// Where keeps only the elements for which fn returns true. -// This is an alias for Filter(fn) for SQL-style ergonomics. -// This method mutates the collection in place and returns the same instance. -// @group Slicing -// @behavior mutable -// @fluent true -// -// Example: integers -// -// nums := collection.New([]int{1, 2, 3, 4}) -// nums.Where(func(v int) bool { -// return v%2 == 0 -// }) -// collection.Dump(nums.Items()) -// // #[]int [ -// // 0 => 2 #int -// // 1 => 4 #int -// // ] -// -// Example: structs -// -// type User struct { -// ID int -// Name string -// } -// -// users := collection.New([]User{ -// {ID: 1, Name: "Alice"}, -// {ID: 2, Name: "Bob"}, -// {ID: 3, Name: "Carol"}, -// }) -// -// users.Where(func(u User) bool { -// return u.ID >= 2 -// }) -// -// collection.Dump(users.Items()) -// // #[]main.User [ -// // 0 => #main.User { -// // +ID => 2 #int -// // +Name => "Bob" #string -// // } -// // 1 => #main.User { -// // +ID => 3 #int -// // +Name => "Carol" #string -// // } -// // ] -func (c *Collection[T]) Where(fn func(T) bool) *Collection[T] { - return c.Filter(fn) -} diff --git a/where_test.go b/where_test.go deleted file mode 100644 index 1b10199..0000000 --- a/where_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package collection - -import ( - "reflect" - "testing" -) - -func TestWhere_AliasToFilter(t *testing.T) { - fn := func(v int) bool { return v%2 == 0 } - - c1 := New([]int{1, 2, 3, 4, 5}) - c2 := New([]int{1, 2, 3, 4, 5}) - - out1 := c1.Filter(fn) - out2 := c2.Where(fn) - - if out2 != c2 { - t.Fatalf("Where should return the same collection instance") - } - - if !reflect.DeepEqual(out1.items, out2.items) { - t.Fatalf("Where should behave exactly the same as Filter(fn)") - } -} diff --git a/window.go b/window.go index 86bfb8d..aaf8c07 100644 --- a/window.go +++ b/window.go @@ -5,7 +5,10 @@ package collection // Windows that are shorter than size are omitted. // @group Slicing // @behavior allocates -// @fluent true +// @chainable true +// @terminal false +// +// NOTE: windows share the backing array with the source collection. // // Example: integers - step 1 // @@ -96,7 +99,7 @@ package collection // // ] func Window[T any](c *Collection[T], size int, step int) *Collection[[]T] { if size <= 0 { - return &Collection[[]T]{items: nil} + return New([][]T(nil)) } if step <= 0 { @@ -105,7 +108,7 @@ func Window[T any](c *Collection[T], size int, step int) *Collection[[]T] { n := len(c.items) if n < size { - return &Collection[[]T]{items: nil} + return New([][]T(nil)) } // Compute number of windows. @@ -113,10 +116,8 @@ func Window[T any](c *Collection[T], size int, step int) *Collection[[]T] { out := make([][]T, 0, count) for i := 0; i+size <= n; i += step { - window := make([]T, size) - copy(window, c.items[i:i+size]) - out = append(out, window) + out = append(out, c.items[i:i+size]) } - return &Collection[[]T]{items: out} + return New(out) } diff --git a/zip.go b/zip.go index d81253d..d97a94a 100644 --- a/zip.go +++ b/zip.go @@ -4,7 +4,8 @@ package collection // The resulting length is the smaller of the two inputs. // @group Transformation // @behavior immutable -// @fluent true +// @chainable true +// @terminal false // // Example: integers and strings // @@ -74,7 +75,8 @@ func Zip[A any, B any](a *Collection[A], b *Collection[B]) *Collection[Tuple[A, // The resulting length is the smaller of the two inputs. // @group Transformation // @behavior immutable -// @fluent true +// @chainable true +// @terminal false // // Example: sum ints //