vl/vl.lua
2025-06-04 14:46:37 +03:00

279 lines
7.8 KiB
Lua

---@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<string, Validator>
---@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<string, Validator>
---@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,
}