diff --git a/.emmyrc.json b/.emmyrc.json
new file mode 100644
index 0000000..0e12414
--- /dev/null
+++ b/.emmyrc.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://raw.githubusercontent.com/EmmyLuaLs/emmylua-analyzer-rust/refs/heads/main/crates/emmylua_code_analysis/resources/schema.json",
+ "workspace": {
+ "ignoreDir": [
+ ".rocks",
+ "build",
+ "tmp"
+ ]
+ },
+ "runtime": {
+ "version": "LuaJIT"
+ },
+ "diagnostics": {
+ "disable": [
+ "need-check-nil",
+ "redefined-local",
+ "unresolved-require"
+ ],
+ "globals": [
+ "box"
+ ]
+ }
+}
diff --git a/.luacheckrc b/.luacheckrc
index 57c02f0..7f11a70 100644
--- a/.luacheckrc
+++ b/.luacheckrc
@@ -2,3 +2,12 @@ include_files = {'**/*.lua', '*.luacheckrc', '*.rockspec'}
exclude_files = {'.rocks/', 'tmp/', 'build/'}
max_line_length = 120
redefined = false
+globals = {
+ "box",
+}
+ignore = {
+ -- Accessing an undefined field of a global variable
.
+ "143/table",
+ -- Unused variable with `_` prefix.
+ "212/_.*",
+}
diff --git a/checks.lua b/checks.lua
index 35060a8..c61997b 100644
--- a/checks.lua
+++ b/checks.lua
@@ -1,3 +1,12 @@
+---The `checks` module provides the ability to check the types of arguments
+---passed to a Lua function. It is designed to reveal mistakes in code, not to
+---validate user input.
+---
+---The returned module table is callable: `checks(type_1, ...)` is equivalent
+---to `checks.checks(type_1, ...)`. It exposes the `checks` function and the
+---`_VERSION` string field.
+---
+---@alias checks.qualifier string|table
local ffi = require('ffi')
ffi.cdef[[
@@ -18,14 +27,14 @@ local _qualifiers_cache = {
-- },
}
---- Check that string (or substring) starts with given string
--- Optionally restricting the matching with the given offsets
--- @function startswith
--- @string inp original string
--- @string head the substring to check against
--- @int[opt] _start start index of matching boundary
--- @int[opt] _end end index of matching boundary
--- @returns boolean
+---Check that the given string (or substring) starts with the given string,
+---optionally restricting the matching with the given offsets.
+---
+---@param inp string The original string.
+---@param head string The substring to check against.
+---@param _start? integer Start index of the matching boundary (default: `1`).
+---@param _end? integer End index of the matching boundary (default: `#inp`).
+---@return boolean `true` if `inp` starts with `head`, `false` otherwise.
local function startswith(inp, head, _start, _end)
if type(inp) ~= 'string' then
error(err_string_arg:format(1, 'string.startswith', 'string',
@@ -69,6 +78,13 @@ local function startswith(inp, head, _start, _end)
end
+---Check that a value conforms to the given string type qualifier.
+---
+---@param value any The value to check.
+---@param expected_type string The string type qualifier, e.g. `'string'`,
+--- `'?number|string'`, or `'?'` for any type.
+---@return boolean? `true` if the value conforms to the type qualifier.
+---@return string? error_message The formatted error message if the check fails.
local function check_string_type(value, expected_type)
-- 1. Check any value.
if expected_type == '?' then
@@ -76,6 +92,7 @@ local function check_string_type(value, expected_type)
end
-- 2. Parse type qualifier
+ ---@type { [integer]: string, optional: boolean }?
local qualifier = _qualifiers_cache[expected_type]
if qualifier == nil then
qualifier = { optional = false }
@@ -123,6 +140,11 @@ local function check_string_type(value, expected_type)
)
end
+---Format a table key for an error message: `.key` for strings and `[n]`
+---for numbers.
+---
+---@param key any The table key.
+---@return string The formatted key name.
local function keyname_fmt(key)
if type(key) == 'string' then
return string.format('.%s', key)
@@ -133,6 +155,12 @@ local function keyname_fmt(key)
end
end
+---Check that a table conforms to the given table type qualifier.
+---
+---@param tbl table? The table to check.
+---@param expected_fields table The table type qualifier.
+---@return boolean? `true` if the table conforms to the type qualifier.
+---@return string? error_message The formatted error message if the check fails.
local function check_table_type(tbl, expected_fields)
if tbl == nil then
tbl = nil
@@ -144,11 +172,13 @@ local function check_table_type(tbl, expected_fields)
if type(expected_type) == 'string' then
local ok, efmt = check_string_type(value, expected_type)
if not ok then
+ ---@cast efmt string
return nil, string.format(efmt, '%s'..keyname_fmt(expected_key), '%s')
end
elseif type(expected_type) == 'table' then
local ok, efmt = check_string_type(value, '?table')
if not ok then
+ ---@cast efmt string
return nil, string.format(efmt, '%s'..keyname_fmt(expected_key), '%s')
end
@@ -159,6 +189,7 @@ local function check_table_type(tbl, expected_fields)
local ok, efmt = check_table_type(value, expected_type)
if not ok then
+ ---@cast efmt string
return nil, string.format(efmt, '%s'..keyname_fmt(expected_key), '%s')
end
else
@@ -187,12 +218,27 @@ local function check_table_type(tbl, expected_fields)
return true
end
+---Checks that the arguments of the calling function conform to the specified
+---types. Must be called at the top of the function being checked; each type
+---qualifier corresponds to the argument at the same position.
+---
+---String qualifiers check Lua types, Tarantool-specific types (`uint64`,
+---`int64`, `decimal`, `uuid`, etc.), metatable `__type` values, and custom
+---checker names; they can be combined into union types (`'number|string'`) and
+---made optional (`'?string'`). Table qualifiers validate the values of a table
+---argument.
+---
+---@param ... checks.qualifier|number Type qualifiers, one per argument to
+--- check. The first argument may also be a stack level (used internally).
local function checks(...)
local skip = 0
+ ---@type integer
local level = 1
- if type(...) == 'number' then
- level = ...
+ local first = ...
+ if type(first) == 'number' then
+ ---@cast first integer
+ level = first
skip = 1
end
level = level + 1 -- escape the checks level
@@ -216,6 +262,7 @@ local function checks(...)
local ok, efmt = check_string_type(value, expected_type)
if not ok then
local info = debug.getinfo(level, 'nl')
+ ---@cast efmt string
local err = string.format(efmt, '#'..tostring(i), info.name)
error(err, level)
end
@@ -224,18 +271,23 @@ local function checks(...)
local ok, efmt = check_string_type(value, '?table')
if not ok then
local info = debug.getinfo(level, 'nl')
+ ---@cast efmt string
local err = string.format(efmt, '#'..tostring(i), info.name)
error(err, level)
end
if rawget(_G, '_checks_v2_compatible') and value == nil then
value = {}
+ -- In emmylua-check 0.22.0 the debug.setlocal index
+ -- parameter is mistyped as `string`; it is an integer.
+ ---@diagnostic disable-next-line: param-type-mismatch
debug.setlocal(level, i, value)
end
local ok, efmt = check_table_type(value, expected_type)
if not ok then
local info = debug.getinfo(level, 'nl')
+ ---@cast efmt string
local err = string.format(efmt, argname, info.name)
error(err, level)
end
@@ -249,15 +301,39 @@ local function checks(...)
end
end
+---The `checks` function is also available as a global, so it can be used
+---without loading the module (Tarantool 2.11.0 and later).
rawset(_G, 'checks', checks)
+---The `checkers` global table provides access to checkers for different types.
+---It can be extended with custom checkers that perform arbitrary validations.
+---
+---@class checks.checkers
+---@field datetime fun(arg: any): boolean Check that the value is a datetime object.
+---@field decimal fun(arg: any): boolean Check that the value has the decimal type.
+---@field error fun(arg: any): boolean Check that the value is an error object.
+---@field int64 fun(arg: any): boolean Check that the value is an int64 value.
+---@field interval fun(arg: any): boolean Check that the value is an interval object.
+---@field tuple fun(arg: any): boolean Check that the value is a tuple.
+---@field uint64 fun(arg: any): boolean Check that the value is a uint64 value.
+---@field uuid fun(arg: any): boolean Check that the value is a uuid object.
+---@field uuid_bin fun(arg: any): boolean Check that the value is a uuid as a 16-byte binary string.
+---@field uuid_str fun(arg: any): boolean Check that the value is a uuid as a 36-byte hexadecimal string.
local checkers = rawget(_G, 'checkers') or {}
rawset(_G, 'checkers', checkers)
+---When set to `true`, substitutes `nil` table arguments with empty tables for
+---backward compatibility with v2.1.
+---@type boolean
local _checks_v2_compatible = rawget(_G, '_checks_v2_compatible') or false
rawset(_G, '_checks_v2_compatible', _checks_v2_compatible)
-local ffi = require('ffi')
+---Check whether the specified value is a `uint64` value: an integer Lua number
+---in the range from 0 to 2^53-1 (inclusive), a cdata `ctype`, or a
+---cdata `ctype` in the range from 0 to `LLONG_MAX`.
+---
+---@param arg any The value to check.
+---@return boolean `true` if the value is a `uint64` value, `false` otherwise.
function checkers.uint64(arg)
if type(arg) == 'number' then
-- Double floating point format has 52 fraction bits
@@ -277,6 +353,13 @@ function checkers.uint64(arg)
return false
end
+---Check whether the specified value is an `int64` value: an integer Lua
+---number in the range from -2^53+1 to 2^53-1 (inclusive), a cdata
+---`ctype`, or a cdata `ctype` in the range from 0 to
+---`LLONG_MAX`.
+---
+---@param arg any The value to check.
+---@return boolean `true` if the value is an `int64` value, `false` otherwise.
function checkers.int64(arg)
if type(arg) == 'number' then
return (arg > -2^53) and (arg < 2^53) and (math.floor(arg) == arg)
@@ -293,6 +376,7 @@ function checkers.int64(arg)
return false
end
+---Check whether the specified value is a tuple.
local has_box = rawget(_G, 'box') ~= nil
if has_box and box.tuple ~= nil then
checkers.tuple = box.tuple.is
@@ -303,11 +387,19 @@ if has_decimal then
-- There is a decimal.is_decimal check since 2.4, but we
-- reimplement it here to support older versions which have decimal.
local cdata_t = ffi.typeof(decimal.new(0))
+ ---Check whether the specified value has the decimal type.
+ ---
+ ---@param arg any The value to check.
+ ---@return boolean `true` if the value has the decimal type, `false` otherwise.
checkers.decimal = function(arg)
return ffi.istype(cdata_t, arg)
end
end
+---Register a checker for a cdata type checked via FFI.
+---
+---@param checks_type string The name of the checker to register in `checkers`.
+---@param c_type string The C type name, e.g. `'struct tt_uuid'`.
local function add_ffi_type_checker(checks_type, c_type)
local has_cdata_t, cdata_t = pcall(ffi.typeof, c_type)
if has_cdata_t then
@@ -322,6 +414,11 @@ end
-- https://github.com/tarantool/tarantool/blob/7682d34162be34648172d91008e9185301bce8f6/src/lua/uuid.lua#L29
add_ffi_type_checker('uuid', 'struct tt_uuid')
+---Check whether the specified value is a uuid represented by a 36-byte
+---hexadecimal string.
+---
+---@param arg any The value to check.
+---@return boolean `true` if the value is a uuid string, `false` otherwise.
function checkers.uuid_str(arg)
if type(arg) == 'string' and #arg == 36 then
local match = arg:match(
@@ -339,6 +436,11 @@ function checkers.uuid_str(arg)
end
end
+---Check whether the specified value is a uuid represented by a 16-byte binary
+---string.
+---
+---@param arg any The value to check.
+---@return boolean `true` if the value is a uuid binary string, `false` otherwise.
function checkers.uuid_bin(arg)
if type(arg) == 'string' and #arg == 16 then
return true
@@ -349,6 +451,7 @@ end
add_ffi_type_checker('error', 'struct error')
+---Check whether the specified value is a datetime object.
local has_datetime, datetime = pcall(require, 'datetime')
if has_datetime then
checkers.datetime = datetime.is_datetime
@@ -356,7 +459,7 @@ end
add_ffi_type_checker('interval', 'struct interval')
-return setmetatable(
+local M = setmetatable(
{
checks = checks,
_VERSION = require('checks.version'),
@@ -368,3 +471,5 @@ return setmetatable(
end
}
)
+
+return M
diff --git a/test/perftest.lua b/test/perftest.lua
index 13502e7..1a83176 100755
--- a/test/perftest.lua
+++ b/test/perftest.lua
@@ -134,7 +134,7 @@ for _, case in pairs(cases) do
local name = ('test_%s_%s'):format(json.encode(case.check), case.argtype):gsub('%.', '_')
g[name] = function(_)
- local fn = function(arg) -- luacheck: no unused args
+ local fn = function(_arg)
checks(case.check)
end
diff --git a/test/test.lua b/test/test.lua
index 1f55aa3..652d5b6 100755
--- a/test/test.lua
+++ b/test/test.lua
@@ -19,41 +19,41 @@ end
local testdata = {}
local _l_number_optstring = 2 + debug.getinfo(1).currentline
-function testdata.fn_number_optstring(arg1, arg2) -- luacheck: no unused args
+function testdata.fn_number_optstring(_arg1, _arg2)
checks('number', '?string')
end
local _l_number_or_string = 2 + debug.getinfo(1).currentline
-function testdata.fn_number_or_string(arg1) -- luacheck: no unused args
+function testdata.fn_number_or_string(_arg1)
checks('number|string')
end
local _l_positive_number = 2 + debug.getinfo(1).currentline
-function testdata.fn_positive_number(arg1) -- luacheck: no unused args
+function testdata.fn_positive_number(_arg1)
checks('positive_number')
end
-function testdata.fn_anytype(arg1) -- luacheck: no unused args
+function testdata.fn_anytype(_arg1)
checks('?')
end
local _l_nil_or_number_or_string = 2 + debug.getinfo(1).currentline
-function testdata.fn_nil_or_number_or_string(arg1) -- luacheck: no unused args
+function testdata.fn_nil_or_number_or_string(_arg1)
checks('nil|number|string')
end
local _l_optnumber_or_optstring = 2 + debug.getinfo(1).currentline
-function testdata.fn_optnumber_or_optstring(arg1) -- luacheck: no unused args
+function testdata.fn_optnumber_or_optstring(_arg1)
checks('?number|?string')
end
local _l_varargs = 2 + debug.getinfo(1).currentline
-function testdata.fn_varargs(arg1, ...) -- luacheck: no unused args
+function testdata.fn_varargs(_arg1, ...) -- luacheck: ignore 212
checks('string')
end
local _l_options = 2 + debug.getinfo(1).currentline
-function testdata.fn_options(options) -- luacheck: no unused args
+function testdata.fn_options(_options)
checks({
mystring = '?string',
mynumber = '?number',
@@ -61,17 +61,17 @@ function testdata.fn_options(options) -- luacheck: no unused args
end
local _l_array = 2 + debug.getinfo(1).currentline
-function testdata.fn_array(array) -- luacheck: no unused args
+function testdata.fn_array(_array)
checks({'number', 'number'})
end
local _l_table = 2 + debug.getinfo(1).currentline
-function testdata.fn_table(table) -- luacheck: no unused args
+function testdata.fn_table(_table)
checks({mykey = 'number'})
end
local _l_inception = 2 + debug.getinfo(1).currentline
-function testdata.fn_inception(options) -- luacheck: no unused args
+function testdata.fn_inception(_options)
checks({
we = {
need = {
@@ -90,27 +90,27 @@ local function deepchecks()
end
local _l_deepcheck = 2 + debug.getinfo(1).currentline
-function testdata.fn_deepcheck(arg1) -- luacheck: no unused args
+function testdata.fn_deepcheck(_arg1)
deepchecks()
end
local _l_excess_checks = 2 + debug.getinfo(1).currentline
-function testdata.fn_excess_checks(arg1) -- luacheck: no unused args
+function testdata.fn_excess_checks(_arg1)
checks('?number', '?string')
end
local _l_missing_checks = 2 + debug.getinfo(1).currentline
-function testdata.fn_missing_checks(arg1, arg2) -- luacheck: no unused args
+function testdata.fn_missing_checks(_arg1, _arg2)
checks('?number')
end
local _l_bad_check_type_1 = 2 + debug.getinfo(1).currentline
-function testdata.bad_check_type_1(arg1, arg2) -- luacheck: no unused args
+function testdata.bad_check_type_1(_arg1, _arg2)
checks("?string", 5)
end
local _l_bad_check_type_2 = 2 + debug.getinfo(1).currentline
-function testdata.bad_check_type_2(arg1, arg2) -- luacheck: no unused args
+function testdata.bad_check_type_2(_arg1, _arg2)
checks({param = 5})
end
@@ -294,12 +294,12 @@ local err_cases = {
{
code = 'fn_options({mynumber = "bad"})',
line = _l_options,
- error = 'bad argument options.mynumber to fn_options (?number expected, got string)',
+ error = 'bad argument _options.mynumber to fn_options (?number expected, got string)',
},
{
code = 'fn_options({badfield = "bad"})',
line = _l_options,
- error = 'unexpected argument options.badfield to fn_options',
+ error = 'unexpected argument _options.badfield to fn_options',
},
-- fn_array
@@ -311,32 +311,32 @@ local err_cases = {
{
code = 'fn_array()',
line = _l_array,
- error = 'bad argument array[1] to fn_array (number expected, got nil)',
+ error = 'bad argument _array[1] to fn_array (number expected, got nil)',
},
{
code = 'fn_array(nil)',
line = _l_array,
- error = 'bad argument array[1] to fn_array (number expected, got nil)',
+ error = 'bad argument _array[1] to fn_array (number expected, got nil)',
},
{
code = 'fn_array(box.NULL)',
line = _l_array,
- error = 'bad argument array[1] to fn_array (number expected, got nil)',
+ error = 'bad argument _array[1] to fn_array (number expected, got nil)',
},
{
code = 'fn_array({})',
line = _l_array,
- error = 'bad argument array[1] to fn_array (number expected, got nil)',
+ error = 'bad argument _array[1] to fn_array (number expected, got nil)',
},
{
code = 'fn_array({"str1"})',
line = _l_array,
- error = 'bad argument array[1] to fn_array (number expected, got string)',
+ error = 'bad argument _array[1] to fn_array (number expected, got string)',
},
{
code = 'fn_array({1})',
line = _l_array,
- error = 'bad argument array[2] to fn_array (number expected, got nil)',
+ error = 'bad argument _array[2] to fn_array (number expected, got nil)',
},
{
code = 'fn_array({1, 2})',
@@ -344,7 +344,7 @@ local err_cases = {
{
code = 'fn_array({1, 2, 3})',
line = _l_array,
- error = 'unexpected argument array[3] to fn_array',
+ error = 'unexpected argument _array[3] to fn_array',
},
-- fn_table
@@ -356,17 +356,17 @@ local err_cases = {
{
code = 'fn_table()',
line = _l_table,
- error = 'bad argument table.mykey to fn_table (number expected, got nil)',
+ error = 'bad argument _table.mykey to fn_table (number expected, got nil)',
},
{
code = 'fn_table({})',
line = _l_table,
- error = 'bad argument table.mykey to fn_table (number expected, got nil)',
+ error = 'bad argument _table.mykey to fn_table (number expected, got nil)',
},
{
code = 'fn_table({mykey = "str"})',
line = _l_table,
- error = 'bad argument table.mykey to fn_table (number expected, got string)',
+ error = 'bad argument _table.mykey to fn_table (number expected, got string)',
},
{
code = 'fn_table({mykey = 0})',
@@ -374,7 +374,7 @@ local err_cases = {
{
code = 'fn_table({mykey = 0, excess = 1})',
line = _l_table,
- error = 'unexpected argument table.excess to fn_table',
+ error = 'unexpected argument _table.excess to fn_table',
},
-- fn_inception
@@ -387,7 +387,7 @@ local err_cases = {
{
code = 'fn_inception({we = false})',
line = _l_inception,
- error = 'bad argument options.we to fn_inception (?table expected, got boolean)',
+ error = 'bad argument _options.we to fn_inception (?table expected, got boolean)',
},
{
code = 'fn_inception({we = {}})',
@@ -407,7 +407,7 @@ local err_cases = {
{
code = 'fn_inception({we = {need = {to = {go = {deeper = {}}}}}})',
line = _l_inception,
- error = 'bad argument options.we.need.to.go.deeper to fn_inception (?number expected, got table)',
+ error = 'bad argument _options.we.need.to.go.deeper to fn_inception (?number expected, got table)',
},
-- fn_deepcheck
@@ -429,7 +429,7 @@ local err_cases = {
{
code = 'fn_missing_checks()',
line = _l_missing_checks,
- error = 'checks: argument "arg2" is not checked',
+ error = 'checks: argument "_arg2" is not checked',
},
{
code = 'bad_check_type_1()',
@@ -456,6 +456,7 @@ for _, case in pairs(err_cases) do
g[name] = function(_)
local fn = loadstring(case.code)
+ ---@cast fn function
local ok, err = pcall(fn)
if case.error == nil then
@@ -582,34 +583,34 @@ for _, case in pairs(options_v2_cases) do
end
------------------------------------------------------------------------------
-function testdata.fn_int64(arg) -- luacheck: no unused args
+function testdata.fn_int64(_arg)
checks('int64')
end
-function testdata.fn_uint64(arg) -- luacheck: no unused args
+function testdata.fn_uint64(_arg)
checks('uint64')
end
local uuid = require('uuid')
testdata.myid = uuid()
-function testdata.fn_uuid(arg) -- luacheck: no unused args
+function testdata.fn_uuid(_arg)
checks('uuid')
end
-function testdata.fn_uuid_str(arg) -- luacheck: no unused args
+function testdata.fn_uuid_str(_arg)
checks('uuid_str')
end
-function testdata.fn_uuid_bin(arg) -- luacheck: no unused args
+function testdata.fn_uuid_bin(_arg)
checks('uuid_bin')
end
-function testdata.fn_tuple(arg) -- luacheck: no unused args
+function testdata.fn_tuple(_arg)
checks('tuple')
end
-function testdata.fn_decimal(arg) -- luacheck: no unused args
+function testdata.fn_decimal(_arg)
checks('decimal')
end
@@ -618,13 +619,13 @@ if has_decimal then
testdata.decimal = decimal
end
-function testdata.fn_error(arg) -- luacheck: no unused args
+function testdata.fn_error(_arg)
checks('error')
end
local has_error = (box.error ~= nil) and (box.error.new ~= nil)
-function testdata.fn_datetime(arg) -- luacheck: no unused args
+function testdata.fn_datetime(_arg)
checks('datetime')
end
@@ -633,7 +634,7 @@ if has_datetime then
testdata.datetime = datetime
end
-function testdata.fn_interval(arg) -- luacheck: no unused args
+function testdata.fn_interval(_arg)
checks('interval')
end
@@ -1095,6 +1096,7 @@ for _, case in pairs(ret_cases) do
t.skip_if(case.skip, "type unsupported")
local fn = loadstring(case.code)
+ ---@cast fn function
local ok, err = pcall(fn)
t.assert_equals(case.ok, ok, err)
end
diff --git a/test/types.lua b/test/types.lua
new file mode 100644
index 0000000..07e6b2b
--- /dev/null
+++ b/test/types.lua
@@ -0,0 +1,6 @@
+---@meta
+---
+--- Tarantool extensions to the standard Lua `table` library.
+---
+---@class tablelib
+---@field deepcopy fun(t: table): table Deep-copies a table.