Skip to content

Commit 7abc4b0

Browse files
committed
feat: input validators
1 parent ea5bbd8 commit 7abc4b0

11 files changed

Lines changed: 660 additions & 3 deletions

File tree

README.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,92 @@ All inputs share `name` (string, required — the key in the result table) and
139139

140140
`value()` returns the selected `id` (not the label).
141141

142+
## Validation
143+
144+
Each input spec accepts an optional `validator` function:
145+
146+
```lua
147+
validator = fun(value: any): string|nil
148+
```
149+
150+
Return a non-empty error message string to mark the input invalid, or `nil` /
151+
`""` when valid. The error message is shown in the input's bottom border
152+
(red), and the border + label turn red too. Validation runs:
153+
154+
- **On blur** — the first time the user leaves the field it is marked
155+
"touched" and the validator runs. Nothing is shown before that.
156+
- **On change** — once touched, each buffer change re-runs the validator.
157+
- **On submit**`form:submit()` force-validates every input (touched or
158+
not). If any input has an error, submission is blocked, all errors are
159+
rendered, and focus moves to the first invalid input.
160+
161+
### Built-in validators
162+
163+
```lua
164+
local V = require("input-form").validators
165+
166+
V.non_empty([msg]) -- require a non-empty value
167+
V.min_length(n, [msg]) -- at least `n` characters
168+
V.max_length(n, [msg]) -- at most `n` characters
169+
V.matches(lua_pattern, [msg]) -- match a Lua pattern
170+
V.is_number([msg]) -- tonumber() must succeed
171+
V.one_of({ "a", "b", ... }, [msg]) -- value must be in the list
172+
V.custom(predicate, msg) -- wrap a `fun(v): boolean` predicate
173+
V.chain(v1, v2, ...) -- run validators in order, first error wins
174+
```
175+
176+
Example:
177+
178+
```lua
179+
local f = require("input-form")
180+
local V = f.validators
181+
182+
f.create_form({
183+
inputs = {
184+
{
185+
name = "id",
186+
label = "Enter ID",
187+
type = "text",
188+
validator = V.chain(
189+
V.non_empty(),
190+
V.min_length(3),
191+
V.matches("^[%w_-]+$", "Only letters, digits, - and _")
192+
),
193+
},
194+
{
195+
name = "age",
196+
label = "Age",
197+
type = "text",
198+
validator = V.chain(V.non_empty(), V.is_number()),
199+
},
200+
},
201+
on_submit = function(results)
202+
vim.print(results) -- only runs if every validator passes
203+
end,
204+
}):show()
205+
```
206+
207+
Custom validators are just functions — no need to use the builder helpers if
208+
you'd rather write one inline:
209+
210+
```lua
211+
validator = function(value)
212+
if value == "admin" then
213+
return "Username 'admin' is reserved"
214+
end
215+
end
216+
```
217+
218+
### Highlight groups
219+
220+
Error rendering uses three highlight groups. Override them to re-theme:
221+
222+
```lua
223+
vim.api.nvim_set_hl(0, "InputFormFieldError", { fg = "#ff5555" })
224+
vim.api.nvim_set_hl(0, "InputFormFieldErrorBorder", { fg = "#ff5555" })
225+
vim.api.nvim_set_hl(0, "InputFormFieldErrorTitle", { fg = "#ff5555", bold = true })
226+
```
227+
142228
## Configuration
143229

144230
Defaults:

doc/input-form.txt

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ Expose the Form class for advanced use.
6969
`M.config`
7070
Expose the config module.
7171

72+
------------------------------------------------------------------------------
73+
*M.validators*
74+
`M.validators`
75+
Expose the built-in validator library. See |input-form.validators|.
76+
7277

7378
==============================================================================
7479
------------------------------------------------------------------------------
@@ -137,4 +142,98 @@ Return ~
137142
`(table)`
138143

139144

