feat: input validators

This commit is contained in:
2026-04-05 01:20:52 +03:00
parent ea5bbd8e9f
commit 7abc4b046d
11 changed files with 660 additions and 3 deletions

View File

@@ -139,6 +139,92 @@ All inputs share `name` (string, required — the key in the result table) and
`value()` returns the selected `id` (not the label).
## Validation
Each input spec accepts an optional `validator` function:
```lua
validator = fun(value: any): string|nil
```
Return a non-empty error message string to mark the input invalid, or `nil` /
`""` when valid. The error message is shown in the input's bottom border
(red), and the border + label turn red too. Validation runs:
- **On blur** — the first time the user leaves the field it is marked
"touched" and the validator runs. Nothing is shown before that.
- **On change** — once touched, each buffer change re-runs the validator.
- **On submit** — `form:submit()` force-validates every input (touched or
not). If any input has an error, submission is blocked, all errors are
rendered, and focus moves to the first invalid input.
### Built-in validators
```lua
local V = require("input-form").validators
V.non_empty([msg]) -- require a non-empty value
V.min_length(n, [msg]) -- at least `n` characters
V.max_length(n, [msg]) -- at most `n` characters
V.matches(lua_pattern, [msg]) -- match a Lua pattern
V.is_number([msg]) -- tonumber() must succeed
V.one_of({ "a", "b", ... }, [msg]) -- value must be in the list
V.custom(predicate, msg) -- wrap a `fun(v): boolean` predicate
V.chain(v1, v2, ...) -- run validators in order, first error wins
```
Example:
```lua
local f = require("input-form")
local V = f.validators
f.create_form({
inputs = {
{
name = "id",
label = "Enter ID",
type = "text",
validator = V.chain(
V.non_empty(),
V.min_length(3),
V.matches("^[%w_-]+$", "Only letters, digits, - and _")
),
},
{
name = "age",
label = "Age",
type = "text",
validator = V.chain(V.non_empty(), V.is_number()),
},
},
on_submit = function(results)
vim.print(results) -- only runs if every validator passes
end,
}):show()
```
Custom validators are just functions — no need to use the builder helpers if
you'd rather write one inline:
```lua
validator = function(value)
if value == "admin" then
return "Username 'admin' is reserved"
end
end
```
### Highlight groups
Error rendering uses three highlight groups. Override them to re-theme:
```lua
vim.api.nvim_set_hl(0, "InputFormFieldError", { fg = "#ff5555" })
vim.api.nvim_set_hl(0, "InputFormFieldErrorBorder", { fg = "#ff5555" })
vim.api.nvim_set_hl(0, "InputFormFieldErrorTitle", { fg = "#ff5555", bold = true })
```
## Configuration
Defaults:

View File

