Ecosyste.ms: Awesome

An open API service indexing awesome lists of open source software.

Awesome Lists | Featured Topics | Projects

https://github.com/szos/defconfig

A customizer for common lisp dynamic variables
https://github.com/szos/defconfig

Last synced: 21 days ago
JSON representation

A customizer for common lisp dynamic variables

Awesome Lists containing this project

README

        

#+TITLE: DEFCONFIG
*** Status [[https://travis-ci.com/szos/defconfig.svg?branch=main]]
Currently tested against SBCL, CCL, ABCL, ECL, and Allegro CL.
* Purpose
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).

* Usage
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.

* Regarding This Readme
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.

* Basic Tutorial
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.

** Defconfig
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.
#+BEGIN_SRC lisp
(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"))
#+END_SRC

** Setv
Now, somewhere in the users =~/.myapp.d/init=, they want to set the
background color to black. Lets look at three examples of that:
#+BEGIN_SRC lisp
(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!")
#+END_SRC
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.

** With-atomic-setv
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.
#+BEGIN_SRC lisp
(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))
#+END_SRC
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:
#+BEGIN_SRC lisp
(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)))
#+END_SRC

** Controlling Permissiveness
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.

* Quirks, Oddities and Limitations
There are a few places in ~defconfig~ that arent naturally intuitive.
** Setv and macros
Setv wont work with macros that expand into something else to be set in the
same way setf does. Example:
#+BEGIN_SRC lisp
(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*
#+END_SRC
** Psetv
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:
#+BEGIN_SRC lisp
(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*)
#+END_SRC
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"~.
** ~With-atomic-setv~
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:
#+BEGIN_SRC lisp
(with-atomic-setv ()
(setv (accessor *myvar*) 0)
…)

(with-atomic-setv* ()
(setv (accessor *myvar*) 0)
…)
#+END_SRC
Both of these will evaluate ~(accessor *myvar*)~ multiple times depending on
whether it gets reset or not.
#+BEGIN_SRC lisp
(with-atomic-setv ()
(setv (accessor (progn (incf *counter*)
*myvar*))
0)
…)

(with-atomic-setv* ()
(setv (accessor (progn (incf *counter*)
*myvar*))
0)
…)
#+END_SRC
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.

** Accessor Configs vs Metaclass
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.
*** Metaclass Implementation Example
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~.
#+BEGIN_SRC lisp
(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)))))
#+END_SRC
** A Note About Reset-Place
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.

** Defconfig and CLISP
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+.

* Macros
** ~Define-variable-config~
*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~
*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~
*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~
*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~
*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/*~
*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:
#+BEGIN_SRC lisp
(with-atomic-setv (:errorp nil)
(error "hello")
"return string")

WARNING: WITH-ATOMIC-SETV encountered the error
#
and reset.
=> #

(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
=> #
#+END_SRC

** ~Define-defconfig-db~
*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.

* Help Wanted
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.