diff --git a/README.md b/README.md index 2ef8d73..3f2e8a6 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Write **dramatically** cleaner, more expressive Lua code: - **Haskell-inspired syntax** - Write `f * g % x` instead of `f(g(x))` - **Zero dependencies** - Pure Lua implementation with no external dependencies - **Excellent performance** - In LuaJIT environments (like Neovim), pre-composed functions have **virtually no overhead** compared to pure Lua - - See [Performance Benchmarks](./doc/examples.md#-performance-considerations) for detailed results + - See [Performance Benchmarks](./doc/examples.md#performance-considerations) for detailed results > [!NOTE] > **About the name:** @@ -216,6 +216,16 @@ For practical examples and use cases, see **[./doc/examples.md](./doc/examples.m - `let(x, y) % arrow(f)` -- Start an arrow pipeline with multiple values - `fun(f) % let(x, y)` -- Start a fun pipeline with multiple values +**Quick reference for `luarrow.utils.list`:** +- `list.map(f)` -- Apply `f` to each element +- `list.filter(pred)` -- Keep elements satisfying `pred` +- `list.foldl(f, init)` -- Left fold with initial value +- `list.find(pred)` -- First element satisfying `pred` +- `list.sort_by(key)` -- Sort by key function +- `list.sort_with(cmp)` -- Sort with comparator function +- `list.group_by(f)` -- Group elements by key function +- ...and [many more](./doc/api.md#luarrowutilslist-api-reference) + ## 🔄 Comparison Haskell-Style with Real Haskell | Haskell | luarrow | Pure Lua | @@ -272,40 +282,18 @@ local _ = 5 ^ arrow(print) -- 25 ``` -### List Processing (`fun`) +### List Processing (`luarrow.utils.list`) ```lua -local fun = require('luarrow').fun - -local map = function(f) - return function(list) - local result = {} - for i, v in ipairs(list) do - result[i] = f(v) - end - return result - end -end - -local filter = function(predicate) - return function(list) - local result = {} - for _, v in ipairs(list) do - if predicate(v) then - table.insert(result, v) - end - end - return result - end -end - -local numbers = {1, 2, 3, 4, 5, 6} - -local is_even = function(x) return x % 2 == 0 end -local double = function(x) return x * 2 end - -local result = fun(map(double)) * fun(filter(is_even)) % numbers -print(result) -- { 4, 8, 12 } +local arrow = require('luarrow').arrow +local list = require('luarrow.utils.list') + +-- Curried list functions compose directly with arrow! +local _ = { 1, 2, 3 } + % arrow(list.map(function(x) return x + 10 end)) -- { 11, 12, 13 } + ^ arrow(list.filter(function(x) return x % 2 ~= 0 end)) -- { 11, 13 } + ^ arrow(list.find(function(x) return x > 10 end)) -- 11 + ^ arrow(print) ``` ### Multi-Value Composition (`arrow` and `fun`) diff --git a/doc/api.md b/doc/api.md index 25f3be6..a8ff7cd 100644 --- a/doc/api.md +++ b/doc/api.md @@ -22,6 +22,13 @@ For practical examples and use cases, see [examples.md](examples.md). - [Let class](#let-class) - [let(...)](#let) - [let(...) % arrow\_or\_fun (Multi-Value Pipeline Entry)](#let--arrow_or_fun-multi-value-pipeline-entry) +4. [luarrow.utils.list API Reference](#luarrowutilslist-api-reference) + - [Basic Operations](#basic-operations) + - [Fold Operations](#fold-operations) + - [Aggregation](#aggregation) + - [Inspection](#inspection) + - [Transformation](#transformation) + - [Search](#search) ## ⛲ `Fun` API Reference @@ -613,3 +620,507 @@ print(result) -- "500" > [!NOTE] > `let(x, y, ...) % f` is equivalent to `f:apply(x, y, ...)`. > It provides cleaner syntax for multi-value pipeline entry points. + +## 📋 `luarrow.utils.list` API Reference + +The `luarrow.utils.list` module provides functional list manipulation utilities. All functions that accept additional arguments are **curried** — they return a `fun(xs: A[])` — so they compose directly with `arrow`: + +```lua +local arrow = require('luarrow').arrow +local list = require('luarrow.utils.list') + +local _ = { 1, 2, 3 } + % arrow(list.map(function(x) return x + 10 end)) + ^ arrow(list.filter(function(x) return x % 2 ~= 0 end)) + ^ arrow(list.find(function(x) return x > 10 end)) + ^ arrow(print) -- 11 +``` + +### Basic Operations + +#### `map(f)` + +Apply a function to each element, producing a new list. + +```lua +local doubled = list.map(function(x) return x * 2 end)({ 1, 2, 3 }) +-- { 2, 4, 6 } + +-- With arrow: +local _ = { 1, 2, 3 } % arrow(list.map(function(x) return x * 2 end)) +``` + +**Parameters:** +- `f: fun(x: A): B` - Transformation function + +**Returns:** +- `fun(xs: A[]): B[]` + +--- + +#### `filter(pred)` + +Keep elements that satisfy a predicate. + +```lua +local evens = list.filter(function(x) return x % 2 == 0 end)({ 1, 2, 3, 4, 5 }) +-- { 2, 4 } +``` + +**Parameters:** +- `pred: fun(x: A): boolean` - Predicate function + +**Returns:** +- `fun(xs: A[]): A[]` + +--- + +#### `flat_map(f)` / `concat_map(f)` + +Map then flatten one level. `concat_map` is an alias. + +```lua +local result = list.flat_map(function(x) return { x, x * 2 } end)({ 1, 2, 3 }) +-- { 1, 2, 2, 4, 3, 6 } +``` + +**Parameters:** +- `f: fun(x: A): B[]` - Function returning a list + +**Returns:** +- `fun(xs: A[]): B[]` + +--- + +#### `flatten(list)` + +Flatten one level of nesting. + +```lua +local result = list.flatten({ { 1, 2 }, { 3, 4 }, { 5 } }) +-- { 1, 2, 3, 4, 5 } +``` + +**Parameters:** +- `list: A[][]` - Nested list + +**Returns:** +- `A[]` + +--- + +#### `find(pred)` + +Return the first element satisfying the predicate, or `nil`. + +```lua +local first = list.find(function(x) return x > 3 end)({ 1, 2, 3, 4, 5 }) +-- 4 +``` + +**Parameters:** +- `pred: fun(x: A): boolean` - Predicate function + +**Returns:** +- `fun(xs: A[]): A | nil` + +--- + +### Fold Operations + +#### `foldl(f, init)` / `reduce(f, init)` + +Left fold with an initial accumulator. `reduce` is an alias. + +```lua +local sum = list.foldl(function(acc, x) return acc + x end, 0)({ 1, 2, 3, 4 }) +-- 10 +``` + +**Parameters:** +- `f: fun(acc: B, x: A): B` - Fold function +- `init: B` - Initial accumulator + +**Returns:** +- `fun(xs: A[]): B` + +--- + +#### `foldr(f, init)` + +Right fold with an initial accumulator. + +```lua +local result = list.foldr(function(x, acc) return x .. acc end, 'd')({ 'a', 'b', 'c' }) +-- 'abcd' +``` + +**Parameters:** +- `f: fun(x: A, acc: B): B` - Fold function +- `init: B` - Initial accumulator + +**Returns:** +- `fun(xs: A[]): B` + +--- + +#### `foldl1(f)` + +Left fold without initial value (errors on empty list). + +```lua +local product = list.foldl1(function(acc, x) return acc * x end)({ 2, 3, 4 }) +-- 24 +``` + +**Parameters:** +- `f: fun(acc: A, x: A): A` - Fold function + +**Returns:** +- `fun(xs: A[]): A` + +--- + +#### `foldr1(f)` + +Right fold without initial value (errors on empty list). + +```lua +local result = list.foldr1(function(x, acc) return x .. acc end)({ 'a', 'b', 'c' }) +-- 'abc' +``` + +**Parameters:** +- `f: fun(x: A, acc: A): A` - Fold function + +**Returns:** +- `fun(xs: A[]): A` + +--- + +### Aggregation + +#### `join(sep)` + +Join a list of strings with a delimiter. + +```lua +local result = list.join(', ')({ 'a', 'b', 'c' }) +-- 'a, b, c' +``` + +**Parameters:** +- `sep: string` - Delimiter string + +**Returns:** +- `fun(xs: string[]): string` + +--- + +#### `sum(list)` + +Sum all numeric elements. + +```lua +list.sum({ 1, 2, 3, 4, 5 }) -- 15 +list.sum({}) -- 0 +``` + +**Parameters:** +- `list: number[]` + +**Returns:** +- `number` + +--- + +#### `product(list)` + +Multiply all numeric elements. + +```lua +list.product({ 2, 3, 4 }) -- 24 +list.product({}) -- 1 +``` + +**Parameters:** +- `list: number[]` + +**Returns:** +- `number` + +--- + +### Inspection + +#### `length(list)` + +Return the number of elements. + +```lua +list.length({ 1, 2, 3 }) -- 3 +``` + +**Parameters:** +- `list: A[]` + +**Returns:** +- `integer` + +--- + +#### `is_empty(list)` + +Return `true` if the list has no elements. + +```lua +list.is_empty({}) -- true +list.is_empty({ 1 }) -- false +``` + +**Parameters:** +- `list: A[]` + +**Returns:** +- `boolean` + +--- + +#### `head(list)` + +Return the first element, or `nil` for an empty list. + +```lua +list.head({ 1, 2, 3 }) -- 1 +list.head({}) -- nil +``` + +**Parameters:** +- `list: A[]` + +**Returns:** +- `A | nil` + +--- + +#### `tail(list)` + +Return all elements except the first. + +```lua +list.tail({ 1, 2, 3, 4 }) -- { 2, 3, 4 } +list.tail({}) -- {} +``` + +**Parameters:** +- `list: A[]` + +**Returns:** +- `A[]` + +--- + +#### `last(list)` + +Return the last element, or `nil` for an empty list. + +```lua +list.last({ 1, 2, 3 }) -- 3 +list.last({}) -- nil +``` + +**Parameters:** +- `list: A[]` + +**Returns:** +- `A | nil` + +--- + +#### `init(list)` + +Return all elements except the last. + +```lua +list.init({ 1, 2, 3, 4 }) -- { 1, 2, 3 } +``` + +**Parameters:** +- `list: A[]` + +**Returns:** +- `A[]` + +--- + +### Transformation + +#### `reverse(list)` + +Return a reversed copy of the list. + +```lua +list.reverse({ 1, 2, 3 }) -- { 3, 2, 1 } +``` + +**Parameters:** +- `list: A[]` + +**Returns:** +- `A[]` + +--- + +#### `sort(list)` + +Return a sorted copy using the default comparator. + +```lua +list.sort({ 3, 1, 4, 1, 5 }) -- { 1, 1, 3, 4, 5 } +``` + +**Parameters:** +- `list: A[]` + +**Returns:** +- `A[]` + +--- + +#### `sort_by(key)` + +Sort by a derived key value. The key function is applied to each element exactly once (Schwartzian transform). Keys must be comparable with `<` (numbers or strings). + +```lua +local items = { { name = 'b', v = 2 }, { name = 'a', v = 1 } } +local result = list.sort_by(function(x) return x.v end)(items) +-- { { name='a', v=1 }, { name='b', v=2 } } + +-- Sort strings by length +list.sort_by(function(s) return #s end)({ 'banana', 'fig', 'apple' }) +-- { 'fig', 'apple', 'banana' } +``` + +> [!NOTE] +> To sort by a boolean field, convert it to a number: `sort_by(function(x) return x.active and 1 or 0 end)`. + +**Parameters:** +- `key: fun(x: A): K` - Key function. Must not return `nil`; raises `'sort_by: key function returned nil (nil keys are not supported)'` if it does. + +**Returns:** +- `fun(xs: A[]): A[]` + +--- + +#### `sort_with(cmp)` + +Sort using a custom comparator function. The comparator receives two elements and must return `true` when the first should precede the second (same contract as `table.sort`). + +```lua +-- Descending order +local result = list.sort_with(function(a, b) return a > b end)({ 3, 1, 2 }) +-- { 3, 2, 1 } + +-- Sort structs by multiple fields +list.sort_with(function(a, b) + if a.score ~= b.score then return a.score > b.score end + return a.name < b.name +end)(players) +``` + +**Parameters:** +- `cmp: fun(a: A, b: A): boolean` + +**Returns:** +- `fun(xs: A[]): A[]` + +--- + +#### `unique(list)` + +Remove duplicates, keeping the first occurrence of each value. Equality uses Lua's `==` operator: primitives (numbers, strings, booleans) are compared by value; tables and functions are compared by **reference**. Use `unique_by` to deduplicate tables by a derived key. + +```lua +list.unique({ 1, 2, 2, 3, 1, 4 }) -- { 1, 2, 3, 4 } +list.unique({ 'a', 'b', 'a' }) -- { 'a', 'b' } +``` + +**Parameters:** +- `list: A[]` + +**Returns:** +- `A[]` + +--- + +#### `unique_by(f)` + +Remove duplicates by a derived key, keeping the first occurrence whose key was not yet seen. The key returned by `f` is compared with `==` (use primitives — numbers, strings — as keys for reliable equality). + +```lua +local t1 = { name = 'Alice', score = 10 } +local t2 = { name = 'Alice', score = 20 } +local t3 = { name = 'Bob', score = 30 } + +-- Deduplicate by the 'name' field +list.unique_by(function(x) return x.name end)({ t1, t2, t3 }) +-- { t1, t3 } (t2 dropped — 'Alice' already seen) +``` + +**Parameters:** +- `f: fun(x: A): K` - Key function. Must not return `nil`; raises `'unique_by: key function returned nil (nil keys are not supported)'` if it does. + +**Returns:** +- `fun(xs: A[]): A[]` + +--- + +#### `group_by(f)` + +Group elements by the value returned by `f`. + +```lua +local groups = list.group_by(function(x) return x % 2 end)({ 1, 2, 3, 4, 5, 6 }) +-- groups[0] = { 2, 4, 6 } (even) +-- groups[1] = { 1, 3, 5 } (odd) +``` + +**Parameters:** +- `f: fun(x: A): K` - Key function. Must not return `nil`; raises `'group_by: key function returned nil for element at index N'` if it does. + +**Returns:** +- `fun(xs: A[]): table` + +--- + +### Search + +#### `maximum(list)` + +Return the largest element (errors on empty list). + +```lua +list.maximum({ 3, 1, 4, 1, 5 }) -- 5 +``` + +**Parameters:** +- `list: number[]` + +**Returns:** +- `number` + +--- + +#### `minimum(list)` + +Return the smallest element (errors on empty list). + +```lua +list.minimum({ 3, 1, 4, 1, 5 }) -- 1 +``` + +**Parameters:** +- `list: number[]` + +**Returns:** +- `number` diff --git a/doc/examples.md b/doc/examples.md index 8489181..eaee437 100644 --- a/doc/examples.md +++ b/doc/examples.md @@ -19,7 +19,7 @@ For API reference, see [api.md](api.md). 1. [Comparison: Fun vs Arrow](#comparison-fun-vs-arrow) 1. [Real-World Examples](#real-world-examples) - [Pipeline-Style (`Arrow`) Real-World Examples](#pipeline-style-arrow-real-world-examples) - - [List Transformations](#list-transformations) + - [List Processing with `luarrow.utils.list`](#list-processing-with-luarrowutilslist) - [Configuration Processing](#configuration-processing) - [Haskell-Style (`Fun`) Real-World Examples](#haskell-style-fun-real-world-examples) - [Data Validation Pipeline](#data-validation-pipeline) @@ -253,49 +253,37 @@ Both produce the same result, but the syntax reflects different mental models: ### Pipeline-Style (`Arrow`) Real-World Examples -#### List Transformations +#### List Processing with `luarrow.utils.list` + +`luarrow.utils.list` provides curried list functions that compose directly with `arrow`, so no wrapper functions are needed. ```lua local arrow = require('luarrow').arrow +local list = require('luarrow.utils.list') --- Higher-order functions for lists -local filter = function(predicate) - return function(list) - local result = {} - for _, v in ipairs(list) do - if predicate(v) then - table.insert(result, v) - end - end - return result - end -end +-- The curried functions plug straight into arrow() +local _ = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 } + % arrow(list.filter(function(x) return x % 2 == 0 end)) -- evens: { 2, 4, 6, 8, 10 } + ^ arrow(list.map(function(x) return x * 2 end)) -- doubled: { 4, 8, 12, 16, 20 } + ^ arrow(list.foldl(function(a, b) return a + b end, 0)) -- sum: 60 + ^ arrow(print) -- 60 +``` -local map = function(f) - return function(list) - local result = {} - for i, v in ipairs(list) do - result[i] = f(v) - end - return result - end -end +More list utilities: -local reduce = function(initial, f) - return function(list) - local acc = initial - for _, v in ipairs(list) do - acc = f(acc, v) - end - return acc - end -end +```lua +local arrow = require('luarrow').arrow +local list = require('luarrow.utils.list') -local _ = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 } - % arrow(filter(function(x) return x % 2 == 0 end)) -- filter evens: {2, 4, 6, 8, 10} - ^ arrow(map(function(x) return x * 2 end)) -- double each: {4, 8, 12, 16, 20} - ^ arrow(reduce(0, function(a, b) return a + b end)) -- sum: 60 - ^ arrow(print) -- 60 +-- Sort, group, and report +local numbers = { 5, 3, 8, 1, 9, 2, 7, 4, 6 } + +local _ = numbers + % arrow(list.sort) -- { 1, 2, 3, 4, 5, 6, 7, 8, 9 } + ^ arrow(list.reverse) -- { 9, 8, 7, 6, 5, 4, 3, 2, 1 } + ^ arrow(list.unique) -- same (no duplicates) + ^ arrow(list.join(', ')) -- '9, 8, 7, 6, 5, 4, 3, 2, 1' + ^ arrow(print) ``` ### Configuration Processing diff --git a/luarrow-main-8.rockspec b/luarrow-main-8.rockspec index 693c18a..8733011 100644 --- a/luarrow-main-8.rockspec +++ b/luarrow-main-8.rockspec @@ -28,6 +28,9 @@ build = { luarrow = 'src/luarrow.lua', ['luarrow.fun'] = 'src/luarrow/fun.lua', ['luarrow.arrow'] = 'src/luarrow/arrow.lua', + ['luarrow.let'] = 'src/luarrow/let.lua', + ['luarrow.utils'] = 'src/luarrow/utils.lua', + ['luarrow.utils.list'] = 'src/luarrow/utils/list.lua', }, copy_directories = { 'doc', diff --git a/spec/luarrow/utils/list_spec.lua b/spec/luarrow/utils/list_spec.lua new file mode 100644 index 0000000..47bd5b5 --- /dev/null +++ b/spec/luarrow/utils/list_spec.lua @@ -0,0 +1,478 @@ +local list = require('luarrow.utils.list') +local arrow = require('luarrow.arrow').arrow + +describe('luarrow.utils.list', function() + describe('map', function() + it('should apply function to each element', function() + local result = list.map(function(x) + return x * 2 + end)({ 1, 2, 3 }) + assert.are.same({ 2, 4, 6 }, result) + end) + + it('should work with empty list', function() + local result = list.map(function(x) + return x * 2 + end)({}) + assert.are.same({}, result) + end) + + it('should work with arrow', function() + local result = { 1, 2, 3 } % arrow(list.map(function(x) + return x + 10 + end)) + assert.are.same({ 11, 12, 13 }, result) + end) + end) + + describe('filter', function() + it('should keep elements that satisfy predicate', function() + local result = list.filter(function(x) + return x % 2 == 0 + end)({ 1, 2, 3, 4, 5 }) + assert.are.same({ 2, 4 }, result) + end) + + it('should work with empty list', function() + local result = list.filter(function(x) + return x % 2 == 0 + end)({}) + assert.are.same({}, result) + end) + + it('should work with arrow', function() + local result = { 11, 12, 13 } % arrow(list.filter(function(x) + return x % 2 ~= 0 + end)) + assert.are.same({ 11, 13 }, result) + end) + end) + + describe('flat_map / concat_map', function() + it('should map and flatten one level', function() + local result = list.flat_map(function(x) + return { x, x * 2 } + end)({ 1, 2, 3 }) + assert.are.same({ 1, 2, 2, 4, 3, 6 }, result) + end) + + it('concat_map should be alias for flat_map', function() + local result = list.concat_map(function(x) + return { x, x + 1 } + end)({ 1, 2 }) + assert.are.same({ 1, 2, 2, 3 }, result) + end) + end) + + describe('foldl / reduce', function() + it('should fold left with initial value', function() + local result = list.foldl(function(acc, x) + return acc + x + end, 0)({ 1, 2, 3, 4 }) + assert.are.equal(10, result) + end) + + it('should work with strings', function() + local result = list.foldl(function(acc, x) + return acc .. x + end, '')({ 'a', 'b', 'c' }) + assert.are.equal('abc', result) + end) + + it('reduce should be alias for foldl', function() + local result = list.reduce(function(acc, x) + return acc * x + end, 1)({ 1, 2, 3 }) + assert.are.equal(6, result) + end) + end) + + describe('foldr', function() + it('should fold right with initial value', function() + local result = list.foldr(function(x, acc) + return x .. acc + end, 'd')({ 'a', 'b', 'c' }) + assert.are.equal('abcd', result) + end) + + it('should work with numbers', function() + local result = list.foldr(function(x, acc) + return x - acc + end, 0)({ 1, 2, 3 }) + -- 1 - (2 - (3 - 0)) = 1 - (2 - 3) = 1 - (-1) = 2 + assert.are.equal(2, result) + end) + end) + + describe('foldl1', function() + it('should fold left without initial value', function() + local result = list.foldl1(function(acc, x) + return acc + x + end)({ 1, 2, 3, 4 }) + assert.are.equal(10, result) + end) + + it('should throw error on empty list', function() + assert.has_error(function() + list.foldl1(function(acc, x) + return acc + x + end)({}) + end) + end) + end) + + describe('foldr1', function() + it('should fold right without initial value', function() + local result = list.foldr1(function(x, acc) + return x .. acc + end)({ 'a', 'b', 'c' }) + assert.are.equal('abc', result) + end) + + it('should throw error on empty list', function() + assert.has_error(function() + list.foldr1(function(x, acc) + return x .. acc + end)({}) + end) + end) + end) + + describe('flatten', function() + it('should flatten one level of nesting', function() + local result = list.flatten({ { 1, 2 }, { 3, 4 }, { 5 } }) + assert.are.same({ 1, 2, 3, 4, 5 }, result) + end) + + it('should work with empty sublists', function() + local result = list.flatten({ { 1 }, {}, { 2 } }) + assert.are.same({ 1, 2 }, result) + end) + end) + + describe('join', function() + it('should join strings with delimiter', function() + local result = list.join(', ')({ 'a', 'b', 'c' }) + assert.are.equal('a, b, c', result) + end) + + it('should work with empty string delimiter', function() + local result = list.join('')({ 'hello', 'world' }) + assert.are.equal('helloworld', result) + end) + end) + + describe('sum', function() + it('should sum numeric elements', function() + local result = list.sum({ 1, 2, 3, 4, 5 }) + assert.are.equal(15, result) + end) + + it('should return 0 for empty list', function() + local result = list.sum({}) + assert.are.equal(0, result) + end) + end) + + describe('product', function() + it('should multiply numeric elements', function() + local result = list.product({ 2, 3, 4 }) + assert.are.equal(24, result) + end) + + it('should return 1 for empty list', function() + local result = list.product({}) + assert.are.equal(1, result) + end) + end) + + describe('length', function() + it('should return number of elements', function() + assert.are.equal(3, list.length({ 1, 2, 3 })) + assert.are.equal(0, list.length({})) + end) + end) + + describe('is_empty', function() + it('should return true for empty list', function() + assert.is_true(list.is_empty({})) + end) + + it('should return false for non-empty list', function() + assert.is_false(list.is_empty({ 1 })) + end) + end) + + describe('head', function() + it('should return first element', function() + assert.are.equal(1, list.head({ 1, 2, 3 })) + end) + + it('should return nil for empty list', function() + assert.is_nil(list.head({})) + end) + end) + + describe('tail', function() + it('should return list without first element', function() + local result = list.tail({ 1, 2, 3, 4 }) + assert.are.same({ 2, 3, 4 }, result) + end) + + it('should return empty list when given single element', function() + local result = list.tail({ 1 }) + assert.are.same({}, result) + end) + + it('should return empty list when given empty list', function() + local result = list.tail({}) + assert.are.same({}, result) + end) + end) + + describe('last', function() + it('should return last element', function() + assert.are.equal(3, list.last({ 1, 2, 3 })) + end) + + it('should return nil for empty list', function() + assert.is_nil(list.last({})) + end) + end) + + describe('init', function() + it('should return all elements except last', function() + local result = list.init({ 1, 2, 3, 4 }) + assert.are.same({ 1, 2, 3 }, result) + end) + + it('should return empty list when given single element', function() + local result = list.init({ 1 }) + assert.are.same({}, result) + end) + end) + + describe('reverse', function() + it('should reverse the list', function() + local result = list.reverse({ 1, 2, 3, 4 }) + assert.are.same({ 4, 3, 2, 1 }, result) + end) + + it('should work with empty list', function() + local result = list.reverse({}) + assert.are.same({}, result) + end) + end) + + describe('maximum', function() + it('should return largest element', function() + assert.are.equal(5, list.maximum({ 3, 1, 5, 2, 4 })) + end) + + it('should throw error on empty list', function() + assert.has_error(function() + list.maximum({}) + end) + end) + end) + + describe('minimum', function() + it('should return smallest element', function() + assert.are.equal(1, list.minimum({ 3, 1, 5, 2, 4 })) + end) + + it('should throw error on empty list', function() + assert.has_error(function() + list.minimum({}) + end) + end) + end) + + describe('sort', function() + it('should sort numbers', function() + local result = list.sort({ 3, 1, 4, 1, 5, 9, 2, 6 }) + assert.are.same({ 1, 1, 2, 3, 4, 5, 6, 9 }, result) + end) + + it('should sort strings', function() + local result = list.sort({ 'c', 'a', 'b' }) + assert.are.same({ 'a', 'b', 'c' }, result) + end) + + it('should not modify original list', function() + local original = { 3, 1, 2 } + local result = list.sort(original) + assert.are.same({ 3, 1, 2 }, original) + assert.are.same({ 1, 2, 3 }, result) + end) + end) + + describe('sort_by / sort_with', function() + it('should sort by key function', function() + local items = { { name = 'c', value = 3 }, { name = 'a', value = 1 }, { name = 'b', value = 2 } } + local result = list.sort_by(function(x) + return x.value + end)(items) + assert.are.equal('a', result[1].name) + assert.are.equal('b', result[2].name) + assert.are.equal('c', result[3].name) + end) + + it('sort_with should sort with comparator (descending)', function() + local result = list.sort_with(function(a, b) + return a > b + end)({ 3, 1, 2 }) + assert.are.same({ 3, 2, 1 }, result) + end) + + it('should handle boolean key via numeric conversion', function() + -- Lua does not support < on booleans; convert to number first + local items = { { active = true, id = 3 }, { active = false, id = 1 }, { active = true, id = 2 } } + local result = list.sort_by(function(x) + return x.active and 1 or 0 + end)(items) + assert.is_false(result[1].active) + end) + + it('sort_by should error when key function returns nil', function() + assert.has_error(function() + list.sort_by(function(_) + return nil + end)({ 1, 2, 3 }) + end, 'sort_by: key function returned nil (nil keys are not supported)') + end) + + it('sort_with ascending', function() + local result = list.sort_with(function(a, b) + return a < b + end)({ 3, 1, 2 }) + assert.are.same({ 1, 2, 3 }, result) + end) + end) + + describe('unique', function() + it('should remove duplicates', function() + local result = list.unique({ 1, 2, 2, 3, 1, 4, 3 }) + assert.are.same({ 1, 2, 3, 4 }, result) + end) + + it('should keep first occurrence', function() + local result = list.unique({ 'a', 'b', 'a', 'c', 'b' }) + assert.are.same({ 'a', 'b', 'c' }, result) + end) + + it('should keep both tables with identical content (reference equality)', function() + local t1 = { name = 'Alice' } + local t2 = { name = 'Alice' } -- same content, different reference + local result = list.unique({ t1, t2, t1 }) + -- t1 and t2 are distinct references, so both are kept; second t1 is a duplicate + assert.are.equal(2, #result) + assert.are.equal(t1, result[1]) + assert.are.equal(t2, result[2]) + end) + end) + + describe('unique_by', function() + it('should deduplicate tables by derived key', function() + local t1 = { name = 'Alice', age = 30 } + local t2 = { name = 'Bob', age = 25 } + local t3 = { name = 'Alice', age = 40 } -- same name as t1, different reference + local result = list.unique_by(function(x) + return x.name + end)({ t1, t2, t3 }) + assert.are.equal(2, #result) + assert.are.equal(t1, result[1]) + assert.are.equal(t2, result[2]) + end) + + it('should keep first occurrence by key', function() + local result = list.unique_by(function(x) + return x % 3 + end)({ 1, 2, 3, 4, 5, 6 }) + -- remainders: 1,2,0,1,2,0 → keep 1 (r=1), 2 (r=2), 3 (r=0) + assert.are.same({ 1, 2, 3 }, result) + end) + + it('should handle empty list', function() + local result = list.unique_by(function(x) + return x + end)({}) + assert.are.same({}, result) + end) + + it('should error when key function returns nil', function() + assert.has_error(function() + list.unique_by(function(_) + return nil + end)({ 1, 2, 3 }) + end, 'unique_by: key function returned nil (nil keys are not supported)') + end) + end) + + describe('group_by', function() + it('should group by key function', function() + local items = { 1, 2, 3, 4, 5, 6 } + local result = list.group_by(function(x) + return x % 2 + end)(items) + assert.are.same({ 2, 4, 6 }, result[0]) + assert.are.same({ 1, 3, 5 }, result[1]) + end) + + it('should work with string keys', function() + local items = { { type = 'a', val = 1 }, { type = 'b', val = 2 }, { type = 'a', val = 3 } } + local result = list.group_by(function(x) + return x.type + end)(items) + assert.are.equal(2, #result['a']) + assert.are.equal(1, #result['b']) + end) + + it('should error when key function returns nil', function() + assert.has_error(function() + list.group_by(function(_) + return nil + end)({ 1, 2, 3 }) + end, 'group_by: key function returned nil for element at index 1') + end) + end) + + describe('find', function() + it('should find first element satisfying predicate', function() + local result = list.find(function(x) + return x > 3 + end)({ 1, 2, 3, 4, 5 }) + assert.are.equal(4, result) + end) + + it('should return nil if no element satisfies predicate', function() + local result = list.find(function(x) + return x > 10 + end)({ 1, 2, 3 }) + assert.is_nil(result) + end) + + it('should work with arrow', function() + local result = { 11, 12, 13 } % arrow(list.find(function(x) + return x > 10 + end)) + assert.are.equal(11, result) + end) + end) + + describe('pipeline example from issue', function() + it('should work with map, filter, and find', function() + local result = { 1, 2, 3 } + % arrow(list.map(function(x) + return x + 10 + end)) + ^ arrow(list.filter(function(x) + return x % 2 ~= 0 + end)) + ^ arrow(list.find(function(x) + return x > 10 + end)) + assert.are.equal(11, result) + end) + end) +end) diff --git a/src/luarrow/utils.lua b/src/luarrow/utils.lua index f3a7330..99874fd 100644 --- a/src/luarrow/utils.lua +++ b/src/luarrow/utils.lua @@ -4,4 +4,6 @@ local M = {} ---@type fun(list: unknown[], i?: integer, j?: integer): ... M.unpack = table.unpack or unpack +M.list = require('luarrow.utils.list') + return M diff --git a/src/luarrow/utils/list.lua b/src/luarrow/utils/list.lua new file mode 100644 index 0000000..1c7ca9e --- /dev/null +++ b/src/luarrow/utils/list.lua @@ -0,0 +1,424 @@ +local M = {} + +---Apply a function to each element, producing a new list. +---@generic A, B +---@param f fun(x: A): B +---@return fun(xs: A[]): B[] +function M.map(f) + return function(list) + local result = {} + for i, v in ipairs(list) do + result[i] = f(v) + end + return result + end +end + +---Keep elements that satisfy a predicate. +---@generic A +---@param pred fun(x: A): boolean +---@return fun(xs: A[]): A[] +function M.filter(pred) + return function(list) + local result = {} + for _, v in ipairs(list) do + if pred(v) then + table.insert(result, v) + end + end + return result + end +end + +---Map then flatten one level. +---@generic A, B +---@param f fun(x: A): B[] +---@return fun(xs: A[]): B[] +function M.flat_map(f) + return function(list) + local result = {} + for _, v in ipairs(list) do + local mapped = f(v) + for _, item in ipairs(mapped) do + table.insert(result, item) + end + end + return result + end +end + +---Alias for flat_map +M.concat_map = M.flat_map + +---Left fold. +---@generic A, B +---@param f fun(acc: B, x: A): B +---@param init B +---@return fun(xs: A[]): B +function M.foldl(f, init) + return function(list) + local acc = init + for _, v in ipairs(list) do + acc = f(acc, v) + end + return acc + end +end + +---Alias for foldl +M.reduce = M.foldl + +---Right fold. +---@generic A, B +---@param f fun(x: A, acc: B): B +---@param init B +---@return fun(xs: A[]): B +function M.foldr(f, init) + return function(list) + local acc = init + for i = #list, 1, -1 do + acc = f(list[i], acc) + end + return acc + end +end + +---Left fold without initial value. +---@generic A +---@param f fun(acc: A, x: A): A +---@return fun(xs: A[]): A +function M.foldl1(f) + return function(list) + if #list == 0 then + error('foldl1: empty list', 2) + end + local acc = list[1] + for i = 2, #list do + acc = f(acc, list[i]) + end + return acc + end +end + +---Right fold without initial value. +---@generic A +---@param f fun(x: A, acc: A): A +---@return fun(xs: A[]): A +function M.foldr1(f) + return function(list) + if #list == 0 then + error('foldr1: empty list', 2) + end + local acc = list[#list] + for i = #list - 1, 1, -1 do + acc = f(list[i], acc) + end + return acc + end +end + +---Flatten one level of nesting. +---@generic A +---@param list A[][] +---@return A[] +function M.flatten(list) + local result = {} + for _, sublist in ipairs(list) do + for _, item in ipairs(sublist) do + table.insert(result, item) + end + end + return result +end + +---Join list of strings with delimiter. +---@param sep string +---@return fun(xs: string[]): string +function M.join(sep) + return function(list) + return table.concat(list, sep) + end +end + +---Sum numeric elements. +---@param list number[] +---@return number +function M.sum(list) + local total = 0 + for _, v in ipairs(list) do + total = total + v + end + return total +end + +---Product of numeric elements. +---@param list number[] +---@return number +function M.product(list) + if #list == 0 then + return 1 + end + local result = 1 + for _, v in ipairs(list) do + result = result * v + end + return result +end + +---Return number of elements. +---@generic A +---@param list A[] +---@return integer +function M.length(list) + return #list +end + +---True if list is empty. +---@generic A +---@param list A[] +---@return boolean +function M.is_empty(list) + return #list == 0 +end + +---First element (or nil). +---@generic A +---@param list A[] +---@return A | nil +function M.head(list) + return list[1] +end + +---List without the first element. +---@generic A +---@param list A[] +---@return A[] +function M.tail(list) + local result = {} + for i = 2, #list do + table.insert(result, list[i]) + end + return result +end + +---Last element (or nil). +---@generic A +---@param list A[] +---@return A | nil +function M.last(list) + return list[#list] +end + +---All elements except the last. +---@generic A +---@param list A[] +---@return A[] +function M.init(list) + local result = {} + for i = 1, #list - 1 do + table.insert(result, list[i]) + end + return result +end + +---Reverse the list. +---@generic A +---@param list A[] +---@return A[] +function M.reverse(list) + local result = {} + for i = #list, 1, -1 do + table.insert(result, list[i]) + end + return result +end + +---Largest element. +---@param list number[] +---@return number +function M.maximum(list) + if #list == 0 then + error('maximum: empty list', 2) + end + local max = list[1] + for i = 2, #list do + if list[i] > max then + max = list[i] + end + end + return max +end + +---Smallest element. +---@param list number[] +---@return number +function M.minimum(list) + if #list == 0 then + error('minimum: empty list', 2) + end + local min = list[1] + for i = 2, #list do + if list[i] < min then + min = list[i] + end + end + return min +end + +---Sort with default comparator. +---@generic A +---@param list A[] +---@return A[] +function M.sort(list) + local result = {} + for i, v in ipairs(list) do + result[i] = v + end + table.sort(result) + return result +end + +---Sort by a key function. +---Derives a sort key from each element and sorts ascending by `key(a) < key(b)`. +---Keys are computed once per element (Schwartzian transform) for efficiency. +---Keys must be comparable with `<` (numbers or strings). +---To sort by a boolean field, convert to a number: +--- `sort_by(function(x) return x.active and 1 or 0 end)` +--- +---For sorting with a custom comparator, use `sort_with` instead. +---@generic A, K +---@param key fun(x: A): K +---@return fun(xs: A[]): A[] +function M.sort_by(key) + return function(list) + -- Schwartzian transform: compute each key once, sort, then strip keys + local decorated = {} + for i, v in ipairs(list) do + local k = key(v) + if k == nil then + error('sort_by: key function returned nil (nil keys are not supported)', 2) + end + decorated[i] = { value = v, key = k } + end + + table.sort(decorated, function(a, b) + return a.key < b.key + end) + + local result = {} + for i, item in ipairs(decorated) do + result[i] = item.value + end + + return result + end +end + +---Sort using a comparator function. +---The comparator receives two elements and must return `true` when the first +---should precede the second (i.e. behaves like `<`). +--- +---For sorting by a derived key value, use `sort_by` instead. +---@generic A +---@param cmp fun(a: A, b: A): boolean +---@return fun(xs: A[]): A[] +function M.sort_with(cmp) + return function(list) + local result = {} + for i, v in ipairs(list) do + result[i] = v + end + table.sort(result, cmp) + return result + end +end + +---Remove duplicates using Lua's `==` operator (first occurrence kept). +--- +---For primitives (numbers, strings, booleans) equality is by value. +---For tables and functions equality is by **reference** — two separate tables +---with identical contents are considered distinct. Use `unique_by` to +---deduplicate tables by a derived key. +---@generic A +---@param list A[] +---@return A[] +function M.unique(list) + local result = {} + local seen = {} + for _, v in ipairs(list) do + if seen[v] == nil then + seen[v] = true + table.insert(result, v) + end + end + return result +end + +---Remove duplicates by a derived key (first occurrence kept). +---Two elements are considered equal when their derived keys are `==`. +---This is useful for deduplicating tables by a field value. +--- +---```lua +---list.unique_by(function(x) return x.name end)( +--- { {name='Alice'}, {name='Bob'}, {name='Alice'} } +---) +----- => { {name='Alice'}, {name='Bob'} } +---``` +---@generic A, K +---@param f fun(x: A): K +---@return fun(xs: A[]): A[] +function M.unique_by(f) + return function(list) + local result = {} + local seen = {} + for _, v in ipairs(list) do + local key = f(v) + if key == nil then + error('unique_by: key function returned nil (nil keys are not supported)', 2) + end + if seen[key] == nil then + seen[key] = true + table.insert(result, v) + end + end + return result + end +end + +---Group by key function. +---@generic A, K +---@param f fun(x: A): K +---@return fun(xs: A[]): table +function M.group_by(f) + return function(list) + local result = {} + for i, v in ipairs(list) do + local key = f(v) + if key == nil then + error('group_by: key function returned nil for element at index ' .. tostring(i), 2) + end + if result[key] == nil then + result[key] = {} + end + table.insert(result[key], v) + end + return result + end +end + +---Find first element that satisfies predicate. +---@generic A +---@param pred fun(x: A): boolean +---@return fun(xs: A[]): A | nil +function M.find(pred) + return function(list) + for _, v in ipairs(list) do + if pred(v) then + return v + end + end + return nil + end +end + +return M