@@ -69,6 +69,11 @@ Expose the Form class for advanced use.
`M.config`
Expose the config module.
------------------------------------------------------------------------------
*M.validators*
`M.validators`
Expose the built-in validator library. See |input-form.validators|.
==============================================================================
------------------------------------------------------------------------------
@@ -137,4 +142,98 @@ Return ~
`(table)`
==============================================================================
------------------------------------------------------------------------------
*input-form.validators*
Validators for form inputs.
A validator is a function `fun(value): string|nil`. It receives the
current input value and returns a non-empty error message string when
invalid, or `nil` / `""` when valid.
This module exposes factory functions for common validators plus a
`chain` combinator that runs several validators in order and returns the
first error.
------------------------------------------------------------------------------
*M.non_empty()*
`M.non_empty`({msg})
Require the field to have a non-empty value.
Parameters ~
{msg} `(string|nil)` Override error message.
Return ~
`(function)`
------------------------------------------------------------------------------
*M.min_length()*
`M.min_length`({n}, {msg})
Require the value's length to be at least `n` characters.
Parameters ~
{n} `(integer)`
{msg} `(string|nil)`
Return ~
`(function)`
------------------------------------------------------------------------------
*M.max_length()*
`M.max_length`({n}, {msg})
Require the value's length to be at most `n` characters.
Parameters ~
{n} `(integer)`
{msg} `(string|nil)`
Return ~
`(function)`
------------------------------------------------------------------------------
*M.matches()*
`M.matches`({pattern}, {msg})
Require the value to match a Lua pattern.
Parameters ~
{pattern} `(string)` Lua pattern (not PCRE).
{msg} `(string|nil)`
Return ~
`(function)`
------------------------------------------------------------------------------
*M.is_number()*
`M.is_number`({msg})
Require the value to parse as a number.
Parameters ~
{msg} `(string|nil)`
Return ~
`(function)`
------------------------------------------------------------------------------
*M.one_of()*
`M.one_of`({choices}, {msg})
Require the value to be one of the given choices (useful for text inputs
that must match a fixed allowlist; select inputs should use their
`options` list instead).
Parameters ~
{choices} `(table)` List of allowed values.
{msg} `(string|nil)`
Return ~
`(function)`
------------------------------------------------------------------------------
*M.custom()*
`M.custom`({predicate}, {msg})
Wrap a predicate `fun(value): boolean` as a validator.
Parameters ~
{predicate} `(function)`
{msg} `(string)` Error message to return when the predicate is false.
Return ~
`(function)`
------------------------------------------------------------------------------
*M.chain()*
`M.chain`({...})
Combine multiple validators. Runs them in order and returns the first
non-empty error. Accepts either a list table or a varargs list.
Parameters ~
{...} `(function|table)`
Return ~
`(function)`
vim:tw=78:ts=8:noet:ft=help:norl:

View File