145+
==============================================================================
146+
------------------------------------------------------------------------------
147+
*input-form.validators*
148+
Validators for form inputs.
149+
150+
A validator is a function `fun(value): string|nil`. It receives the
151+
current input value and returns a non-empty error message string when
152+
invalid, or `nil` / `""` when valid.
153+
154+
This module exposes factory functions for common validators plus a
155+
`chain` combinator that runs several validators in order and returns the
156+
first error.
157+
158+
------------------------------------------------------------------------------
159+
*M.non_empty()*
160+
`M.non_empty`({msg})
161+
Require the field to have a non-empty value.
162+
Parameters ~
163+
{msg} `(string|nil)` Override error message.
164+
Return ~
165+
`(function)`
166+
167+
------------------------------------------------------------------------------
168+
*M.min_length()*
169+
`M.min_length`({n}, {msg})
170+
Require the value's length to be at least `n` characters.
171+
Parameters ~
172+
{n} `(integer)`
173+
{msg} `(string|nil)`
174+
Return ~
175+
`(function)`
176+
177+
------------------------------------------------------------------------------
178+
*M.max_length()*
179+
`M.max_length`({n}, {msg})
180+
Require the value's length to be at most `n` characters.
181+
Parameters ~
182+
{n} `(integer)`
183+
{msg} `(string|nil)`
184+
Return ~
185+
`(function)`
186+
187+
------------------------------------------------------------------------------
188+
*M.matches()*
189+
`M.matches`({pattern}, {msg})
190+
Require the value to match a Lua pattern.
191+
Parameters ~
192+
{pattern} `(string)` Lua pattern (not PCRE).
193+
{msg} `(string|nil)`
194+
Return ~
195+
`(function)`
196+
197+
------------------------------------------------------------------------------
198+
*M.is_number()*
199+
`M.is_number`({msg})
200+
Require the value to parse as a number.
201+
Parameters ~
202+
{msg} `(string|nil)`
203+
Return ~
204+
`(function)`
205+
206+
------------------------------------------------------------------------------
207+
*M.one_of()*
208+
`M.one_of`({choices}, {msg})
209+
Require the value to be one of the given choices (useful for text inputs
210+
that must match a fixed allowlist; select inputs should use their
211+
`options` list instead).
212+
Parameters ~
213+
{choices} `(table)` List of allowed values.
214+
{msg} `(string|nil)`
215+
Return ~
216+
`(function)`
217+
218+
------------------------------------------------------------------------------
219+
*M.custom()*
220+
`M.custom`({predicate}, {msg})
221+
Wrap a predicate `fun(value): boolean` as a validator.
222+
Parameters ~
223+
{predicate} `(function)`
224+
{msg} `(string)` Error message to return when the predicate is false.
225+
Return ~
226+
`(function)`
227+
228+
------------------------------------------------------------------------------
229+
*M.chain()*
230+
`M.chain`({...})
231+
Combine multiple validators. Runs them in order and returns the first
232+
non-empty error. Accepts either a list table or a varargs list.
233+
Parameters ~
234+
{...} `(function|table)`
235+
Return ~
236+
`(function)`
237+
238+
140239
vim:tw=78:ts=8:noet:ft=help:norl:

