Currently tested against SBCL, CCL, ABCL, ECL, and Allegro CL.
Defconfig is a customization and validation framework for places in CL. Its
intended use is with user-exposed variables and accessors, to protect users
from setting variables to an invalid value an potentially encountering
undefined behavior. It can be used with both dynamic variables and setf
-able
functions. It coexists alongside setf
, and does not replace it - ie you can
still use setf
if you want to avoid validating the value.
If you wish to just dive in, :import
the symbols #:defconfig
, #:setv
,
#:with-atomic-setv
, #:define-defconfig-db
, #:get-db
, and
#:*setv-permissiveness*
and read their docstrings.
If you encounter something that doesnt work as you think it should, please open an issue here. If you describe the exhibited behavior and the expected behavior, it will be added to the test suite and fixed (hopefully quickly).
The basic usage of this system is through the macros defconfig
, setv
and
with-atomic-setv
. The macro defconfig
defines a configuration object which
is used to check values against a predicate. The macro setv
uses these
configuration objects to validate values before calling setf. An error is
signalled if A) the value is invalid, B) the coerced value is invalid (when
applicable) or C) the place one is trying to set doesnt have a configuration
object. For A and B, restarts are put into place to ignore the invalidity and
set regardless, or continue without setting. The macro with-atomic-setv
collects all places set with setv
in its body and if an error is signalled
resets all changed values back to their original value held before the
with-atomic-setv
form.
This readme is not a complete explanation of defconfig. Please see
package.lisp
for a list of exported symbols and see the symbol docstrings
for a better idea of what it does. Most (exported) symbols are well
documented, slots have decent documentation describing their purpose/use, and
errors should be grokkable from their :report
functions.
Lets look at an example where one is defining a user-exposed variable for the
background color of an application. Please see tests/defconfig-tests.lisp
for more examples.
Here we define a variable called *background-color*
which holds a default
value of the color white. It may only hold a color object. If we try to set
it to a string, we try to parse a color object from the string. If that fails
or we get something other than a string, we return the original value. We do
this because the coercer is only ever called on an invalid value, and if we
cannot make a color object from it we want to be certain that we return an
invalid value. We then tag the configuration object with “color” and
“background” such that it can be searched for by those names.
(defpackage :my-app
(:local-nicknames (:dc :defconfig))
(:use :cl))
(in-package :my-app)
(defstruct color r g b)
(defun parse-color-from-string (string)
(make-color :r (parse-integer (subseq string 0 2) :radix 16)
:g (parse-integer (subseq string 2 4) :radix 16)
:b (parse-integer (subseq string 4 6) :radix 16)))
(dc:defconfig *background-color* (make-color :r 256 :g 256 :b 256)
:documentation "the background color for my app"
:typespec 'color
:coercer (lambda (value)
(handler-case (parse-color-from-string value)
(error () value)))
:tags '("color" "background"))
Now, somewhere in the users ~/.myapp.d/init
, they want to set the
background color to black. Lets look at three examples of that:
(dc:setv *background-color* (make-color :r 0 :g 0 :b 0))
(dc:setv *background-color* "000000")
(dc:setv *background-color* "i dont know what this user is thinking!")
The first of these works without a hitch; setv
determines that it is a
valid value as per the typespec the user provided, and sets
*background-color*
to black.
The second of these would fail if we hadnt provided a coercer, but as we did,
and it knows how to handle color strings, we generate a color from the color
string and *background-color*
gets set to black.
The third of these is also a string, but its impossible to parse a color from
it. Assuming parse-color-from-string
errors out on invalid strings, we
return value
and signal an error; *background-color*
remains white.
Lets look at an example of with-atomic-setv
. We will define a bounded
number variable, and then try setting it while signalling various errors.
(dc:defconfig *bounded-number* 0
:typespec '(integer 0 10)
:coercer (lambda (x)
(if (stringp x)
(handler-case (parse-integer x)
(error () x))
x)))
(defun compute-something-that-signals-an-error ()
(error "we encountered an error, oh no!"))
(dc:with-atomic-setv ()
(dc:setv *bounded-number* 1)
(dc:setv *bounded-number* 50))
(dc:with-atomic-setv ()
(dc:setv *bounded-number* 1)
(compute-something-that-signals-an-error)
(dc:setv *bounded-number* 2))
(dc:with-atomic-setv (:handle-conditions dc:config-error)
(dc:setv *bounded-number* 1)
(compute-something-that-signals-an-error)
(dc:setv *bounded-number* 2))
The first of the calls to with-atomic-setv
first sets *bounded-number*
to
1, and then encounters an error when trying to set it to 50. It catches that
error and resets *bounded-number*
to 0, the value *bounded-number*
had
before the call to with-atomic-setv
.
The second of these first sets *bounded-number*
to 1, and then an error is
signalled by (compute-something-that-signals-an-error)
. It catches this
error and resets *bounded-number*
to 0.
The third of these first sets *bounded-number*
to 1, and then an error is
signalled that it is not set up to handle; it will only catch errors of type
config-error
. Whether or not it attempts to set *bounded-number*
to 2 is
determined by what handlers and restarts are set up around the error. If
there a restart is chosen that doesnt unwind the stack then
*bounded-number*
will be set to 2, but if there is a non-local transfer of
control to a point outside of with-atomic-setv
then *bounded-number*
will
remain set to 1. This is the only way to escape with-atomic-setv
that
leaves things in a partially configured state. Lets look at an example of
this that would end up with *bounded-number*
being 2:
(defun compute-something-that-signals-an-error ()
(restart-case (error "we encountered an error, oh no!")
(continue () nil)))
(handler-bind ((error
(lambda (c)
(declare (ignore c))
(when (find-restart 'continue)
(invoke-restart 'continue)))))
(dc:with-atomic-setv (:handle-conditions dc:config-error)
(dc:setv *bounded-number* 1)
(compute-something-that-signals-an-error)
(dc:setv *bounded-number* 2)))
By setting *setv-permissiveness*
one can control how setv
handles missing
configuration objects. It can be set to one of the following values:
:strict
- Signal all errors as they occur. This is the default behavior:greedy
- When unable to find a configuration object in the specified database, search in all databases for a matching configuration object, using the first one encountered.:permissive
- When a configuration object isnt found, set the variable to the value.:greedy+permissive
- When a configuration object isnt found, search for one as per:greedy
. If one still isnt found, set the variable to the value.
There are a few places in defconfig
that arent naturally intuitive.
Setv wont work with macros that expand into something else to be set in the same way setf does. Example:
(defconfig *var* nil)
(defmacro var () '*var*)
(setf (var) 1) ; works
(setv (var) 2) ; tries to find config for the accessor var, not the variable *var*
The macro psetv
is a setv
equivalent of psetf
. However, while bindings
are “preserved” throughout the form, if an error occurs and there is a
non-local transfer of control, any places being set after the error will not
be set. An example from the test suite:
(defconfig-minimal *a* 'a
:typespec 'symbol)
(defconfig-minimal *b* "b"
:typespec 'string)
(defconfig-minimal *c* 'c
:typespec 'symbol)
(psetv *a* *c*
*b* *a*
*c* *a*)
If one enters this in a repl, an error condition will be signalled upon
trying to set *b*
to 'a
, and if one chooses to abort (via q
, or
sly-db-abort
) then *c*
will retain the value 'c
, and *b*
"b"
.
The star variant of with-atomic-setv
has a quirk in that places get
evaluated multiple times if one resets, while both variants evaluate
accessors multiple times. Some code to demonstrate:
(with-atomic-setv ()
(setv (accessor *myvar*) 0)
…)
(with-atomic-setv* ()
(setv (accessor *myvar*) 0)
…)
Both of these will evaluate (accessor *myvar*)
multiple times depending on
whether it gets reset or not.
(with-atomic-setv ()
(setv (accessor (progn (incf *counter*)
*myvar*))
0)
…)
(with-atomic-setv* ()
(setv (accessor (progn (incf *counter*)
*myvar*))
0)
…)
In the above example, the first of these will evaluate (progn (incf
*counter*) *myvar*)
once and only once, while the second will evaluate
(progn (incf *counter*) *myvar*)
once if there is no reset, but twice if
there is a reset. Both version of this macro will evaluate the accessor
multiple times. Another way of putting it is to say that with-atomic-setv*
is symmetrical - that is to say, upon resetting every call to setv
will
have a matching reset. In contrast, with-atomic-setv
will only reset a
place if it hasn’t already been reset.
When it comes to enforcing a typespec upon an objects slot, one could do as
such with defconfig, but this could also be done by defining a new metaclass
that checks the type of a value by defining a method for
slot-value-using-class. Defconfig has the advantage of being usable with
general “accessor” functions (ie not accessor methods as generated by
defclass
). If one wants to restrict a specific setf-able function, then
using defconfig is the right move. However, if one wants to restrict a slot
type, then a metaclass is the best option.
The below implements a metaclass that will typecheck all slots when being
set, regardless of how they are being set. It is dependent upon
:closer-mop
.
(defclass typesafe-class (standard-class) ())
(defmethod closer-mop:validate-superclass ((c typesafe-class) (sc standard-class)) t)
(defmethod (setf closer-mop:slot-value-using-class) :around
(value (class typesafe-class) obj slot)
(if (typep value (closer-mop:slot-definition-type slot))
(call-next-method)
(restart-case (error "Value ~A is not of type ~A when setting slot ~A in object ~A"
value (closer-mop:slot-definition-type slot) slot obj)
(use-value (new-value)
:report "Use a new value."
:interactive (lambda ()
(format *query-io* "Enter new value: ")
(multiple-value-list (eval (read))))
(call-next-method new-value class obj slot)))))
The macro reset-place
(and by extension the function
reset-computed-place
) could be a little confusing. It takes a place, and
resets it to its default value. However if previous-value
is true, then it
resets to the previous value instead. Before setting, it checks if the current
value is eql to the value to reset to (this can be controlled with
already-reset-test
) and if it is it isnt reset as it would have no
effect. If it isnt, we both reset the place, AND set the previous-value
slot
to the (now no longer) current value. thusly, if the default value is a,
previous value is b, and current value is c, and we reset to the default
value, we will have a default of a, previous of c, and current of a. If we had
instead reset to the previous value, we effectively swap the previous and
current values. Furthermore, we cannot reset accessor places.
Many distributions package an older version of CLISP upon which the defconfig
testing dependency :fiveam
wont load. CLISP version 2.49.92 and higher is
known to work, and can be obtained from the 2.50 branch on gitlab. At the
time of writing the master branch is v2.49.93+.
define-variable-config place default-value &key validator typespec coercer documentation db tags regen-config => config-info
The define-variable-config
macro generates a config info object and
registers it in a database.
- Side Effects
- Causes config-info to be places into db
- Any side effects of calling validator on default-value, when validator is provided.
- Arguments and Values
- place - a symbol denoting a dynamic variable.
- default-value - the default value for place. Must conform to validator or typespec when provided.
- validator - a function of one argument returning true or nil. May not be provided alongside typespec.
- typespec - a type specifier denoting valid types for place. May not be provided alongside validator
- coercer - a function of one argument used to attempt to coerce its argument to a valid value. Will only be called on invalid values.
- regen-config - when true, regenerate the configuration object regarless of its pre-existence
- db - the database to register config-info in.
- tags - a set of tags used when searching for a configuration object
define-accessor-config place &key validator typespec coercer documentation db tags regen-config => config-info
The define-accessor-config
macro generates a config info object and
registers it in a database.
- Side Effects
- Causes config-info to be places into db
- Arguments and Values
- place - a symbol denoting a dynamic variable.
- validator - a function of one argument returning true or nil. May not be provided alongside typespec.
- typespec - a type specifier denoting valid types for place. May not be provided alongside validator
- coercer - a function of one argument used to attempt to coerce its argument to a valid value. Will only be called on invalid values.
- regen-config - when true, regenerate the configuration object regarless of its pre-existence
- db - the database to register config-info in.
- tags - a set of tags used when searching for a configuration object
defconfig place &rest args => config-info
The defconfig
macro wraps around the define-*-config
macros. When place
is a symbol, it expands into a call to define-variable-config
, as well as a
call to either defparameter
or defvar
. When place is a symbol one
additional key argument is accepted: :reinitialize
. When true, a
defparameter
form is generated.
- Side Effects:
- Causes config-info to be placed into db
- May modify place
- May cause place to be defined as a dynamic variable
- Any side effects of running validator
setv {place value}* :db => result
The setv
macro expands into multiple calls to %%setv
, which validates a
value before setting the place to it. It functions the same as setf
, but
accepts the keyword :db
to specify a database other than the default one
provided by defconfig
. Returns the final value.
- Side Effects:
- Any side effects of evaluating a value. Place/value pairs are evaluated sequentially. If a value is not valid, no further values will be processed.
- Causes place to be set to value
psetv {place value}* :db => result
The psetv
macro expands into multiple calls to %%setv
, the same as
setv
, but differs in that all values are computed before setting, giving
the illusion of setting in parallel (similar to psetf
).
- Side Effects:
- Any side effects of evaluating a value. Place/value pairs are evaluated sequentially. If a value is not valid, no further values will be processed.
- Causes place to be set to value
with-atomic-setv (errorp handle-conditions db) form* => results
There are two versions of this macro: with-atomic-setv
and
with-atomic-setv*
. The former tracks places and values purely at runtime,
while the latter tracks places at macroexpansion time and values at runtime.
The with-atomic-setv
macro resets any places set using setv
to the value
it held before the call to with-atomic-setv
, when a condition is
encountered. One can specify whether to re-signal the condition or not with
:errorp
. If :errorp
is nil a warning will be issued on encountering a
handled condition and the condition will be returned. Re-signalled conditions
are wrapped in the condition setv-wrapped-error
. One can specify which
conditions to handle with :handle-conditions
, which accepts an (unquoted)
type specifier. One can handle no conditions by passing (or)
, though that
defeats the purpose of with-atomic-setv
. The default database to use for
all calls to setv
occuring within form* can be controleld with :db
. It
defaults to defconfig:*default-db*
.
An example:
(with-atomic-setv (:errorp nil)
(error "hello")
"return string")
WARNING: WITH-ATOMIC-SETV encountered the error
#<SIMPLE-ERROR "hello" {address}>
and reset.
=> #<SIMPLE-ERROR "hello" {address}>
(with-atomic-setv (:errorp nil :handle-conditions config-error)
(error "hello")
"return string")
drops into the debugger
(with-atomic-setv (:errorp nil)
(warn "hello")
"return string")
WARNING: hello
=> "return string"
(with-atomic-setv (:errorp nil :handle-conditions (or error warning))
(warn "hello")
"return string")
WARNING: hello
=> #<SIMPLE-WARNING "hello" {address}>
define-defconfig-db var key &key parameter if-exists doc
The define-defconfig-db
macro defines a new dynamic variable containing a
defconfig database and stores that database internally such that it can be
referenced via key. All databases used with defconfig should be created
using this macro. The key argument parameter defaults to true, and controls
whether the database is defined using defvar
or defparameter
. Variable
documentation is added via the doc key argument. When a key already denotes
a defconfig database, an error will be signalled. This can be handled by
setting if-exists to :use
or redefine
, to either use the existing
database or redefine the database respectively. When if-exists is nil the
error will propogate up to the user.
Currently, Travis is being used for CI. However, these builds sometimes fail for unknown reasons unrelated to the defconfig test suite. It would be nice to be able to detect these failures and re-run the job upon encountering them.