---@class Validator ---@overload fun(value: any): any, string? ---validates some value and returns it back if successful. if the validation fails, returns nil and an error message. local Validator = {} local validator_mt = { __index = Validator, __call = function(self, value) local ok, err = self._validate(value) if not ok then return nil, err end return value end } local function construct_validator(v_fn) return setmetatable({ _validate = v_fn }, validator_mt) end -- additional constraints ---attaches an additional constraint to the validator and returns it. ---@param fn fun(value: any): boolean the validation function ---@param err_message string? the error message to display when this constraint fails ---@return Validator function Validator:also(fn, err_message) local original_validate = self._validate self._validate = function(value) local ok, err = original_validate(value) if not ok then return ok, err end if not fn(value) then return false, err_message or "additional constraint failed" end return true end return self end -- core ---constructs a validator that always succeeds. ---@return Validator local function always() return construct_validator(function(_) return true end) end ---constructs a validator that never succeeds. ---@return Validator local function never() return construct_validator(function(_) return false, "validator failed unconditionally" end) end ---constructs a validator that succeeds on the first successful inner validator (ordered choice). ---@param ... Validator ---@return Validator local function attempts(...) local validators = {...} return construct_validator(function(value) local errors = {} for i, v in ipairs(validators) do local ok, result = v._validate(value) if ok then return true, result end table.insert(errors, ("[%d]: %s"):format(i, result or "validation failed")) end return false, "no validators matched: " .. table.concat(errors, "; ") end) end -- primitives ---constructs a validator that succeeds if the input is a string. ---@return Validator local function string() return construct_validator(function(value) if type(value) ~= "string" then return false, "expected string, got " .. type(value) end return true end) end ---constructs a validator that succeeds if the input is a number. ---@return Validator local function number() return construct_validator(function(value) if type(value) ~= "number" then return false, "expected number, got " .. type(value) end return true end) end ---constructs a validator that succeeds if and only if the input value is exactly equal to `expected` (via `__eq`). ---@param expected any ---@return Validator local function literal(expected) return construct_validator(function(value) if value ~= expected then return false, ("expected value to be %s, got %s"):format(expected, value) end return true end) end ---constructs a validator that succeeds if the input is a boolean. ---@return Validator local function boolean() return attempts( literal(true), literal(false) ) end -- shaped validators ---constructs a validator that succeeds if `v` returns `nil`. equivalent to `attempts(p_validator, literal(nil))`. ---@param v Validator ---@return Validator local function maybe(v) return attempts(literal(nil), v) end ---constructs a validator that succeeds if and only if all the validator elements of `shape` validate entries of the input under the same keys. will fail if the input contains any keys not present in `shape`. ---@param shape table ---@return Validator local function schema(shape) return construct_validator(function(value) if type(value) ~= "table" then return false, "expected table, got " .. type(value) end local errors = {} for k, elem_validator in pairs(shape) do local ok, err = elem_validator._validate(value[k]) if not ok then table.insert(errors, ("key %s: %s"):format(k, err)) end end for k in pairs(value) do if shape[k] == nil then table.insert(errors, "unexpected field: "..k) end end if #errors > 0 then return false, "schema errors: "..table.concat(errors, "; ") end return true end) end ---constructs a validator that succeeds if all the validator elements of `shape` validate entries of the input under the same keys. will NOT fail if the input contains any keys not present in `shape`. ---@param shape table ---@return Validator local function open_schema(shape) return construct_validator(function(value) if type(value) ~= "table" then return false, "expected table, got " .. type(value) end local errors = {} for k, elem_validator in pairs(shape) do local ok, err = elem_validator._validate(value[k]) if not ok then table.insert(errors, ("key %s: %s"):format(k, err)) end end if #errors > 0 then return false, "schema errors: "..table.concat(errors, "; ") end return true end) end ---constructs a validator that succeeds if and only if all the validator elements of the `shape` array validate entries of the input with the same numerical index. will fail if the input is not exactly the same size as `shape`. ---@param shape Validator[] ---@return Validator local function array(shape) return construct_validator(function(value) if type(value) ~= "table" then return false, "expected table, got " .. type(value) end if #shape ~= #value then return false, ("expected exactly %d elements, got %d"):format(#shape, #value) end local errors = {} for i, elem_validator in ipairs(shape) do local ok, err = elem_validator._validate(value[i]) if not ok then table.insert(errors, ("%d: %s"):format(i, err)) end end if #errors > 0 then return false, "array errors: "..table.concat(errors, "; ") end return true end) end ---constructs a validator that succeeds if all the validator elements of the `shape` array validate entries of the input with the same numerical index. will fail if the input array's size is smaller than that of `shape`'s. ---@param shape Validator[] ---@return Validator local function open_array(shape) return construct_validator(function(value) if type(value) ~= "table" then return false, "expected table, got " .. type(value) end if #value < #shape then return false, ("expected at least %d elements, got %d"):format(#shape, #value) end local errors = {} for i, elem_validator in ipairs(shape) do local ok, err = elem_validator._validate(value[i]) if not ok then table.insert(errors, ("%d: %s"):format(i, err)) end end if #errors > 0 then return false, "array errors: "..table.concat(errors, "; ") end return true end) end ---constructs a validator that will validate every element in an array with the `v` validator. ---@param v Validator ---@return Validator local function array_of(v) return construct_validator(function(value) if type(value) ~= "table" then return false, "expected table, got " .. type(value) end local errors = {} for i, elem in ipairs(value) do local ok, err = v._validate(elem) if not ok then table.insert(errors, ("[%d]: %s"):format(i, err)) end end if #errors > 0 then return false, "array_of errors: "..table.concat(errors, "; ") end return true end) end return { always = always, never = never, maybe = maybe, string = string, number = number, boolean = boolean, literal = literal, attempts = attempts, schema = schema, open_schema = open_schema, array = array, open_array = open_array, array_of = array_of, }