doc/tags

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
Default input-form.txt /*Default*
22
M.Form input-form.txt /*M.Form*
3+
M.chain() input-form.txt /*M.chain()*
34
M.config input-form.txt /*M.config*
5+
M.custom() input-form.txt /*M.custom()*
6+
M.is_number() input-form.txt /*M.is_number()*
7+
M.matches() input-form.txt /*M.matches()*
8+
M.max_length() input-form.txt /*M.max_length()*
9+
M.min_length() input-form.txt /*M.min_length()*
10+
M.non_empty() input-form.txt /*M.non_empty()*
11+
M.one_of() input-form.txt /*M.one_of()*
12+
M.validators input-form.txt /*M.validators*
413
input-form.config input-form.txt /*input-form.config*
514
input-form.config.setup input-form.txt /*input-form.config.setup*
615
input-form.create_form input-form.txt /*input-form.create_form*
716
input-form.nvim input-form.txt /*input-form.nvim*
817
input-form.setup input-form.txt /*input-form.setup*
18+
input-form.validators input-form.txt /*input-form.validators*
919
register_helptags() input-form.txt /*register_helptags()*
1020
values: input-form.txt /*values:*

lua/input-form/form.lua

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,15 +235,51 @@ function M:results()
235235
return out
236236
end
237237

238-
--- Submit the form: gather values, close windows, invoke `on_submit(results)`.
238+
--- Submit the form: gather values, run validators, and invoke
239+
--- `on_submit(results)` only if everything validates. If any input has an
240+
--- error, submission is blocked, all inputs are force-validated (so the user
241+
--- sees every error, including ones on untouched fields), and focus moves to
242+
--- the first invalid input.
239243
function M:submit()
244+
if self._visible and self:_validate_all() then
245+
-- Validation failed — do not close, do not invoke on_submit.
246+
return
247+
end
240248
local results = self:results()
241249
self:hide()
242250
if self._on_submit then
243251
self._on_submit(results)
244252
end
245253
end
246254

255+
--- Run every input's validator (marking each as touched first) and render
256+
--- results. Returns `true` if any input has an error.
257+
function M:_validate_all()
258+
local any_error = false
259+
local first_bad = nil
260+
for i, input in ipairs(self._inputs) do
261+
if input.validator then
262+
input._touched = true
263+
local err = input.validator(input:value())
264+
if err == "" then
265+
err = nil
266+
end
267+
input._error = err
268+
self:_render_validation(input)
269+
if err and not first_bad then
270+
first_bad = i
271+
end
272+
if err then
273+
any_error = true
274+
end
275+
end
276+
end
277+
if first_bad then
278+
self:_focus(first_bad)
279+
end
280+
return any_error
281+
end
282+
247283
--- Cancel the form: close windows, invoke `on_cancel()` if provided.
248284
function M:cancel()
249285
self:hide()
@@ -252,6 +288,112 @@ function M:cancel()
252288
end
253289
end
254290

291+
--- Install validation autocmds for an input. No-op if the input has no
292+
--- validator. Validation runs:
293+
--- - on `WinLeave` (blurring the field marks it touched and validates)
294+
--- - on `TextChanged` / `TextChangedI` IF the input is already touched
295+
---
296+
--- For `select` inputs, the user "touches" the field by picking an option;
297+
--- `_render_display`'s `nvim_buf_set_lines` call also fires `TextChanged`
298+
--- so the same path handles re-validation there.
299+
function M:_install_validation(input)
300+
if not input.validator then
301+
return
302+
end
303+
local buf = input.buf
304+
if not buf then
305+
return
306+
end
307+
local form = self
308+
local group = vim.api.nvim_create_augroup("InputFormValidate_" .. tostring(buf), { clear = true })
309+
input._val_group = group
310+
311+
vim.api.nvim_create_autocmd("WinLeave", {
312+
group = group,
313+
buffer = buf,
314+
callback = function()
315+
input._touched = true
316+
form:_validate_input(input)
317+
end,
318+
})
319+
320+
vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, {
321+
group = group,
322+
buffer = buf,
323+
callback = function()
324+
if input._touched then
325+
form:_validate_input(input)
326+
end
327+
end,
328+
})
329+
330+
-- For `select` inputs, changes happen while the dropdown buffer is current,
331+
-- so `TextChanged` on the display buffer doesn't fire. The input exposes an
332+
-- `_on_change` hook that we use to mark it touched and re-validate.
333+
input._on_change = function()
334+
input._touched = true
335+
form:_validate_input(input)
336+
end
337+
end
338+
339+
--- Run the validator for one input and update its visual error state.
340+
function M:_validate_input(input)
341+
if not input.validator then
342+
return
343+
end
344+
local err = input.validator(input:value())
345+
if err == "" then
346+
err = nil
347+
end
348+
input._error = err
349+
self:_render_validation(input)
350+
end
351+
352+
--- Apply/clear the red border, title, and footer error message on an input's
353+
--- floating window based on its current `_error` state.
354+
function M:_render_validation(input)
355+
local win = input.win
356+
if not (win and vim.api.nvim_win_is_valid(win)) then
357+
return
358+
end
359+
local has_error = input._error ~= nil
360+
361+
if has_error then
362+
vim.wo[win].winhl = table.concat({
363+
"NormalFloat:InputFormField",
364+
"FloatBorder:InputFormFieldErrorBorder",
365+
"FloatTitle:InputFormFieldErrorTitle",
366+
"FloatFooter:InputFormFieldError",
367+
}, ",")
368+
else
369+
vim.wo[win].winhl = table.concat({
370+
"NormalFloat:InputFormField",
371+
"FloatBorder:InputFormFieldBorder",
372+
"FloatTitle:InputFormFieldTitle",
373+
}, ",")
374+
end
375+
376+
-- Footer (error message) requires nvim 0.10+.
377+
if vim.fn.has("nvim-0.10") == 1 then
378+
local ok, cfg = pcall(vim.api.nvim_win_get_config, win)
379+
if ok and cfg and cfg.relative ~= "" then
380+
if has_error then
381+
-- Truncate to window width with an ellipsis.
382+
local max_w = (cfg.width or 0) - 2
383+
local msg = input._error
384+
if max_w > 3 and vim.fn.strdisplaywidth(msg) > max_w then
385+
msg = vim.fn.strcharpart(msg, 0, max_w - 1) .. ""
386+
end
387+
cfg.footer = " " .. msg .. " "
388+
cfg.footer_pos = "left"
389+
else
390+
cfg.footer = ""
391+
end
392+
pcall(vim.api.nvim_win_set_config, win, cfg)
393+
end
394+
end
395+
end
396+
255397
--- Build a help-line string describing the active keymaps.
256398
function M:_help_line()
257399
local km = config.options.keymaps

0 commit comments

Comments
 (0)