@@ -1,10 +1,20 @@
Default input-form.txt /*Default*
M.Form input-form.txt /*M.Form*
M.chain() input-form.txt /*M.chain()*
M.config input-form.txt /*M.config*
M.custom() input-form.txt /*M.custom()*
M.is_number() input-form.txt /*M.is_number()*
M.matches() input-form.txt /*M.matches()*
M.max_length() input-form.txt /*M.max_length()*
M.min_length() input-form.txt /*M.min_length()*
M.non_empty() input-form.txt /*M.non_empty()*
M.one_of() input-form.txt /*M.one_of()*
M.validators input-form.txt /*M.validators*
input-form.config input-form.txt /*input-form.config*
input-form.config.setup input-form.txt /*input-form.config.setup*
input-form.create_form input-form.txt /*input-form.create_form*
input-form.nvim input-form.txt /*input-form.nvim*
input-form.setup input-form.txt /*input-form.setup*
input-form.validators input-form.txt /*input-form.validators*
register_helptags() input-form.txt /*register_helptags()*
values: input-form.txt /*values:*

View File

@@ -235,8 +235,16 @@ function M:results()
return out
end
--- Submit the form: gather values, close windows, invoke `on_submit(results)`.
--- Submit the form: gather values, run validators, and invoke
--- `on_submit(results)` only if everything validates. If any input has an
--- error, submission is blocked, all inputs are force-validated (so the user
--- sees every error, including ones on untouched fields), and focus moves to
--- the first invalid input.
function M:submit()
if self._visible and self:_validate_all() then
-- Validation failed — do not close, do not invoke on_submit.
return
end
local results = self:results()
self:hide()
if self._on_submit then
@@ -244,6 +252,34 @@ function M:submit()
end
end
--- Run every input's validator (marking each as touched first) and render
--- results. Returns `true` if any input has an error.
function M:_validate_all()
local any_error = false
local first_bad = nil
for i, input in ipairs(self._inputs) do
if input.validator then
input._touched = true
local err = input.validator(input:value())
if err == "" then
err = nil
end
input._error = err
self:_render_validation(input)
if err and not first_bad then
first_bad = i
end
if err then
any_error = true
end
end
end
if first_bad then
self:_focus(first_bad)
end
return any_error
end
--- Cancel the form: close windows, invoke `on_cancel()` if provided.
function M:cancel()
self:hide()
@@ -252,6 +288,112 @@ function M:cancel()
end
end
--- Install validation autocmds for an input. No-op if the input has no
--- validator. Validation runs:
--- - on `WinLeave` (blurring the field marks it touched and validates)
--- - on `TextChanged` / `TextChangedI` IF the input is already touched
---
--- For `select` inputs, the user "touches" the field by picking an option;
--- `_render_display`'s `nvim_buf_set_lines` call also fires `TextChanged`
--- so the same path handles re-validation there.
function M:_install_validation(input)
if not input.validator then
return
end
local buf = input.buf
if not buf then
return
end
local form = self
local group = vim.api.nvim_create_augroup("InputFormValidate_" .. tostring(buf), { clear = true })
input._val_group = group
vim.api.nvim_create_autocmd("WinLeave", {
group = group,
buffer = buf,
callback = function()
input._touched = true
form:_validate_input(input)
end,
})
vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, {
group = group,
buffer = buf,
callback = function()
if input._touched then
form:_validate_input(input)
end
end,
})
-- For `select` inputs, changes happen while the dropdown buffer is current,
-- so `TextChanged` on the display buffer doesn't fire. The input exposes an
-- `_on_change` hook that we use to mark it touched and re-validate.
input._on_change = function()
input._touched = true
form:_validate_input(input)
end
end
--- Run the validator for one input and update its visual error state.
function M:_validate_input(input)
if not input.validator then
return
end
local err = input.validator(input:value())
if err == "" then
err = nil
end
input._error = err
self:_render_validation(input)
end
--- Apply/clear the red border, title, and footer error message on an input's
--- floating window based on its current `_error` state.
function M:_render_validation(input)
local win = input.win
if not (win and vim.api.nvim_win_is_valid(win)) then
return
end
local has_error = input._error ~= nil
if has_error then
vim.wo[win].winhl = table.concat({
"NormalFloat:InputFormField",
"FloatBorder:InputFormFieldErrorBorder",
"FloatTitle:InputFormFieldErrorTitle",
"FloatFooter:InputFormFieldError",
}, ",")
else
vim.wo[win].winhl = table.concat({
"NormalFloat:InputFormField",
"FloatBorder:InputFormFieldBorder",
"FloatTitle:InputFormFieldTitle",
}, ",")
end
-- Footer (error message) requires nvim 0.10+.
if vim.fn.has("nvim-0.10") == 1 then
local ok, cfg = pcall(vim.api.nvim_win_get_config, win)
if ok and cfg and cfg.relative ~= "" then
if has_error then
-- Truncate to window width with an ellipsis.
local max_w = (cfg.width or 0) - 2
local msg = input._error
if max_w > 3 and vim.fn.strdisplaywidth(msg) > max_w then
msg = vim.fn.strcharpart(msg, 0, max_w - 1) .. ""
end
cfg.footer = " " .. msg .. " "
cfg.footer_pos = "left"
else
cfg.footer = ""
end
pcall(vim.api.nvim_win_set_config, win, cfg)
end
end
end
--- Build a help-line string describing the active keymaps.
function M:_help_line()
local km = config.options.keymaps

View File

@@ -74,6 +74,9 @@ M.Form = Form
--- Expose the config module.
M.config = config
--- Expose the built-in validator library. See |input-form.validators|.
M.validators = require("input-form.validators")
-- Run once on first require so users/plugin devs don't have to call `setup()`
-- just to get working help tags.
register_helptags()

View File

@@ -17,7 +17,11 @@ function M.build(spec)
local t = spec.type or "text"
local impl = M.types[t]
assert(impl, "unknown input type: " .. tostring(t))
return impl.new(spec)
local input = impl.new(spec)
input.validator = spec.validator
input._touched = false
input._error = nil
return input
end
return M

View File

@@ -189,9 +189,14 @@ function M:open_dropdown()
end
local function confirm()
local row = vim.api.nvim_win_get_cursor(self.dropdown_win)[1]
self._selected_id = self.options[row].id
local new_id = self.options[row].id
local changed = new_id ~= self._selected_id
self._selected_id = new_id
self:_render_display()
self:close_dropdown()
if changed and self._on_change then
self._on_change()
end
end
map("<CR>", confirm)
map("<Esc>", function()
@@ -220,10 +225,14 @@ end
--- Programmatically set the selected option by id (used in tests & external callers).
function M:select_id(id)
local changed = id ~= self._selected_id
for _, opt in ipairs(self.options) do
if opt.id == id then
self._selected_id = id
self:_render_display()
if changed and self._on_change then
self._on_change()
end
return true
end
end

View File

@@ -0,0 +1,139 @@
--- Validators for form inputs.
---
--- A validator is a function `fun(value): string|nil`. It receives the
--- current input value and returns a non-empty error message string when
--- invalid, or `nil` / `""` when valid.
---
--- This module exposes factory functions for common validators plus a
--- `chain` combinator that runs several validators in order and returns the
--- first error.
---
---@tag input-form.validators
local M = {}
--- Require the field to have a non-empty value.
---@param msg string|nil Override error message.
---@return function
function M.non_empty(msg)
msg = msg or "This field is required"
return function(value)
if value == nil then
return msg
end
if type(value) == "string" and value == "" then
return msg
end
return nil
end
end
--- Require the value's length to be at least `n` characters.
---@param n integer
---@param msg string|nil
---@return function
function M.min_length(n, msg)
return function(value)
local s = type(value) == "string" and value or tostring(value or "")
if vim.fn.strchars(s) < n then
return msg or ("Must be at least " .. n .. " characters")
end
return nil
end
end
--- Require the value's length to be at most `n` characters.
---@param n integer
---@param msg string|nil
---@return function
function M.max_length(n, msg)
return function(value)
local s = type(value) == "string" and value or tostring(value or "")
if vim.fn.strchars(s) > n then
return msg or ("Must be at most " .. n .. " characters")
end
return nil
end
end
--- Require the value to match a Lua pattern.
---@param pattern string Lua pattern (not PCRE).
---@param msg string|nil
---@return function
function M.matches(pattern, msg)
return function(value)
local s = type(value) == "string" and value or tostring(value or "")
if not s:match(pattern) then
return msg or "Invalid format"
end
return nil
end
end
--- Require the value to parse as a number.
---@param msg string|nil
---@return function
function M.is_number(msg)
return function(value)
if value == nil or value == "" or tonumber(value) == nil then
return msg or "Must be a number"
end
return nil
end
end
--- Require the value to be one of the given choices (useful for text inputs
--- that must match a fixed allowlist; select inputs should use their
--- `options` list instead).
---@param choices table List of allowed values.
---@param msg string|nil
---@return function
function M.one_of(choices, msg)
return function(value)
for _, c in ipairs(choices) do
if c == value then
return nil
end
end
return msg or "Value is not allowed"
end
end
--- Wrap a predicate `fun(value): boolean` as a validator.
---@param predicate function
---@param msg string Error message to return when the predicate is false.
---@return function
function M.custom(predicate, msg)
return function(value)
if predicate(value) then
return nil
end
return msg
end
end
--- Combine multiple validators. Runs them in order and returns the first
--- non-empty error. Accepts either a list table or a varargs list.
---@param ... function|table
---@return function
function M.chain(...)
local validators = { ... }
if
#validators == 1
and type(validators[1]) == "table"
and type(validators[1][1]) == "function"
then
validators = validators[1]
end
return function(value)
for _, v in ipairs(validators) do
local err = v(value)
if err and err ~= "" then
return err
end
end
return nil
end
end
return M

View File

@@ -5,4 +5,5 @@ require("mini.doc").setup()
MiniDoc.generate({
"lua/input-form/init.lua",
"lua/input-form/config.lua",
"lua/input-form/validators.lua",
}, "doc/input-form.txt")

View File

@@ -187,6 +187,81 @@ T["form"]["multiline inputs do not rebind <CR>"] = function()
eq(has_cr, false)
end
T["form"]["validation"] = MiniTest.new_set()
local function make_validated_form(child)
child.lua([[
local V = require('input-form.validators')
_G.submit_result = nil
_G.vf = require('input-form').create_form({
inputs = {
{ name = 'id', label = 'Enter ID', type = 'text',
default = '',
validator = V.chain(V.non_empty(), V.min_length(3)) },
{ name = 'body', label = 'Body', type = 'multiline', default = '' },
},
on_submit = function(r) _G.submit_result = r end,
})
_G.vf:show()
]])
end
T["form"]["validation"]["no error shown before the field is touched"] = function()
make_validated_form(child)
eq(child.lua_get([[_G.vf._inputs[1]._touched]]), false)
eq(child.lua_get([[_G.vf._inputs[1]._error]]), vim.NIL)
end
T["form"]["validation"]["blur marks touched and runs validator"] = function()
make_validated_form(child)
-- Move focus from input 1 to input 2 — fires WinLeave on input 1.
child.lua([[_G.vf:focus_next()]])
eq(child.lua_get([[_G.vf._inputs[1]._touched]]), true)
eq(child.lua_get([[_G.vf._inputs[1]._error]]), "This field is required")
end
T["form"]["validation"]["re-validates on change after touched"] = function()
make_validated_form(child)
child.lua([[_G.vf:focus_next()]]) -- blur input 1, errors
child.lua([[_G.vf:focus_prev()]]) -- back to input 1
child.lua([[vim.api.nvim_buf_set_lines(_G.vf._inputs[1].buf, 0, -1, false, { 'ab' })]])
eq(child.lua_get([[_G.vf._inputs[1]._error]]), "Must be at least 3 characters")
child.lua([[vim.api.nvim_buf_set_lines(_G.vf._inputs[1].buf, 0, -1, false, { 'abcd' })]])
eq(child.lua_get([[_G.vf._inputs[1]._error]]), vim.NIL)
end
T["form"]["validation"]["submit blocked when any input is invalid"] = function()
make_validated_form(child)
child.lua([[_G.vf:submit()]])
-- submit should NOT have run on_submit
eq(child.lua_get([[_G.submit_result]]), vim.NIL)
-- form still visible
eq(child.lua_get([[_G.vf._visible]]), true)
-- first input is now touched and errored
eq(child.lua_get([[_G.vf._inputs[1]._touched]]), true)
eq(child.lua_get([[_G.vf._inputs[1]._error]]), "This field is required")
-- focus moved to the first invalid input
eq(child.lua_get([[_G.vf._focus_idx]]), 1)
end
T["form"]["validation"]["submit proceeds once all inputs are valid"] = function()
make_validated_form(child)
child.lua([[vim.api.nvim_buf_set_lines(_G.vf._inputs[1].buf, 0, -1, false, { 'valid id' })]])
child.lua([[_G.vf:submit()]])
eq(child.lua_get([[type(_G.submit_result)]]), "table")
eq(child.lua_get([[_G.submit_result.id]]), "valid id")
eq(child.lua_get([[_G.vf._visible]]), false)
end
T["form"]["validation"]["inputs without a validator are never marked touched"] = function()
make_validated_form(child)
-- Second input has no validator.
child.lua([[_G.vf:focus_next()]])
child.lua([[_G.vf:focus_prev()]])
eq(child.lua_get([[_G.vf._inputs[2]._touched]]), false)
eq(child.lua_get([[_G.vf._inputs[2]._error]]), vim.NIL)
end
T["form"]["keymaps are installed on each input buffer"] = function()
child.lua([[_G.f = _G.make_form(); _G.f:show()]])
-- <Tab> should be mapped in normal mode on the first input's buffer.

89
tests/test_validators.lua Normal file
View File

@@ -0,0 +1,89 @@
local helpers = dofile("tests/helpers.lua")
local MiniTest = require("mini.test")
local child = helpers.new_child_neovim()
local eq = helpers.expect.equality
local T = MiniTest.new_set({
hooks = {
pre_case = function()
child.restart({ "-u", "scripts/minimal_init.lua" })
child.lua([[V = require('input-form.validators')]])
end,
post_once = child.stop,
},
})
T["validators"] = MiniTest.new_set()
T["validators"]["non_empty"] = function()
eq(child.lua_get([[V.non_empty()("")]]), "This field is required")
eq(child.lua_get([[V.non_empty()(nil)]]), "This field is required")
eq(child.lua_get([[V.non_empty()("ok")]]), vim.NIL)
eq(child.lua_get([[V.non_empty("nope")("")]]), "nope")
end
T["validators"]["min_length"] = function()
eq(child.lua_get([[V.min_length(3)("ab")]]), "Must be at least 3 characters")
eq(child.lua_get([[V.min_length(3)("abc")]]), vim.NIL)
eq(child.lua_get([[V.min_length(3)("abcd")]]), vim.NIL)
eq(child.lua_get([[V.min_length(3, "too short")("a")]]), "too short")
end
T["validators"]["max_length"] = function()
eq(child.lua_get([[V.max_length(3)("abcd")]]), "Must be at most 3 characters")
eq(child.lua_get([[V.max_length(3)("abc")]]), vim.NIL)
eq(child.lua_get([[V.max_length(3)("")]]), vim.NIL)
end
T["validators"]["matches"] = function()
eq(child.lua_get([[V.matches('^%d+$')("abc")]]), "Invalid format")
eq(child.lua_get([[V.matches('^%d+$')("123")]]), vim.NIL)
eq(child.lua_get([[V.matches('^%d+$', 'digits only')("x")]]), "digits only")
end
T["validators"]["is_number"] = function()
eq(child.lua_get([[V.is_number()("abc")]]), "Must be a number")
eq(child.lua_get([[V.is_number()("")]]), "Must be a number")
eq(child.lua_get([[V.is_number()("42")]]), vim.NIL)
eq(child.lua_get([[V.is_number()("3.14")]]), vim.NIL)
end
T["validators"]["one_of"] = function()
eq(child.lua_get([[V.one_of({ 'a', 'b' })('c')]]), "Value is not allowed")
eq(child.lua_get([[V.one_of({ 'a', 'b' })('a')]]), vim.NIL)
end
T["validators"]["custom"] = function()
eq(child.lua_get([[V.custom(function(v) return v == 'yes' end, 'say yes')('no')]]), "say yes")
eq(child.lua_get([[V.custom(function(v) return v == 'yes' end, 'say yes')('yes')]]), vim.NIL)
end
T["validators"]["chain returns first error (varargs)"] = function()
eq(child.lua_get([[V.chain(V.non_empty(), V.min_length(3))("")]]), "This field is required")
eq(
child.lua_get([[V.chain(V.non_empty(), V.min_length(3))("ab")]]),
"Must be at least 3 characters"
)
eq(child.lua_get([[V.chain(V.non_empty(), V.min_length(3))("abcd")]]), vim.NIL)
end
T["validators"]["chain accepts a list"] = function()
eq(
child.lua_get([[V.chain({ V.non_empty(), V.min_length(3) })("ab")]]),
"Must be at least 3 characters"
)
end
T["validators"]["chain treats empty string return as success"] = function()
-- A validator that returns "" should be treated as "no error".
eq(
child.lua_get([[V.chain(
function(v) return "" end,
V.non_empty()
)("")]]),
"This field is required"
)
end
return T