src/chancery.lisp @ 11dfd8438119 default tip

Added tag v1.0.1 for changeset acf092c22cdb
author Steve Losh <steve@stevelosh.com>
date Thu, 27 Aug 2020 22:17:20 -0400
parents acf092c22cdb
children (none)
(in-package :chancery)

;;;; Utils ---------------------------------------------------------------------
(deftype non-keyword-symbol ()
  '(and symbol (not keyword)))


(defmacro -<> (expr &rest forms)
  "Thread the given forms, with `<>` as a placeholder."
  ;; I am going to lose my fucking mind if I have to program lisp without
  ;; a threading macro, but I don't want to add another dep to this library, so
  ;; here we are.
  `(let* ((<> ,expr)
          ,@(mapcar (lambda (form)
                      (if (symbolp form)
                        `(<> (,form <>))
                        `(<> ,form)))
                    forms))
     <>))


(defmacro assert-nonempty (place message)
  `(assert (not (emptyp ,place)) (,place) ,message))

(defmacro gimme (n &body body)
  `(loop :repeat ,n :collect (progn ,@body)))


(defun emptyp (string)
  (zerop (length string)))

(defun cat (&rest strings)
  "Concatenate `strings` into a string."
  (apply #'concatenate 'string strings))

(defun ch (string index)
  "Return the character of `string` at `index`.  Allows negative indices."
  (if (emptyp string)
    nil
    (aref string (if (minusp index)
                   (+ (length string) index)
                   index))))

(defun chop (string n)
  "Chop `n` characters off the end of `string`"
  (subseq string 0 (max 0 (- (length string) n))))

(defun vowelp (character)
  (ensure-boolean (member character '(#\a #\e #\i #\o #\u
                                      #\A #\E #\I #\O #\U))))


(defun prefix-sums (sequence &aux (sum 0.0f0))
  (map 'list (lambda (n) (incf sum (float n sum))) sequence))

(defun separate-with-spaces (list)
  (-<> list
    (split-sequence :. <>)
    (mapcar (rcurry #'riffle #\Space) <>)
    (apply #'append <>)))

(defun join-string (&rest parts)
  (apply #'cat (mapcar #'princ-to-string parts)))


;;;; RNG ----------------------------------------------------------------------
(defparameter *random* #'random
  "The random number generation function to use (default: `CL:RANDOM`).")

(defun chancery-random (n)
  (funcall *random* n))


;;;; Weightlists --------------------------------------------------------------
(defstruct (weightlist (:constructor %make-weightlist))
  ;; items and weights are the original things passed in.
  ;; sums and total are coerced to single floats for easier comparison.
  items weights sums total)

(defun make-weightlist (items weights)
  "Make a weightlist of the given items and weights.

  Weights can be any `real` numbers.  Weights of zero are fine, as long as at
  least one of the weights is nonzero (otherwise there's nothing to choose).

  "
  (%make-weightlist
    :items items
    :weights weights
    :sums (prefix-sums weights)
    :total (coerce (apply #'+ weights) 'single-float)))


(defmethod print-object ((object weightlist) stream)
  (print-unreadable-object (object stream :type t)
    (prin1 (mapcar #'list
                   (weightlist-weights object)
                   (weightlist-items object))
           stream)))

(defmethod make-load-form ((object weightlist) &optional environment)
  (make-load-form-saving-slots object
                               :slot-names '(weights sums items total)
                               :environment environment))


(defun weightlist-random (weightlist)
  "Return a random item from the weightlist, taking the weights into account."
  (loop :with n = (chancery-random (weightlist-total weightlist))
        :for item :in (weightlist-items weightlist)
        :for weight :in (weightlist-sums weightlist)
        ;; Use <= instead of < here to work around https://github.com/Clozure/ccl/issues/342
        :when (<= n weight) :do (return item)))


;;;; Core ---------------------------------------------------------------------
(defun special-form-p (form)
  (ensure-boolean
    (and (consp form)
         (member (first form) '(quote eval)))))

(deftype special-form ()
  '(satisfies special-form-p))


(defun compile-list (contents)
  `(list ,@(mapcar #'compile-expression contents)))

(defun compile-symbol (symbol)
  `(,symbol))

(defun compile-special-form (expression)
  (destructuring-bind (symbol argument) expression
    (ecase symbol
      (quote `(quote ,argument))
      (eval argument))))

(defun compile-expression (expression)
  (typecase expression
    (null expression)
    (non-keyword-symbol (compile-symbol expression))
    (special-form (compile-special-form expression))
    (string expression)
    (list (compile-list expression))
    ; todo: vectors?
    (t expression)))


(defun build-weightlist-weighted (size weights)
  (make-weightlist (range 0 size) weights))

(defun build-weightlist-zipf (size exponent)
  (loop
    :with denominator = (loop :for n :from 1.0 :to size
                              :sum (/ (expt n exponent)))
    :repeat size
    :for rank :from 1
    :for item :from 0
    :for weight = (/ (/ (expt rank exponent))
                     denominator)
    :collect item :into items
    :collect weight :into weights
    :finally (return (make-weightlist items weights))))


(defun compile-selector-uniform (expressions)
  (values `(chancery-random ,(length expressions))
          expressions))

(defun compile-selector-weighted (expressions)
  (values `(weightlist-random
             ,(build-weightlist-weighted (length expressions)
                                         (mapcar #'first expressions)))
          (mapcar #'second expressions)))

(defun compile-selector-zipf (expressions &key (exponent 1.0))
  (values `(weightlist-random
             ,(build-weightlist-zipf (length expressions) exponent))
          expressions))

(defun compile-selector (distribution-and-options expressions)
  (destructuring-bind (distribution &rest distribution-options)
      (ensure-list distribution-and-options)
    (apply (ecase distribution
             (:uniform #'compile-selector-uniform)
             (:weighted #'compile-selector-weighted)
             (:zipf #'compile-selector-zipf))
           expressions
           distribution-options)))


(defun compile-rule-body (expression-compiler expressions distribution)
  (if (= 1 (length expressions))
    (funcall expression-compiler (first expressions))
    (multiple-value-bind (selector expressions)
        (compile-selector distribution expressions)
      `(case ,selector
         ,@(loop
            :for i :from 0
            :for expression :in expressions
            :collect `(,i ,(funcall expression-compiler expression)))))))


(defun compile-define-rule (expression-compiler name-and-options expressions)
  (destructuring-bind (name &key
                            documentation
                            (distribution :uniform)
                            (arguments '()))
      (ensure-list name-and-options)
    `(defun ,name ,arguments
       ,@(ensure-list documentation)
       ,(compile-rule-body expression-compiler expressions distribution))))

(defun compile-create-rule (expression-compiler options expressions)
  (destructuring-bind (&key documentation
                            (distribution :uniform)
                            (arguments '()))
      options
    (compile nil
             `(lambda ,arguments
                ,@(ensure-list documentation)
                ,(compile-rule-body expression-compiler
                                    expressions
                                    distribution)))))


(defmacro define-rule (name-and-options &rest expressions)
  "Define a function that will return random elements of `expressions`.

  `name-and-options` should be of the form:

    (name &key documentation (distribution :uniform) (arguments '()))

  If no options are needed a bare symbol can be given.

  `name` is the symbol under which the resulting function will be defined.

  `documentation` will be used as a docstring for the resulting function.

  `distribution` denotes the distribution of elements returned.

  `arguments` is the arglist of the resulting function.

  Examples:

    (define-rule color
      :blue
      :green
      :red)

    (define-rule (metal :documentation \"Return a random metal.\"
                        :distribution :zipf)
      :copper
      :silver
      :gold
      :platinum)

  See the full documentation for more information.

  "
  (compile-define-rule #'compile-expression name-and-options expressions))

(defun create-rule (expressions &rest options)
  "Return a function that will return random elements of `expressions`.

  `options` should be of the form:

    (&key documentation (distribution :uniform) (arguments '()))

  `documentation` will be used as a docstring for the resulting function.

  `distribution` denotes the distribution of elements returned.

  `arguments` is the arglist of the resulting function.

  Examples:

    (create-rule (list :blue :red :green))

    (create-rule (list :copper :silver :gold :platinum)
      :documentation \"Return a random metal.\"
      :distribution :zipf)

  See the full documentation for more information.

  "
  (compile-create-rule #'compile-expression options expressions))

(defmacro generate (expression)
  "Generate a single Chancery expression.

  Example:

    (define-rule x 1 2 3)

    (generate (x x x))
    ; => (1 3 1)

  "
  (compile-expression expression))

(defun invoke-generate (expression)
  "Generate a single Chancery expression.

  THIS FUNCTION IS EXPERIMENTAL AND SUBJECT TO CHANGE IN THE FUTURE.

  Because this is a function, not a macro, you'll need to do the quoting
  yourself:

    (define-rule x 1 2 3)

    (generate (x x x))
    ; => (1 3 3)

    (invoke-generate '(x x x))
    ; => (2 1 2)

  "
  (eval (compile-expression expression)))


;;;; Strings ------------------------------------------------------------------
(defun compile-string-combination (list)
  `(join-string ,@(-<> list
                    (separate-with-spaces <>)
                    (mapcar #'compile-string-expression <>))))

(defun compile-string-modifiers (vector)
  ; #("foo" a b c) => (c (b (a "foo")))
  `(princ-to-string
     ,(reduce (flip #'list) vector
              :start 1
              :initial-value (compile-string-expression (aref vector 0)))))

(defun compile-string-other (expr)
  `(princ-to-string ,expr))

(defun compile-string-expression (expression)
  (typecase expression
    (string expression)
    (null "")
    (non-keyword-symbol (compile-symbol expression))
    (special-form (compile-special-form expression))
    (vector (compile-string-modifiers expression))
    (cons (compile-string-combination expression))
    (t (compile-string-other expression))))


(defmacro define-string (name-and-options &rest expressions)
  "Define a function that will return random stringified elements of `expressions`.

  `name-and-options` should be of the form:

    (name &key documentation (distribution :uniform) (arguments '()))

  If no options are needed a bare symbol can be given.

  `name` is the symbol under which the resulting function will be defined.

  `documentation` will be used as a docstring for the resulting function.

  `distribution` denotes the distribution of elements returned.

  `arguments` is the arglist of the resulting function.

  Examples:

    (define-string color \"white\" \"gray\" \"black\")

    (define-string (animal :distribution :weighted)
      (100 (color \"cat\"))
      (100 (color \"dog\"))
      (100 (color \"dragon\")))

  See the full documentation for more information.

  "
  (compile-define-rule #'compile-string-expression name-and-options expressions))

(defun create-string (expressions &rest options)
  "Return a function that will return random stringified elements of `expressions`.

  `options` should be of the form:

    (&key documentation (distribution :uniform) (arguments '()))

  `documentation` will be used as a docstring for the resulting function.

  `distribution` denotes the distribution of elements returned.

  `arguments` is the arglist of the resulting function.

  Examples:

    (create-string (list \"white\" \"gray\" \"black\"))

    (create-string '((100 (color \"cat\"))
                     (100 (color \"dog\"))
                     (100 (color \"dragon\")))
      :distribution :weighted)

  See the full documentation for more information.

  "
  (compile-create-rule #'compile-string-expression options expressions))

(defmacro generate-string (expression)
  "Generate and stringify a single Chancery string expression.

  Example:

    (define-string x 1 2 3)

    (generate-string (x x x))
    ; => \"1 3 1\"

  "
  (compile-string-expression expression))

(defun invoke-generate-string (expression)
  "Generate and stringify a single Chancery expression.

  THIS FUNCTION IS EXPERIMENTAL AND SUBJECT TO CHANGE IN THE FUTURE.

  Because this is a function, not a macro, you'll need to do the quoting
  yourself:

    (define-string x 1 2 3)

    (generate-string (x x x))
    ; => \"1 3 3\"

    (invoke-generate-string '(x x x))
    ; => \"2 1 2\"

  "
  (eval (compile-string-expression expression)))


;;;; Modifiers ----------------------------------------------------------------
(defun cap (string)
  "Capitalize the first character of `string`."
  (assert-nonempty string "Cannot capitalize an empty string.")
  (string-capitalize string :end 1))

(defun cap-all (string)
  "Capitalize each word of `string`."
  (assert-nonempty string "Cannot capitalize-all an empty string.")
  (string-capitalize string))

(defun a (string)
  "Add an indefinite article (a or an) to the front of `string`."
  (assert-nonempty string "Cannot add an article to an empty string.")
  (cat (if (vowelp (ch string 0))
         "an "
         "a ")
       string))

(defun s (string)
  "Pluralize `string`."
  ;; todo: fix for caps
  ;; todo: make this suck less in general, see http://blog.writeathome.com/index.php/2011/12/how-to-make-nouns-plural/
  (assert-nonempty string "Cannot pluralize an empty string.")
  (case (char-downcase (ch string -1))
    (#\y (if (vowelp (ch string -2))
           (cat string "s")
           (cat (chop string 1) "ies")))
    ((#\x #\s #\z) (cat string "es"))
    (#\h (cat string
              (case (ch string -2)
                ((#\c #\s) "es")
                (t "s"))))
    (t (cat string "s"))))

(defun pos (string)
  "Make `string` posessive by adding an apostrophe (and possibly an s)."
  (assert-nonempty string "Cannot make an empty string posessive.")
  (cat string
       (if (eql #\s (ch string -1))
         "'"
         "'s")))


;;;; Scratch ------------------------------------------------------------------