guix-kreyren/guix/records.scm
Ludovic Courtès 5291fd7a42
records: Support field sanitizers.
* guix/records.scm (make-syntactic-constructor): Add #:sanitizers.
[field-sanitizer]: New procedure.
[wrap-field-value]: Honor F's sanitizer.
(define-record-type*)[field-sanitizer]: New procedure.
Pass #:sanitizer to 'make-syntactic-constructor'.
* tests/records.scm ("define-record-type* & sanitize")
("define-record-type* & sanitize & thunked"): New tests.
2021-08-12 12:34:13 +02:00

554 lines
23 KiB
Scheme

;;; GNU Guix --- Functional package management for GNU
;;; Copyright © 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021 Ludovic Courtès <ludo@gnu.org>
;;; Copyright © 2018 Mark H Weaver <mhw@netris.org>
;;;
;;; This file is part of GNU Guix.
;;;
;;; GNU Guix is free software; you can redistribute it and/or modify it
;;; under the terms of the GNU General Public License as published by
;;; the Free Software Foundation; either version 3 of the License, or (at
;;; your option) any later version.
;;;
;;; GNU Guix is distributed in the hope that it will be useful, but
;;; WITHOUT ANY WARRANTY; without even the implied warranty of
;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;;; GNU General Public License for more details.
;;;
;;; You should have received a copy of the GNU General Public License
;;; along with GNU Guix. If not, see <http://www.gnu.org/licenses/>.
(define-module (guix records)
#:use-module (srfi srfi-1)
#:use-module (srfi srfi-9)
#:use-module (srfi srfi-26)
#:use-module (ice-9 match)
#:use-module (ice-9 regex)
#:use-module (ice-9 rdelim)
#:autoload (system base target) (target-most-positive-fixnum)
#:export (define-record-type*
this-record
alist->record
object->fields
recutils->alist
match-record))
;;; Commentary:
;;;
;;; Utilities for dealing with Scheme records.
;;;
;;; Code:
(define-syntax record-error
(syntax-rules ()
"Report a syntactic error in use of CONSTRUCTOR."
((_ constructor form fmt args ...)
(syntax-violation constructor
(format #f fmt args ...)
form))))
(eval-when (expand load eval)
;; The procedures below are needed both at run time and at expansion time.
(define (current-abi-identifier type)
"Return an identifier unhygienically derived from TYPE for use as its
\"current ABI\" variable."
(let ((type-name (syntax->datum type)))
(datum->syntax
type
(string->symbol
(string-append "% " (symbol->string type-name)
" abi-cookie")))))
(define (abi-check type cookie)
"Return syntax that checks that the current \"application binary
interface\" (ABI) for TYPE is equal to COOKIE."
(with-syntax ((current-abi (current-abi-identifier type)))
#`(unless (eq? current-abi #,cookie)
;; The source file where this exception is thrown must be
;; recompiled.
(throw 'record-abi-mismatch-error 'abi-check
"~a: record ABI mismatch; recompilation needed"
(list #,type) '()))))
(define* (report-invalid-field-specifier name bindings
#:optional parent-form)
"Report the first invalid binding among BINDINGS. PARENT-FORM is used for
error-reporting purposes."
(let loop ((bindings bindings))
(syntax-case bindings ()
(((field value) rest ...) ;good
(loop #'(rest ...)))
((weird _ ...) ;weird!
;; WEIRD may be an identifier, thus lacking source location info, and
;; BINDINGS is a list, also lacking source location info. Hopefully
;; PARENT-FORM provides source location info.
(apply syntax-violation name "invalid field specifier"
(if parent-form
(list parent-form #'weird)
(list #'weird)))))))
(define (report-duplicate-field-specifier name ctor)
"Report the first duplicate identifier among the bindings in CTOR."
(syntax-case ctor ()
((_ bindings ...)
(let loop ((bindings #'(bindings ...))
(seen '()))
(syntax-case bindings ()
(((field value) rest ...)
(not (memq (syntax->datum #'field) seen))
(loop #'(rest ...) (cons (syntax->datum #'field) seen)))
((duplicate rest ...)
(syntax-violation name "duplicate field initializer"
#'duplicate))
(()
#t)))))))
(define-syntax-parameter this-record
(lambda (s)
"Return the record being defined. This macro may only be used in the
context of the definition of a thunked field."
(syntax-case s ()
(id
(identifier? #'id)
(syntax-violation 'this-record
"cannot be used outside of a record instantiation"
#'id)))))
(define-syntax make-syntactic-constructor
(syntax-rules ()
"Make the syntactic constructor NAME for TYPE, that calls CTOR, and
expects all of EXPECTED fields to be initialized. DEFAULTS is the list of
FIELD/DEFAULT-VALUE tuples, THUNKED is the list of identifiers of thunked
fields, DELAYED is the list of identifiers of delayed fields, and SANITIZERS
is the list of FIELD/SANITIZER tuples.
ABI-COOKIE is the cookie (an integer) against which to check the run-time ABI
of TYPE matches the expansion-time ABI."
((_ type name ctor (expected ...)
#:abi-cookie abi-cookie
#:thunked thunked
#:this-identifier this-identifier
#:delayed delayed
#:innate innate
#:sanitizers sanitizers
#:defaults defaults)
(define-syntax name
(lambda (s)
(define (record-inheritance orig-record field+value)
;; Produce code that returns a record identical to ORIG-RECORD,
;; except that values for the FIELD+VALUE alist prevail.
(define (field-inherited-value f)
(and=> (find (lambda (x)
(eq? f (car (syntax->datum x))))
field+value)
car))
;; Make sure there are no unknown field names.
(let* ((fields (map (compose car syntax->datum) field+value))
(unexpected (lset-difference eq? fields '(expected ...))))
(when (pair? unexpected)
(record-error 'name s "extraneous field initializers ~a"
unexpected)))
#`(make-struct/no-tail type
#,@(map (lambda (field index)
(or (field-inherited-value field)
(if (innate-field? field)
(wrap-field-value
field (field-default-value field))
#`(struct-ref #,orig-record
#,index))))
'(expected ...)
(iota (length '(expected ...))))))
(define (thunked-field? f)
(memq (syntax->datum f) 'thunked))
(define (delayed-field? f)
(memq (syntax->datum f) 'delayed))
(define (innate-field? f)
(memq (syntax->datum f) 'innate))
(define field-sanitizer
(let ((lst (map (match-lambda
((f p)
(list (syntax->datum f) p)))
#'sanitizers)))
(lambda (f)
(or (and=> (assoc-ref lst (syntax->datum f)) car)
#'(lambda (x) x)))))
(define (wrap-field-value f value)
(let* ((sanitizer (field-sanitizer f))
(value #`(#,sanitizer #,value)))
(cond ((thunked-field? f)
#`(lambda (x)
(syntax-parameterize ((#,this-identifier
(lambda (s)
(syntax-case s ()
(id
(identifier? #'id)
#'x)))))
#,value)))
((delayed-field? f)
#`(delay #,value))
(else value))))
(define default-values
;; List of symbol/value tuples.
(map (match-lambda
((f v)
(list (syntax->datum f) v)))
#'defaults))
(define (field-default-value f)
(car (assoc-ref default-values (syntax->datum f))))
(define (field-bindings field+value)
;; Return field to value bindings, for use in 'let*' below.
(map (lambda (field+value)
(syntax-case field+value ()
((field value)
#`(field
#,(wrap-field-value #'field #'value)))))
field+value))
(syntax-case s (inherit expected ...)
((_ (inherit orig-record) (field value) (... ...))
#`(let* #,(field-bindings #'((field value) (... ...)))
#,(abi-check #'type abi-cookie)
#,(record-inheritance #'orig-record
#'((field value) (... ...)))))
((_ (field value) (... ...))
(let ((fields (map syntax->datum #'(field (... ...)))))
(define (field-value f)
(or (find (lambda (x)
(eq? f (syntax->datum x)))
#'(field (... ...)))
(wrap-field-value f (field-default-value f))))
;; Pass S to make sure source location info is preserved.
(report-duplicate-field-specifier 'name s)
(let ((fields (append fields (map car default-values))))
(cond ((lset= eq? fields '(expected ...))
#`(let* #,(field-bindings
#'((field value) (... ...)))
#,(abi-check #'type abi-cookie)
(ctor #,@(map field-value '(expected ...)))))
((pair? (lset-difference eq? fields
'(expected ...)))
(record-error 'name s
"extraneous field initializers ~a"
(lset-difference eq? fields
'(expected ...))))
(else
(record-error 'name s
"missing field initializers ~a"
(lset-difference eq?
'(expected ...)
fields)))))))
((_ bindings (... ...))
;; One of BINDINGS doesn't match the (field value) pattern.
;; Report precisely which one is faulty, instead of letting the
;; "source expression failed to match any pattern" error.
(report-invalid-field-specifier 'name
#'(bindings (... ...))
s))))))))
(define-syntax-rule (define-field-property-predicate predicate property)
"Define PREDICATE as a procedure that takes a syntax object and, when passed
a field specification, returns the field name if it has the given PROPERTY."
(define (predicate s)
(syntax-case s (property)
((field (property values (... ...)) _ (... ...))
#'field)
((field _ properties (... ...))
(predicate #'(field properties (... ...))))
(_ #f))))
(define-syntax define-record-type*
(lambda (s)
"Define the given record type such that an additional \"syntactic
constructor\" is defined, which allows instances to be constructed with named
field initializers, à la SRFI-35, as well as default values. An example use
may look like this:
(define-record-type* <thing> thing make-thing
thing?
this-thing
(name thing-name (default \"chbouib\"))
(port thing-port
(default (current-output-port)) (thunked))
(loc thing-location (innate) (default (current-source-location))))
This example defines a macro 'thing' that can be used to instantiate records
of this type:
(thing
(name \"foo\")
(port (current-error-port)))
The value of 'name' or 'port' could as well be omitted, in which case the
default value specified in the 'define-record-type*' form is used:
(thing)
The 'port' field is \"thunked\", meaning that calls like '(thing-port x)' will
actually compute the field's value in the current dynamic extent, which is
useful when referring to fluids in a field's value. Furthermore, that thunk
can access the record it belongs to via the 'this-thing' identifier.
A field can also be marked as \"delayed\" instead of \"thunked\", in which
case its value is effectively wrapped in a (delay …) form.
A field can also have an associated \"sanitizer\", which is a procedure that
takes a user-supplied field value and returns a \"sanitized\" value for the
field:
(define-record-type* <thing> thing make-thing
thing?
this-thing
(name thing-name
(sanitize (lambda (value)
(cond ((string? value) value)
((symbol? value) (symbol->string value))
(else (throw 'bad! value)))))))
It is possible to copy an object 'x' created with 'thing' like this:
(thing (inherit x) (name \"bar\"))
This expression returns a new object equal to 'x' except for its 'name'
field and its 'loc' field---the latter is marked as \"innate\", so it is not
inherited."
(define (field-default-value s)
(syntax-case s (default)
((field (default val) _ ...)
(list #'field #'val))
((field _ properties ...)
(field-default-value #'(field properties ...)))
(_ #f)))
(define (field-sanitizer s)
(syntax-case s (sanitize)
((field (sanitize proc) _ ...)
(list #'field #'proc))
((field _ properties ...)
(field-sanitizer #'(field properties ...)))
(_ #f)))
(define-field-property-predicate delayed-field? delayed)
(define-field-property-predicate thunked-field? thunked)
(define-field-property-predicate innate-field? innate)
(define (wrapped-field? s)
(or (thunked-field? s) (delayed-field? s)))
(define (wrapped-field-accessor-name field)
;; Return the name (an unhygienic syntax object) of the "real"
;; getter for field, which is assumed to be a wrapped field.
(syntax-case field ()
((field get properties ...)
(let* ((getter (syntax->datum #'get))
(real-getter (symbol-append '% getter '-real)))
(datum->syntax #'get real-getter)))))
(define (field-spec->srfi-9 field)
;; Convert a field spec of our style to a SRFI-9 field spec of the
;; form (field get).
(syntax-case field ()
((name get properties ...)
#`(name
#,(if (wrapped-field? field)
(wrapped-field-accessor-name field)
#'get)))))
(define (thunked-field-accessor-definition field)
;; Return the real accessor for FIELD, which is assumed to be a
;; thunked field.
(syntax-case field ()
((name get _ ...)
(with-syntax ((real-get (wrapped-field-accessor-name field)))
#'(define-inlinable (get x)
;; The real value of that field is a thunk, so call it.
((real-get x) x))))))
(define (delayed-field-accessor-definition field)
;; Return the real accessor for FIELD, which is assumed to be a
;; delayed field.
(syntax-case field ()
((name get _ ...)
(with-syntax ((real-get (wrapped-field-accessor-name field)))
#'(define-inlinable (get x)
;; The real value of that field is a promise, so force it.
(force (real-get x)))))))
(define (compute-abi-cookie field-specs)
;; Compute an "ABI cookie" for the given FIELD-SPECS. We use
;; 'string-hash' because that's a better hash function that 'hash' on a
;; list of symbols.
(syntax-case field-specs ()
(((field get properties ...) ...)
(string-hash (object->string
(syntax->datum #'((field properties ...) ...)))
(cond-expand
(guile-3 (target-most-positive-fixnum))
(else most-positive-fixnum))))))
(syntax-case s ()
((_ type syntactic-ctor ctor pred
this-identifier
(field get properties ...) ...)
(identifier? #'this-identifier)
(let* ((field-spec #'((field get properties ...) ...))
(thunked (filter-map thunked-field? field-spec))
(delayed (filter-map delayed-field? field-spec))
(innate (filter-map innate-field? field-spec))
(defaults (filter-map field-default-value
#'((field properties ...) ...)))
(sanitizers (filter-map field-sanitizer
#'((field properties ...) ...)))
(cookie (compute-abi-cookie field-spec)))
(with-syntax (((field-spec* ...)
(map field-spec->srfi-9 field-spec))
((thunked-field-accessor ...)
(filter-map (lambda (field)
(and (thunked-field? field)
(thunked-field-accessor-definition
field)))
field-spec))
((delayed-field-accessor ...)
(filter-map (lambda (field)
(and (delayed-field? field)
(delayed-field-accessor-definition
field)))
field-spec)))
#`(begin
(define-record-type type
(ctor field ...)
pred
field-spec* ...)
(define #,(current-abi-identifier #'type)
#,cookie)
#,@(if (free-identifier=? #'this-identifier #'this-record)
#'()
#'((define-syntax-parameter this-identifier
(lambda (s)
"Return the record being defined. This macro may
only be used in the context of the definition of a thunked field."
(syntax-case s ()
(id
(identifier? #'id)
(syntax-violation 'this-identifier
"cannot be used outside \
of a record instantiation"
#'id)))))))
thunked-field-accessor ...
delayed-field-accessor ...
(make-syntactic-constructor type syntactic-ctor ctor
(field ...)
#:abi-cookie #,cookie
#:thunked #,thunked
#:this-identifier #'this-identifier
#:delayed #,delayed
#:innate #,innate
#:sanitizers #,sanitizers
#:defaults #,defaults)))))
((_ type syntactic-ctor ctor pred
(field get properties ...) ...)
;; When no 'this' identifier was specified, use 'this-record'.
#'(define-record-type* type syntactic-ctor ctor pred
this-record
(field get properties ...) ...)))))
(define* (alist->record alist make keys
#:optional (multiple-value-keys '()))
"Apply MAKE to the values associated with KEYS in ALIST. Items in KEYS that
are also in MULTIPLE-VALUE-KEYS are considered to occur possibly multiple
times in ALIST, and thus their value is a list."
(let ((args (map (lambda (key)
(if (member key multiple-value-keys)
(filter-map (match-lambda
((k . v)
(and (equal? k key) v)))
alist)
(assoc-ref alist key)))
keys)))
(apply make args)))
(define (object->fields object fields port)
"Write OBJECT (typically a record) as a series of recutils-style fields to
PORT, according to FIELDS. FIELDS must be a list of field name/getter pairs."
(let loop ((fields fields))
(match fields
(()
object)
(((field . get) rest ...)
(format port "~a: ~a~%" field (get object))
(loop rest)))))
(define %recutils-field-charset
;; Valid characters starting a recutils field.
;; info "(recutils) Fields"
(char-set-union char-set:upper-case
char-set:lower-case
(char-set #\%)))
(define (recutils->alist port)
"Read a recutils-style record from PORT and return it as a list of key/value
pairs. Stop upon an empty line (after consuming it) or EOF."
(let loop ((line (read-line port))
(result '()))
(cond ((eof-object? line)
(reverse result))
((string-null? line)
(if (null? result)
(loop (read-line port) result) ; leading space: ignore it
(reverse result))) ; end-of-record marker
(else
;; Now check the first character of LINE, since that's what the
;; recutils manual says is enough.
(let ((first (string-ref line 0)))
(cond
((char-set-contains? %recutils-field-charset first)
(let* ((colon (string-index line #\:))
(field (string-take line colon))
(value (string-trim (string-drop line (+ 1 colon)))))
(loop (read-line port)
(alist-cons field value result))))
((eqv? first #\#) ;info "(recutils) Comments"
(loop (read-line port) result))
((eqv? first #\+) ;info "(recutils) Fields"
(let ((new-line (if (string-prefix? "+ " line)
(string-drop line 2)
(string-drop line 1))))
(match result
(((field . value) rest ...)
(loop (read-line port)
`((,field . ,(string-append value "\n" new-line))
,@rest))))))
(else
(error "unmatched line" line))))))))
(define-syntax match-record
(syntax-rules ()
"Bind each FIELD of a RECORD of the given TYPE to it's FIELD name.
The current implementation does not support thunked and delayed fields."
((_ record type (field fields ...) body ...)
(if (eq? (struct-vtable record) type)
;; TODO compute indices and report wrong-field-name errors at
;; expansion time
;; TODO support thunked and delayed fields
(let ((field ((record-accessor type 'field) record)))
(match-record record type (fields ...) body ...))
(throw 'wrong-type-arg record)))
((_ record type () body ...)
(begin body ...))))
;;; records.scm ends here