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 @@
-
+
- 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 @@
-
+
- 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
//