Fix unknown slot test to be more specific, and also actually work
author |
Steve Losh <steve@stevelosh.com> |
date |
Wed, 02 Dec 2020 23:43:41 -0500 |
parents |
37efd8463e96 |
children |
04933ed07596 |
(in-package :jarl)
;;;; Object Parsers -----------------------------------------------------------
(defun lisp-case-to-snake-case (string)
"Convert a Lisp-cased string designator `\"FOO-BAR\"` into snake cased `\"foo_bar\"`."
(substitute #\_ #\- (string-downcase string)))
(defclass json-object ()
((preserved :accessor preserved :initarg preserved)))
(defclass json-class (standard-class)
((slot-name-to-json-name :accessor slot-name-to-json-name
:initarg :slot-name-to-json-name
:initform 'lisp-case-to-snake-case)
(unknown-slots :accessor unknown-slots
:initarg :unknown-slots
:initform :discard)
(slot-map :accessor slot-map)
(slot-alist :accessor slot-alist)
(allow-print :accessor allow-print :initarg :allow-print :initform t)
(allow-read :accessor allow-read :initarg :allow-read :initform t)))
(defmethod c2mop:validate-superclass ((class json-class) (superclass standard-class))
t)
(defclass json-direct-slot-definition (c2mop:standard-direct-slot-definition)
((json-class :initarg :json :accessor json-class)
(json-name :initarg :json/name :accessor json-name)
(before-print :initarg :json/before-print :accessor before-print)
(after-read :initarg :json/after-read :accessor after-read)))
(defclass json-effective-slot-definition (c2mop:standard-effective-slot-definition)
((json-class :initarg :json :accessor json-class)
(json-name :initarg :json/name :accessor json-name)
(json-initarg :accessor json-initarg)
(before-print :initarg :json/before-print :accessor before-print :initform nil)
(after-read :initarg :json/after-read :accessor after-read :initform nil)))
(defmethod make-instance ((class (eql (find-class 'json-effective-slot-definition)))
&rest args
&key initargs name &allow-other-keys)
;; We need to wrap this to make sure that every JSON slot has at least one
;; initarg, so we can use it when calling `make-instance` during reading.
;;
;; If none of the direct slots have an initarg, we'll gensym one. Otherwise
;; we can use an existing one and not clutter things up.
(apply #'call-next-method class
(append
(when (null initargs)
(list :initargs
(list (gensym (string name)))))
args)))
(defmethod c2mop:direct-slot-definition-class ((class json-class) &rest initargs)
(if (getf initargs :json)
(find-class 'json-direct-slot-definition)
(call-next-method)))
(defvar *effective-slot-definition-class* nil)
(defmethod c2mop:effective-slot-definition-class ((class json-class) &rest initargs)
(declare (ignore initargs))
;; I'm not sure why we need to use this hack here, but for some reason
;; initargs doesn't contain the slot options like :json and :json/name here
;; like it does in direct-slot-definition-class. So we need another way to
;; know which class to use here.
(or *effective-slot-definition-class* (call-next-method)))
(defmethod c2mop:compute-effective-slot-definition ((class json-class) name direct-slots)
(if (not (some (lambda (dslot)
(typep dslot 'json-direct-slot-definition))
direct-slots))
(call-next-method)
(let* ((*effective-slot-definition-class* (find-class 'json-effective-slot-definition))
(eslot (call-next-method))
(dslot (first direct-slots)))
;; todo be smarter about coalescing this stuff
(setf (json-name eslot) (if (slot-boundp dslot 'json-name)
(json-name dslot)
(funcall (slot-name-to-json-name class) name)) ; todo make this less shitty
(json-class eslot) (if (slot-boundp dslot 'json-class)
(canonicalize-class-designator (json-class dslot))
'(t))
(json-initarg eslot) (first (c2mop:slot-definition-initargs eslot))
(after-read eslot) (if (slot-boundp dslot 'after-read)
(after-read dslot)
nil)
(before-print eslot) (if (slot-boundp dslot 'before-print)
(before-print dslot)
nil))
eslot)))
(defun json-slots (effective-slots)
(remove-if-not (lambda (slot) (typep slot 'json-effective-slot-definition))
effective-slots))
(defun make-slot-map (effective-slots)
"Return a slot map for the JSON slots in `effective-slots`, used when reading.
The result will be a hash table of `{name: (initarg class contained-class
after-read)}`.
"
(let* ((slots (json-slots effective-slots))
(result (make-hash-table :test #'equal :size (length slots))))
(dolist (slot slots)
(destructuring-bind (c &optional cc) (json-class slot)
(setf (gethash (json-name slot) result)
(list (json-initarg slot) c cc (after-read slot)))))
result))
(defun make-slot-alist (effective-slots)
"Return a slot alist for the JSON slots of `effective-slots`, used when printing.
The result will be an alist of `((slot . (\"name\" before-print)))`.
"
(mapcar (lambda (slot)
(cons (c2mop:slot-definition-name slot)
(list (json-name slot)
(before-print slot))))
(json-slots effective-slots)))
(defmethod c2mop:compute-slots :around ((class json-class))
(let ((effective-slots (call-next-method)))
(setf (slot-map class) (make-slot-map effective-slots)
(slot-alist class) (make-slot-alist effective-slots))
effective-slots))
(defmethod shared-initialize :around
((class json-class) slot-names
&rest initargs
&key slot-name-to-json-name unknown-slots allow-print allow-read
&allow-other-keys)
(flet ((arg (initarg args)
(when args ; todo assert length = 1
(list initarg (first args)))))
(apply #'call-next-method class slot-names
(append (arg :slot-name-to-json-name slot-name-to-json-name)
(arg :unknown-slots unknown-slots)
(arg :allow-read (or allow-read '(t)))
(arg :allow-print (or allow-print '(t)))
initargs))))
(defun patch-direct-superclasses (direct-superclasses)
"Patch `direct-superclasses` to ensure `json-object` will be a superclass.
If one of the superclasses is already a `json-class` then `json-object` will
already be an indirect superclass, so nothing needs to be done. Otherwise add
`json-object` to the list of direct superclasses.
"
(let ((meta (find-class 'json-class))
(super (find-class 'json-object)))
(if (find meta direct-superclasses :key #'class-of)
direct-superclasses
(append direct-superclasses (list super)))))
(defmethod initialize-instance :around
((class json-class) &rest initargs
&key direct-superclasses &allow-other-keys)
;;; I have no idea why doing this once in shared-initialize works in SBCL but
;;; not in CCL/ECL. Oh well. This solution from https://www.cliki.net/MOP%20design%20patterns
;;; seems to work everywhere.
(apply #'call-next-method class
:direct-superclasses (patch-direct-superclasses direct-superclasses)
initargs))
(defmethod reinitialize-instance :around
((class json-class) &rest initargs
&key (direct-superclasses nil direct-superclasses?) &allow-other-keys)
(if direct-superclasses?
(apply #'call-next-method class
:direct-superclasses (patch-direct-superclasses direct-superclasses)
initargs)
(call-next-method)))
;;;; Read ---------------------------------------------------------------------
(defun parse-json-class (class-name class input)
(unless (allow-read class)
(error "Class ~S does not allow reading." class))
(let ((ch (r input)))
(unless (eql ch #\{)
(e class-name input "expected ~S but got ~S" #\{ ch)))
(incf-depth input)
(skip-whitespace input)
(if (eql (p input) #\})
(progn (r input)
(decf-depth input)
(make-instance class))
(loop
:with preserved = nil
:with unknown = (unknown-slots class)
:with map = (slot-map class)
:with init = (list)
:for name = (read% 'string nil input)
:for (initarg c cc after-read) = (gethash name map)
:do (progn
(parse-kv-separator class-name input)
(if (null initarg)
(ecase unknown
(:preserve (setf (gethash name (or preserved (setf preserved (make-hash-table :test #'equal))))
(read% t nil input)))
(:discard (read% nil nil input))
(:error (error 'unknown-json-slot-error
:class-designator class-name
:line (input-line input)
:column (input-column input)
:name name
:message (format nil "unknown object attribute ~S" name))))
(let ((value (read% c cc input)))
(push (if after-read (funcall after-read value) value) init)
(push initarg init)))
(skip-whitespace input)
(let ((ch (r input)))
(case ch
(#\} (decf-depth input) (loop-finish))
(#\, (skip-whitespace input))
(t (e class-name input "expected ~S or ~S but got ~S." #\} #\, ch)))))
:finally (progn (when preserved
(push preserved init)
(push 'preserved init))
(return (apply #'make-instance class init))))))
(defmethod read% ((class-name symbol) (contained-class null) (input input))
(let ((wrapper (find-wrapper class-name)))
(if wrapper
(read-with-wrapper wrapper input)
(let ((class (find-class class-name nil)))
(typecase class
(json-class
(c2mop:ensure-finalized class)
(parse-json-class class-name class input))
(null (error "Cannot find class ~S to parse JSON into." class-name))
(t (error "Cannot parse JSON into class ~S because that class is not a ~S"
class-name 'json-class)))))))
;;;; Printing -----------------------------------------------------------------
(defun print-json-class (class thing stream indent &aux (first t))
(write-char #\{ stream)
(when indent (incf indent 2))
(flet ((print-pair (k v)
(if first
(setf first nil)
(write-char #\, stream))
(indent indent stream)
(print% k stream indent)
(write-char #\: stream)
(when indent (write-char #\space stream))
(print% v stream indent)))
(loop :for (slot name before-print) :in (slot-alist class)
:when (slot-boundp thing slot)
:do (let ((value (slot-value thing slot)))
(print-pair name (if before-print
(funcall before-print value)
value)))
:finally (when (slot-boundp thing 'preserved)
(maphash #'print-pair (slot-value thing 'preserved)))))
(when (and indent (not first))
(terpri stream))
(write-char #\} stream))
(defmethod print% ((thing json-object) stream indent)
(let ((class (class-of thing)))
(if (allow-print class)
(print-json-class class thing stream indent)
(error "Class ~S does not allow printing." class))))