diff --git a/lib/option.luau b/lib/option.luau index 440934e..1960004 100644 --- a/lib/option.luau +++ b/lib/option.luau @@ -1,17 +1,93 @@ +local function tableEq(tbl1, tbl2) + if tbl1 == tbl2 then + return true + elseif type(tbl1) == "table" and type(tbl2) == "table" then + for key1, value1 in pairs(tbl1) do + local value2 = tbl2[key1] + + if value2 == nil then + -- avoid the type call for missing keys in tbl2 by directly comparing with nil + return false + elseif value1 ~= value2 then + if type(value1) == "table" and type(value2) == "table" then + if not tableEq(value1, value2) then + return false + end + else + return false + end + end + end + + -- check for missing keys in tbl1 + for key2, _ in pairs(tbl2) do + if tbl1[key2] == nil then + return false + end + end + + return true + end + + return false +end + +--[=[ + @class Option + + Type [Option] represents an optional value: every [Option] is either [Some] and contains a + value, or [None], and does not. Common uses of an [Option] may involve: + + * Initial values + * Return values for functions that are not defined over their entire input range (partial functions) + * Return value for otherwise reporting simple errors, where None is returned on error + * Optional object fields + * Values that can be loaned or “taken” + * Optional function arguments + + ```lua + function divide(numerator: number, denominator: number): Option + if denominator == 0 then + None() + else + return Some(numerator / denominator) + end + end + ``` +]=] local Option = {} + export type Option = typeof(Option) & { _optValue: T?, typeId: "Option", } +--[=[ + @within Option + + No value. +]=] function None(): Option return Option.new(nil) :: Option end +--[=[ + @within Option + + Some value of type `T`. +]=] function Some(val: T): Option return Option.new(val) :: Option end +--[=[ + @within Option + + Converts a potentially `nil` value into an [Option]. + + @param val T? -- The value to convert into an [Option] + @return Option +]=] function Option.new(val: T?) return setmetatable( { @@ -29,7 +105,11 @@ function Option.new(val: T?) end, __eq = function(self: Option, other: Option): boolean - return self._optValue == other._optValue + if typeof(self._optValue) == "table" and typeof(other._optValue) == "table" then + return tableEq(self._optValue, other._optValue) + else + return self._optValue == other._optValue + end end, __lt = function(self: Option, other: Option): boolean if self:isSome() and other:isSome() then @@ -51,19 +131,66 @@ function Option.new(val: T?) ) end +--[=[ + @within Option + + Returns `true` is the [Option] is an [Option:Some] value. + + @param self Option + @return boolean +]=] function Option.isSome(self: Option): boolean return self._optValue ~= nil end +--[=[ + @within Option + + Returns `true` is the [Option] is an [Option:Some] value and the value inside of it + matches a predicate. + + @param self Option + @param op (val: T) -> boolean -- The predicate function + @return boolean +]=] function Option.isSomeAnd(self: Option, op: (val: T) -> boolean): boolean -- We know that it's not None, so this is fine return self:isSome() and op(self._optValue :: T) end +--[=[ + @within Option + + Returns `true` is the [Option] is an [Option:None] value. + + @param self Option + @return boolean +]=] function Option.isNone(self: Option): boolean return not self:isSome() end +--[=[ + @within Option + + Returns the [Option:Some]. + + @error panic -- Panics if there is an [Option:None] with a custom panic message provided by `msg`. + + ```lua + local x: Option = Some("value") + assert(x:expect("fruits are healthy") == "value") + ``` + + ```lua + local x: Option = None() + x:expect("fruits are healthy") -- panics with `fruits are healthy` + ``` + + @param self Option + @param msg string -- The panic message + @return boolean +]=] function Option.expect(self: Option, msg: string): T | never if self:isSome() then return self._optValue :: T @@ -72,6 +199,18 @@ function Option.expect(self: Option, msg: string): T | never return error(`panic: {msg}; expected Option to be type Option::Some(T), got Option::None`) end +--[=[ + @within Option + + Returns the [Option:Some]. + + Because this function may panic, its use is generally discouraged. Instead, prefer to use + [Option:unwrapOr], [Option:unwrapOrElse], or [Option:unwrapOrDefault]. + + @error panic -- Panics if the self value is [Option:None]. + @param self Option + @return T | never +]=] function Option.unwrap(self: Option): T | never if self:isSome() then return self._optValue :: T @@ -80,6 +219,24 @@ function Option.unwrap(self: Option): T | never return error("called `Option:unwrap()` on a `None` value") end +--[=[ + @within Option + + Returns the contained [Option:Some] value or a provided default. + + Arguments passed to unwrap_or are eagerly evaluated; if you are passing the result of + a function call, it is recommended to use [Option:unwrapOrElse], which is lazily + evaluated. + + ```lua + assert(Some("car"):unwrapOr("bike") == "car") + assert(None():unwrapOr("bike") == "bike") + ``` + + @param self Option + @param default T -- The default value + @return T +]=] function Option.unwrapOr(self: Option, default: T): T if self:isSome() then return self._optValue :: T @@ -88,6 +245,25 @@ function Option.unwrapOr(self: Option, default: T): T return default end +--[=[ + @within Option + + Returns the contained [Option:Some] value or computes it from a function. + + ```lua + local k = 10 + assert(Some(4).unwrapOrElse(function() + return 2 * k + end) == 4) + assert(None().unwrapOrElse(function() + return 2 * k + end) == 20) + ``` + + @param self Option + @param default () -> T -- The function which computes the default value + @return T +]=] function Option.unwrapOrElse(self: Option, default: () -> T): T if self:isSome() then return self._optValue :: T @@ -96,6 +272,30 @@ function Option.unwrapOrElse(self: Option, default: () -> T): T return default() end +--[=[ + @within Option + + Maps an [Option] to [Option] by applying a function to a contained value + (if [Option:Some]) or returns [Option:None](if [Option:None]). + + ```lua + local maybeSomeString: Option = Some("Hello, World!") + + local maybeSomeLen: Option = maybeSomeString:map(function(s) + return #s + end) + assert(maybeSomeLen == 13) + + local x: Option = None() + assert(x:map(function(s) + return #s + end) == None()) + ``` + + @param self Option + @param op (x: T) -> U? -- The function to apply + @return Option +]=] function Option.map(self: Option, op: (x: T) -> U?): Option if self:isSome() then local val = op(self._optValue :: T) @@ -110,7 +310,30 @@ function Option.map(self: Option, op: (x: T) -> U?): Option return None() end -function Option.inspect(self: Option, op: (x: T) -> nil): Option +--[=[ + @within Option + + Calls the provided closure with the contained value (if [Option:Some]). + + ```lua + local v = { 1, 2, 3, 4, 5 } + + -- prints "got: 4" + local x: Option = Option.new(v[4]):inspect(function(x) + print("got: " .. x) + end) + + -- prints nothing + local x: Option = Option.new(v[5]):inspect(function(x) + print("got: " .. x) + end) + ``` + + @param self Option + @param op (x: T) -> () -- The function to call + @return Option +]=] +function Option.inspect(self: Option, op: (x: T) -> ()): Option if self:isSome() then op(self._optValue :: T) end @@ -118,6 +341,28 @@ function Option.inspect(self: Option, op: (x: T) -> nil): Option return self end +--[=[ + @within Option + + Returns the provided default result (if none), or applies a function to + the contained value (if any). + + Arguments passed to [Option:mapOr] are eagerly evaluated; if you are passing the + result of a function call, it is recommended to use [Option:mapOrElse], + which is lazily evaluated. + + ```lua + local x: Option = Some("foo") + assert(x.mapOr(42, function(v) return #v end) == 3) + + local x: Option = None() + assert(x.mapOr(42, function(v) return #v end) == 42) + ``` + @param self Option + @param default U -- The default value + @param op (val: T) -> U -- The function to apply + @return Option +]=] function Option.mapOr(self: Option, default: U, op: (val: T) -> U) if self:isSome() then return op(self._optValue :: T) @@ -126,6 +371,37 @@ function Option.mapOr(self: Option, default: U, op: (val: T) -> U) return default end +--[=[ + @within Option + + Computes a default function result (if none), or applies a different function + to the contained value (if any). + + ```lua + local k = 21; + + local x: Option = Some("foo") + assert( + x:mapOrElse( + function() return 2 * k end, + function(v) return #v end + ) == 3 + ) + + local x: Option = None() + assert( + x:mapOrElse( + function() return 2 * k end, + function(v) return #v end + ) == 42 + ) + ``` + + @param self Option + @param default () -> U -- The function to compute the default value + @param op (val: T) -> U -- The function to apply + @return Option +]=] function Option.mapOrElse(self: Option, default: () -> U, op: (val: T) -> U): U if self:isSome() then return op(self._optValue :: T) @@ -135,8 +411,40 @@ function Option.mapOrElse(self: Option, default: () -> U, op: (val: T) end -- TODO: Iterator traits -function Option.iter() end +function Option.iter(): never + return error("Unimplemented: `Option:iter()`") +end +--[=[ + @within Option + + Returns [Option:None] if the option is [Option:None], otherwise returns `optb`. + + Arguments passed to and are eagerly evaluated; if you are passing the result of a + function call, it is recommended to use [Option:andThen], which is lazily evaluated. + + ```lua + local x: Option = Some(2) + local y: Option = None() + assert(x:and_(y) == None()) + + local x: Option = None() + local y: Option = Some("foo") + assert(x:and_(y) == None()) + + local x: Option = Some(2) + local y: Option = Some("foo") + assert(x:and_(y) == Some("foo")) + + local x: Option = None() + local y: Option = None() + assert(x:and_(y), None()) + ``` + + @param self Option + @param optb Option -- The other option + @return Option +]=] function Option.and_(self: Option, optb: Option): Option if self:isSome() then return optb @@ -145,6 +453,45 @@ function Option.and_(self: Option, optb: Option): Option return None() end +--[=[ + @within Option + + Returns [Option:None] if the option is [Option:None], otherwise calls `op` with the wrapped + value and returns the result. + + Some languages call this operation flatmap. + + ```lua + function sqThenToString(x: number): Option + return Option.new(x ^ 2):map(function(sq) + return tostring(sq) + end) + end + + assert(Some(2):andThen(sqThenToString) == Some(tostring(4))) + assert(None():andThen(sqThenToString) == None()) + ``` + + Often used to chain fallible operations that may return [Option:None]. + + ```lua + local arr2d = { { "A0", "A1" }, { "B0", "B1" } } + + local item01: Option = Option.new(arr2d[1]):andThen(function(row) + return row[2] + end) + assert(item01 == Some("A1")) + + local item20: Option = Option.new(arr2d[3]):andThen(function(row) + return row[0] + end) + assert(item20 == None()) + ``` + + @param self Option + @param op (val: T) -> Option -- The function to call + @return Option +]=] function Option.andThen(self: Option, op: (val: T) -> Option): Option if self:isSome() then return op(self._optValue :: T) @@ -153,6 +500,29 @@ function Option.andThen(self: Option, op: (val: T) -> Option): Optio return None() end +--[=[ + @within Option + + Returns [Option:None] if the option is [Option:None], otherwise calls `predicate` with + the wrapped value and returns: + + * [Option:Some](t) if predicate returns `true` (where `t` is the wrapped value), and + * [Option:None] if predicate returns `false`. + + ```lua + function isEven(n: number): boolean + return n % 2 == 0 + end + + assert(None():filter(isEven) == None()) + assert(Some(3):filter(isEven) == None()) + assert(Some(4):filter(isEven) == Some(4)) + ``` + + @param self Option + @param predicate (val: T) -> boolean -- The predicate function which must match an element + @return Option +]=] function Option.filter(self: Option, predicate: (val: T) -> boolean): Option if self:isSome() then if predicate(self._optValue :: T) then @@ -163,6 +533,37 @@ function Option.filter(self: Option, predicate: (val: T) -> boolean): Opti return None() end +--[=[ + @within Option + + Returns the option if it contains a value, otherwise returns `optb`. + + Arguments passed to [Option:or_] are eagerly evaluated; if you are passing the result of + a function call, it is recommended to use [Option:orElse], which is lazily + evaluated. + + ```lua + local x: Option = Some(2) + local y: Option = None() + assert(x:or_(y) == Some(2)) + + local x: Option = None() + local y: Option = Some(100) + assert(x:or_(y) == Some(100)) + + local x: Option = Some(2) + local y: Option = Some(100) + assert(x:or_(y) == Some(2)) + + local x: Option = None() + local y: Option = None() + assert(x:or_(y), None()) + ``` + + @param self Option + @param optb Option -- The other option + @return Option +]=] function Option.or_(self: Option, optb: Option): Option if self:isSome() then return self @@ -171,6 +572,28 @@ function Option.or_(self: Option, optb: Option): Option return optb end +--[=[ + @within Option + + Returns the option if it contains a value, otherwise calls `op` and returns the result. + + ```lua + function nobody(): Option + return None() + end + function vikings(): Option + return Some("vikings") + end + + assert(Some("barbarians"):orElse(vikings) == Some("barbarians")) + assert(None():orElse(vikings) == Some("vikings")) + assert(None():orElse(nobody) == None()) + ``` + + @param self Option + @param op () -> Option -- The function to call + @return Option +]=] function Option.orElse(self: Option, op: () -> Option): Option if self:isSome() then return self @@ -179,6 +602,34 @@ function Option.orElse(self: Option, op: () -> Option): Option return op() end +--[=[ + @within Option + + Returns [Option:Some] if exactly one of `self`, `optb` is [Option:Some], + otherwise returns [Option:None]. + + ```lua + local x: Option = Some(2) + local y: Option = None() + assert(x:xor(y) == Some(2)) + + local x: Option = None() + local y: Option = Some(2) + assert(x:xor(y) == Some(2)) + + local x: Option = Some(2) + local y: Option = Some(2) + assert(x:xor(y) == None()) + + local x: Option = None() + local y: Option = None() + assert(x:xor(y) == None()) + ``` + + @param self Option + @param optb Option -- The other option + @return Option +]=] function Option.xor(self: Option, optb: Option): Option if self:isSome() and optb:isSome() then return None() @@ -193,11 +644,59 @@ function Option.xor(self: Option, optb: Option): Option return None() end +--[=[ + @within Option + + Inserts value into the option, then returns it. + + If the option already contains a value, the old value is dropped. + + See also [Option:getOrInsert], which doesn’t update the value if the + option already contains [Option:Some]. + + ```lua + local opt: Option = None() + local val: number = opt:insert(1) + assert(val == 1) + assert(opt:unwrap() == 1) + + local val: number = opt:insert(2) + assert(val == 2) + ``` + + @param self Option + @param val T -- The value to insert + @return T +]=] function Option.insert(self: Option, val: T): T self._optValue = val return self._optValue :: T end +--[=[ + @within Option + + Inserts value into the option, then returns it. + + If the option already contains a value, the old value is dropped. + + See also [Option:getOrInsert], which doesn’t update the value if the + option already contains [Option:Some]. + + ```lua + local opt: Option = None() + local val: number = opt:insert(1) + assert(val == 1) + assert(opt:unwrap() == 1) + + local val: number = opt:insert(2) + assert(val == 2) + ``` + + @param self Option + @param val T -- The value to insert + @return T +]=] function Option.getOrInsert(self: Option, val: T): T if self:isNone() then self._optValue = val @@ -206,6 +705,26 @@ function Option.getOrInsert(self: Option, val: T): T return self._optValue :: T end +--[=[ + @within Option + + Takes the value out of the option, leaving an [Option:None] in its place. + + ```lua + local x: Option = Some(2) + local y: Option = x.take() + assert(x == None()) + assert(y == Some(2)) + + local x: Option = None() + local y: Option = x.take() + assert(x == None()) + assert(y == None()) + ``` + + @param self Option + @return Option +]=] function Option.take(self: Option): Option if self:isSome() then local val = self._optValue :: T @@ -216,6 +735,29 @@ function Option.take(self: Option): Option return None() end +--[=[ + @within Option + + Replaces the actual value in the option by the value given in parameter, returning + the old value if present, leaving an [Option:Some] in its place without + deinitializing either one. + + ```lua + local x: Option = Some(2) + local old: Option = x:replace(5) + assert(x == Some(5)) + assert(old == Some(2)) + + local x: Option = None() + local old: Option = x:replace(3) + assert(x == Some(3)) + assert(old == None()) + ``` + + @param self Option + @param val T + @return Option +]=] function Option.replace(self: Option, val: T): Option local current: Option = self self._optValue = val @@ -227,6 +769,24 @@ function Option.replace(self: Option, val: T): Option return Some(current._optValue :: T) end +--[=[ + @within Option + + Returns true if `val` is contained in the [Option:Some]. + + ```lua + local x: Option = Some(2) + local y: Option = None() + + assert(x:contains(2)) + assert(x:contains(4)) + assert(not y:contains(2)) + ``` + + @param self Option + @param val T + @return boolean +]=] function Option.contains(self: Option, val: T): boolean if self:isSome() then return self._optValue == val @@ -235,6 +795,27 @@ function Option.contains(self: Option, val: T): boolean return false end +--[=[ + @within Option + + Zips `self` with another [Option]. + + If `self` is [Option:Some](s) and other is [Option:Some](o), this method returns + [Option:Some]({s, o}). Otherwise, [Option:None] is returned. + + ```lua + local x: Option = Some(1) + local y: Option = Some("hi") + local z: Option = None() + + assert(x:zip(y) == Some({ 1, "hi" })) + assert(x:zip(z) == None()) + ``` + + @param self Option + @param other Option>U> + @return Option<{T | U}> +]=] function Option.zip(self: Option, other: Option): Option<{ T | U }> if self:isSome() and other:isSome() then return Some({ self._optValue, other._optValue }) @@ -243,6 +824,42 @@ function Option.zip(self: Option, other: Option): Option<{ T | U }> return None() end +--[=[ + @within Option + + Zips `self` and another [Option] with function `op`. + + If `self` is [Option:Some](s) and other is [Option:Some](o), this method returns + [Option:Some](op(s, o)). Otherwise, [Option:None] is returned. + + ```lua + type Point = { + x: number, + y: number, + } + local Point: Point & { + new: (x: number, y: number) -> Point, + } = {} + + function Point.new(x: number, y: number): Point + return { + x = x, + y = y, + } + end + + local xCoord: Option = Some(17.5) + local yCoord: Option = Some(42.7) + + assert(xCoord:zipWith(yCoord, Point.new), Some({ x = 17.5, y = 42.7 })) + assert(x:zipWith(None(), Point.new), None()) + ``` + + @param self Option + @param other Option>U> + @param op (x: T, y: U) -> R? + @return Option +]=] function Option.zipWith(self: Option, other: Option, op: (x: T, y: U) -> R?): Option if self:isSome() and other:isSome() then local computed = op(self._optValue :: T, other._optValue :: U) @@ -255,6 +872,25 @@ function Option.zipWith(self: Option, other: Option, op: (x: T, y return None() end +--[=[ + @within Option + + Unzips an option containing a table of two options. + + If `self` is `Some({a, b})` this method returns `(Some(a), Some(b))`. + Otherwise, `(None(), None())` is returned. + + ```lua + local x: Option<{ number | string }> = Some({ 1, "hi" }) + local y: Option<{ number }> = None() + + assert((x:unzip() == Some(1), Some("hi"))) + assert((y:unzip() == None(), None())) + ``` + + @param self Option + @return (Option, Option) +]=] function Option.unzip(self: Option): (Option, Option) if self:isSome() then if self:isSome() and typeof(self._optValue) == "table" and #self._optValue == 2 then @@ -265,10 +901,47 @@ function Option.unzip(self: Option): (Option, Option) return None(), None() end +--[=[ + @within Option + + Returns the inner value wrapped by the [Option]. + + ```lua + local x: Option = Some("lol") + local y: Option = None() + + assert(x:getInner() == "lol") + assert(y:getInner() == nil) + ``` + + @param self Option + @return T? +]=] function Option.getInner(self: Option): T? return self._optValue end +--[=[ + @within Option + + Returns a formatted representation of the option, often + used for printing to stdout. + + ```lua + local x: Option = Some(123) + local y: Option = None() + + print(x:display()) -- prints `Option::Some(123)` + print(y:display()) -- prints `Option::None` + ``` + + @param self Option + @return string +]=] +function Option.display(self: Option): string + return tostring(self) +end + return { Option = Option, Some = Some,