contract
is a module that checks whether or not function arguments satisfy a specified "contract" string. These strings define the argument type requirements for a given function using a custom mini-language.
contract
provides two main benefits:
- It provides a simple mechanism for checking argument datatypes.
- It helps document the intended use of each function.
contract
is NOT:
- A full unit-testing solution.
- A compile-time evaluator. All checks are performed at run-time, however they can be turned off for non-development builds. If you want something that evaluates your code prior to execution, consider something like TypeScriptToLua.
contract
can be installed using luarocks:
luarocks install contract
You may also download the latest release and include contract.lua in your project folder.
contract
is compatible with LUA versions 5.1 and up. It does not have any external dependencies.
Consider the following function:
local function sum(a, b)
assert(type(a) == 'number', 'arg "a" must be a number')
assert(type(b) == 'number', 'arg "b" must be a number')
return a + b
end
This function expects two numbers to be passed to it. Note the two assert calls - if any other value types are passed in, the asserts will fail and an error will be raised. We can think of the sum()
function as having a "contract" that says, "I expect two number values to be passed to me, and as long as that is true I will run correctly."
contract()
can be used as an alternative to writing out these asserts manually. For example:
local contract = require('contract')
local function sum2(a,b)
contract('rn, rn', a, b)
return a + b
end
Function sum2()
is equivalent to sum()
in that any non-number values for a
and b
will be rejected.
The first argument passed to contract()
is the contract string "rn, rn", which represents a contract for two required number arguments.
The arguments a
and b
are then passed after the contract string to verify if they satisfy the contract. If sum2()
is called with arguments that violate this contract, an error will be raised:
sum2(1, 2) --<passes>
sum2('one', 2) --Contract violated: arg "1" is type "string", but must be "number".
sum2(1, 'two') --Contract violated: arg "2" is type "string", but must be "number".
sum2(1) --Contract violated: arg pos "2" is required.
The syntax for contracts is quite simple:
- Each argument has a rule specifying its allowed datatype(s). For example, in the contract string above "rn" stands for "required number".
- The rules for each argument are listed in position order separated by commas.
- Contracts are not case-sensitive.
- All whitespace is ignored.
contract
can be used to check any of the primitive Lua datatypes:
local function callIfTrue(bl, fnc)
contract('rb, rf', bl, fnc)
if bl then
fnc()
end
end
callIfTrue(true, function() print('hello') end) --'hello'
callIfTrue(true, 'not a function') --Contract violated: arg "2" is type "string", but must be "function".
There are multiple specifiers associated with each datatype. This allows you to be as brief or as explicit as you want:
contract('number', 1) --<passes>
contract('s, str, string', 'one', 'two', 'three') --<passes>
Here is a table listing all the acceptable specifiers for each Lua type:
datatype | specifiers |
---|---|
number | "n", "num", "number" |
string | "s", "str", "string" |
boolean | "b", "bool", "boolean" |
table | "t", "tbl", "table" |
function | "f", "fnc", "func", "function" |
thread | "th", "thread" |
userdata | "u", "usr", "user", "userdata" |
Note that contract
is intended for evaluating arguments passed to a calling function, but you can actually pass any values to it:
contract('rt, rs', {}, '') --<passes>
contract('ru', 'not userdata') --Contract violated: arg "1" is type "string", but must be "userdata".
contract('rf', print) --<passes>
contract
allows arguments to be specified as "required" or "optional". A type specifier preceeded by an "r" flags it as required. Any specifiers without an "r" are treated as optional.
Optional arguments can be omitted, but if they are passed they still must match their specified type for the contract to pass:
local function config(tbl, name, op1, val)
contract('rt, s, rb, n', tbl, name, op1, val)
tbl.name = name or 'default'
tbl.op1 = op1
tbl.val = val or 42
end
config({}, 'mytable', true, 19) --<passes>
config({}, 'mytable', true) --<passes>
config({}, nil, true) --<passes>
config({}, 'mytable', true, 'not a number') --Contract violated: arg "4" is type "string" but must be "number".
config({}, true) --Contract violated: arg "2" is type "boolean" but must be "string".
Optional args at the end of the list can be completely omitted. Also, note that optional args can come before required args, but if they are being omitted they must have nil
passed explicitly - just leaving these out will not work.
You can specify an argument that can be one of multiple types using the '|' operator:
contract('rn|s', 1) --<passes>
contract('rn|s', 'one') --<passes>
contract('rn|s', true) --Contract violated: arg "1" is type "boolean" but must be one of: "number|string".
You can also use the "a" or "any" specifiers to accept values of any type:
contract('a', 1) --<passes>
contract('any', 'one') --<passes>
contract('a', true) --<passes>
contract('ra, ra', 1) --Contract violated: arg pos "2" is required.
If more arguments are passed than are specified in the contract, then as long as the contract holds the extra arguments do not matter:
contract('rn', 1, 'two', 'three') --<passes>
There are actually two ways to use contract()
. The first is by explicitly passing the argument values you wish to check against the contract (all of the examples above use this method). The second method automatically looks up the arguments from the function that called contract()
without needing to pass them in:
local function sum(a, b)
contract('rn, rn') --implicit lookup of a & b
return a + b
end
sum(1, 2) --<passes>
sum(1, 'two') --Contract violated: arg "2" is type "string" but must be "number".
While this method uses less typing, it unfortunately takes longer to execute compared to the explicit method. You are free to use whichever method best suits your needs.
The contract
module can be globally disabled by calling contract.off()
. Once disabled, any calls to contract()
will essentially be no-ops. This is helpful for production builds since it removes the extra overhead needed to process the contracts.
The module can be re-enabled by calling contract.on()
. There is also contract.toggle()
, which toggles between the on/off state.
To help minimize execution time, contract
makes use of an internal cache of intermediate objects tied to each contract string. The first time a contract string is passed, it is parsed and built into a corresponding object. The next time that contract string is passed in, the arguments are checked against the previously-built object rather than creating a new one.
Sadly, the contract
module still adds a bit of execution overhead compared to just checking arguments with assert()
. You can test performance on your system using the performance.lua
file included in this repository.
Contract language in EBNF
Here is the complete grammar for the contract string mini-language:
contract = '' | (argRule , (',' , argRule)*)
argRule = ['r'] , type , ('|' , type)*
type = num|str|bool|user|fnc|th|tbl|any
num = 'n'|'num'|'number'
str = 's'|'str'|'string'
bool = 'b'|'bool'|'boolean'
user = 'u'|'usr'|'user'|'userdata'
fnc = 'f'|'fnc'|'function'
th = 'th'|'thread'
tbl = 't'|'tbl'|'table'
any = 'a'|'any'
Checks the argument list against the contract string input
. If no arguments are passed, attempts to look up the arguments passed to the function that called contract.check()
. Raises an error if the contract is violated.
Alias for contract.check()
.
Enables all contract checking (module is "on" by default).
Turns off all contract checking.
Returns true
if contract checking is currently enabled; otherwise, returns false
.
Switches the on/off state of the module.
Clears the contract cache.
contract
is written and maintained by Matt Rogge.
Portions of the parser code were inspired by this great series of tutorials written by Ruslan Spivak.
contract
is licensed under the MIT license.