summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCarlo Zancanaro <carlo@zancanaro.id.au>2021-02-23 13:33:02 +1100
committerCarlo Zancanaro <carlo@zancanaro.id.au>2021-02-23 23:24:38 +1100
commit11919c0ebde7c071c5d126cef3d04775d8b450fe (patch)
tree2da90e651130a5cefa060472e30655b2a2a8634f
Initial commit
-rw-r--r--.dir-locals.el2
-rw-r--r--deps.edn2
-rw-r--r--src/jester/comparisons.clj36
-rw-r--r--src/jester/expansion.clj460
-rw-r--r--src/jester/match.clj142
-rw-r--r--src/jester/types.clj633
-rw-r--r--test/jester/types_test.clj281
7 files changed, 1556 insertions, 0 deletions
diff --git a/.dir-locals.el b/.dir-locals.el
new file mode 100644
index 0000000..6b5ec6b
--- /dev/null
+++ b/.dir-locals.el
@@ -0,0 +1,2 @@
+((clojure-mode
+ (cider-clojure-cli-global-options . "-A:dev")))
diff --git a/deps.edn b/deps.edn
new file mode 100644
index 0000000..9059de7
--- /dev/null
+++ b/deps.edn
@@ -0,0 +1,2 @@
+{:deps {org.clojure/core.match {:mvn/version "1.0.0"}}
+ :aliases {:dev {:extra-deps {org.clojure/test.check {:mvn/version "1.1.0"}}}}}
diff --git a/src/jester/comparisons.clj b/src/jester/comparisons.clj
new file mode 100644
index 0000000..c47469e
--- /dev/null
+++ b/src/jester/comparisons.clj
@@ -0,0 +1,36 @@
+(ns jester.comparisons
+ "Comparison functions to specify how Jester comparisons work.")
+
+(defn string=
+ "Compare two (potentially-nil) strings. If either is nil, return nil.
+ Otherwise, return true iff a = b, ignoring case, and false
+ otherwise."
+ [^String a, ^String b]
+ (boolean (and a b (.equalsIgnoreCase a b))))
+
+(defn string<
+ "Compare two (potentially-nil) strings. If either is nil, return nil.
+ Otherwise, return true iff a < b, ignoring case, and false
+ otherwise."
+ [^String a, ^String b]
+ (and a b (< (.compareToIgnoreCase a b) 0)))
+
+(defn number=
+ "Compare two (potentially-nil) numbers. If either is nil, return nil.
+ If either is NaN, return false. Otherwise, return true iff a = b,
+ and false otherwise."
+ [a b]
+ (boolean (and a b (== a b))))
+
+(defn number<
+ "Compare two (potentially-nil) numbers. If either is nil, return nil.
+ If either is NaN, return false. Otherwise, return true iff a < b,
+ and false otherwise."
+ [a b]
+ (boolean (and a b (< a b))))
+
+(defn boolean=
+ "Compare two (potentially-nil) booleans. If either is nil, return nil.
+ Otherwise, return true iff a = b, and false otherwise."
+ [a b]
+ (boolean (and (some? a) (some? b) (= a b))))
diff --git a/src/jester/expansion.clj b/src/jester/expansion.clj
new file mode 100644
index 0000000..b6c915b
--- /dev/null
+++ b/src/jester/expansion.clj
@@ -0,0 +1,460 @@
+(ns jester.expansion
+ (:require [clojure.string :as str]
+ [clojure.walk :as walk]
+ [jester.comparisons :refer [string= string<
+ number= number<
+ boolean=]]
+ [jester.match :refer [type-match]]
+ [jester.types :refer [in-constraint-environment
+ constrain
+ ground-type
+ type-variable]]))
+
+(defn ^:private type-of
+ "Return the type of an expanded form. This relies on the metadata,
+ except for primitive types where metadata can't be stored."
+ [expanded-form]
+ (cond (number? expanded-form) `(enum ~expanded-form)
+ (string? expanded-form) `(enum ~expanded-form)
+ (boolean? expanded-form) `(enum ~expanded-form)
+ (nil? expanded-form) `(optional ~(type-variable))
+ :else (::type (meta expanded-form))))
+
+(defn ^:private with-type [object type]
+ (with-meta object {::type type}))
+
+(def ^:private ^:dynamic *parameters*)
+
+(declare expand-form*)
+
+(defn regex-start-index
+ "Return the index of the first character in `string` which matches the
+ start of an instance of `regex`."
+ [regex string]
+ (let [matcher (re-matcher #"\??\." string)]
+ (when (.find matcher)
+ (.start matcher))))
+
+(defn expand-symbol [form]
+ (let [string (name form)]
+ (if-let [sep-index (regex-start-index #"\??\." string)]
+ (let [root (.substring string 0 sep-index)
+ rest (.substring string sep-index)
+ root-sym (symbol root)]
+ (expand-form* `(~(symbol rest)
+ ~root-sym)))
+ (with-type form
+ (or (get *parameters* form)
+ (let [type (type-variable)]
+ (set! *parameters* (assoc *parameters* form type))
+ type))))))
+
+(defn getter-expander [form]
+ (let [[head arg] form
+ string (name head)]
+ (when-not (or (str/starts-with? string ".")
+ (str/starts-with? string "?."))
+ (throw (ex-info "Unknown value in head of form"
+ {:form form})))
+ (let [;; This complicated regex splits the string into parts
+ ;; starting with either "?" or "?."
+ parts (str/split string #"(?=\?\.|(?<!\?)\.)")
+ part ^String (last parts)
+ rest (butlast parts)]
+ (cond
+ (str/starts-with? part ".")
+ (let [type (type-variable)
+ part-sym (symbol (.substring part 1))
+ expanded (expand-form*
+ (if (empty? rest)
+ arg
+ `(~(symbol (str/join rest)) ~arg)))]
+ (constrain (type-of expanded) {part-sym type})
+ (with-type `(get ~expanded '~part-sym)
+ type))
+ (str/starts-with? part "?.")
+ (let [type (type-variable)
+ part-sym (symbol (.substring part 2))
+ expanded (expand-form*
+ (if (empty? rest)
+ arg
+ `(~(symbol (str/join rest)) ~arg)))]
+ (constrain (type-of expanded)
+ `(optional {~part-sym (optional ~type)}))
+ (with-type `(get ~expanded '~part-sym)
+ `(optional ~type)))
+ :else
+ (throw (ex-info "Invalid head for getter"
+ {:head head
+ :part part
+ :rest rest}))))))
+
+(defn expand-map [form]
+ (let [expressions (reduce-kv (fn [acc k value-form]
+ (assoc acc (list 'quote k) (expand-form* value-form)))
+ {} form)]
+ (with-type expressions
+ (reduce-kv (fn [acc k expanded]
+ (assoc acc (second k) (type-of expanded)))
+ {} expressions))))
+
+(defn expand-vector [form]
+ (let [terms (mapv expand-form* form)]
+ (with-type terms
+ (mapv #(type-of %) terms))))
+
+(def expanders {})
+(def operators {})
+
+(defn expand-operator-form [operator form]
+ (let [result (type-variable)
+ args (map expand-form* (rest form))]
+ (constrain ((:type operator)) `(-> ~(mapv type-of args) ~result))
+ (with-type `(~(:name operator) ~@args)
+ result)))
+
+(defn expand-form* [form]
+ (cond
+ (number? form) form
+ (string? form) form
+ (boolean? form) form
+ (nil? form) form
+ (symbol? form) (expand-symbol form)
+ (map? form) (expand-map form)
+ (vector? form) (expand-vector form)
+ (seq? form) (if (symbol? (first form))
+ (if-let [operator (get operators (first form))]
+ (if (get expanders (first form))
+ (throw (ex-info (format "Found both an operator and an expander for %s"
+ (pr-str (first form)))
+ {:head (first form)
+ :form form}))
+ (expand-operator-form operator form))
+ (if-let [expander (get expanders (first form))]
+ (expander form)
+ (getter-expander form)))
+ (throw (ex-info "Invalid form"
+ {:form form})))
+ :else (throw (ex-info "Invalid form"
+ {:form form}))))
+
+(defn ^:private force-all
+ "Walk over a structure, iterating over child objects and forcing
+ sequences. The result is identical the input object.
+
+ (let [x ...]
+ (identical? x (force-all x)))
+ ;;=> true "
+ [obj]
+ (cond
+ ;; Reduce over sequences, vectors, and sets
+ (or (seq? obj)
+ (vector? obj)
+ (set? obj))
+ (reduce (fn [result item]
+ (force-all item)
+ result)
+ obj obj)
+ ;; Reduce over the keys and values in maps
+ (map? obj)
+ (reduce-kv (fn [result key value]
+ (force-all key)
+ (force-all value)
+ result)
+ obj obj)
+ ;; Return everything else unchanged.
+ :else
+ obj))
+
+(defn expand-form [form]
+ (binding [*parameters* {}]
+ (in-constraint-environment
+ (let [expanded (force-all (expand-form* form))
+ grounded (ground-type *parameters* :argument)]
+ ;; Once we've solved the input types, add constraints to
+ ;; ensure that information is captured when we solve the
+ ;; output type. We only need to add them as lower bounds,
+ ;; because :return grounding solves for the greatest lower
+ ;; bound.
+ (doseq [[param type] grounded]
+ (constrain type (get *parameters* param)))
+ (if (instance? clojure.lang.IMeta expanded)
+ (with-meta expanded
+ {:input grounded
+ :output (ground-type (type-of expanded) :return)})
+ expanded)))))
+
+(defmacro define-expander
+ {:style/indent 2}
+ [operator-name [& args] & body]
+ `(alter-var-root #'expanders
+ assoc '~operator-name (fn [[_# ~@args]]
+ ~@body)))
+
+(defn ^:private resolve-symbol [sym]
+ (let [resolved (meta (ns-resolve *ns* sym))
+ ns (:ns resolved)]
+ (when resolved
+ (symbol (-> ns ns-name name)
+ (-> resolved :name name)))))
+
+(defmacro define-simple-operator
+ {:style/indent :defn}
+ ([name type]
+ (if-let [resolved (resolve-symbol name)]
+ `(define-simple-operator ~name ~resolved ~type)
+ (throw (ex-info (format "Cannot resolve symbol for operator: %s" (pr-str name))
+ {:namespace *ns*
+ :symbol name}))))
+ ([name operator-symbol type]
+ (let [type (type-match type
+ (∀ args value)
+ `(let [~@(mapcat #(list % `(list 'var (gensym '~%))) args)]
+ ~(quote-type value (set args)))
+ _
+ (list 'quote type))]
+ (if (namespace operator-symbol)
+ `(alter-var-root #'operators assoc '~name
+ {:type (fn [] ~type)
+ :name '~operator-symbol})
+ (throw (ex-info (format "Operator symbol must have a namespace" (pr-str name))
+ {:symbol operator-symbol}))))))
+
+(defn ^:private e-use [form type]
+ (let [result (expand-form* form)]
+ (constrain (type-of result) type)
+ result))
+
+(defmacro ^:private with-temporary-parameter-type [[parameter type] & body]
+ `(try
+ (let [old# (get *parameters* ~parameter)]
+ (try
+ (set! *parameters* (assoc *parameters* ~parameter ~type))
+ (force-all (do ~@body))
+ (finally
+ (if old#
+ (set! *parameters* (assoc *parameters* ~parameter old#))
+ (set! *parameters* (dissoc *parameters* ~parameter))))))))
+
+(defn ? [optional default]
+ (if (nil? optional) default optional))
+
+(define-simple-operator ?
+ (∀ [x] (-> [(optional x) x] x)))
+
+(define-simple-operator string= (-> [string string] boolean))
+(define-simple-operator string=? (-> [(optional string) (optional string)] (optional boolean)))
+
+(define-expander find [[var & {list :in condition :when}] value]
+ (let [type (type-variable)
+ result (with-temporary-parameter-type [var type]
+ (expand-form* value))]
+ (with-type `(reduce (fn [_# ~var]
+ (when ~(with-temporary-parameter-type [var type]
+ (e-use (or condition true) '(optional boolean)))
+ (reduced ~result)))
+ nil ~(e-use list `(list ~type)))
+ `(optional ~(type-of result)))))
+
+(define-expander for [[var & {list :in condition :when}] value]
+ (let [type (type-variable)
+ result (with-temporary-parameter-type [var type]
+ (expand-form* value))]
+ (with-type `(reduce (fn [acc# ~var]
+ (if ~(with-temporary-parameter-type [var type]
+ (e-use (or condition true) '(optional boolean)))
+ (conj acc# ~result)
+ acc#))
+ [] ~(e-use list `(list ~type)))
+ `(list ~(type-of result)))))
+
+(define-expander all-of [[var & {list :in condition :when}] value]
+ (let [type (type-variable)]
+ (with-type `(reduce (fn [_# ~var]
+ (if ~(with-temporary-parameter-type [var type]
+ `(or (not ~(e-use (or condition true) '(optional boolean)))
+ ~(e-use value 'boolean)))
+ true
+ (reduced false)))
+ true ~(e-use list `(list ~type)))
+ 'boolean)))
+
+(define-expander one-of [[var & {list :in condition :when}] value]
+ (let [type (type-variable)]
+ (with-type `(reduce (fn [_# ~var]
+ (if ~(with-temporary-parameter-type [var type]
+ `(or (not ~(e-use (or condition true) '(optional boolean)))
+ ~(e-use value 'boolean)))
+ true
+ (reduced false)))
+ true ~(e-use list `(list ~type)))
+ 'boolean)))
+
+(define-expander with-optional [[var optional] value]
+ (assert (symbol? var) "Binding in with-optional must be a symbol")
+ (let [type (type-variable)
+ result (with-temporary-parameter-type [var type]
+ (expand-form* value))]
+ (with-type `(let [~var ~(e-use optional `(optional ~type))]
+ (when (some? ~var)
+ ~result))
+ `(optional ~(type-of result)))))
+
+(defn ^:private quote-type [type escape?]
+ (cond
+ (escape? type)
+ type
+ (seq? type)
+ (cons 'list (map #(quote-type % escape?) type))
+ ;; Reduce over sequences, vectors, and sets
+ (or (vector? type))
+ (reduce (fn [result item]
+ (conj result (quote-type item escape?)))
+ [] type)
+ ;; Reduce over the values in maps
+ (map? type)
+ (reduce-kv (fn [result key value]
+ (assoc result key (quote-type value)))
+ {} type)
+ ;; Return everything else quoted.
+ :else
+ (list 'quote type)))
+
+(define-simple-operator number= (-> [number number] boolean))
+(def number=? number=)
+(define-simple-operator number=? (-> [(optional number) (optional number)] (optional boolean)))
+
+;; Working out how exactly these should behave in the presence of
+;; optionals is a bit tricky, mostly because of short-circuiting. The
+;; easiest option is to not short circuit and define it as: "and? is
+;; nil if any argument is nil, false if any argument is false, and
+;; true otherwise". The obvious question then is "why does nil win
+;; over false? Why not have it false, then nil, then true?" Working
+;; out which makes the most sense might require some thinking.
+;; TODO: work this out.
+(define-simple-operator and
+ (-> [& (list boolean)] boolean))
+(define-simple-operator and? clojure.core/and
+ (-> [& (list (optional boolean))] (optional boolean)))
+
+(define-simple-operator or
+ (-> [& (list boolean)] boolean))
+(defmacro or? [& values]
+ (if-let [[x & xs] (seq values)]
+ `(let [val# ~x]
+ (cond (nil? val#) nil
+ (true? val#) true
+ :else `(or? ~@xs)))
+ false))
+(define-simple-operator or?
+ (-> [& (list (optional boolean))] (optional boolean)))
+
+(define-simple-operator not
+ (-> [boolean] boolean))
+(defn not? [value]
+ (cond (nil? value) nil
+ (true? value) false
+ :else true))
+(define-simple-operator not?
+ (-> [(optional boolean)] (optional boolean)))
+
+(define-simple-operator get
+ (∀ [k v] (-> [(map k v) k] (optional v))))
+
+(define-simple-operator str
+ (-> [& (list string)] string))
+
+(def str? str)
+(define-simple-operator str?
+ (-> [& (list (optional string))] string))
+
+(define-simple-operator count
+ (-> [(list any)] number))
+
+(def has? some?)
+(define-simple-operator has?
+ (-> [(optional any)] boolean))
+
+
+
+(import java.time.OffsetDateTime
+ java.time.ZonedDateTime
+ java.time.temporal.ChronoField
+ java.time.format.DateTimeFormatterBuilder)
+
+(def date-time-format
+ (-> (DateTimeFormatterBuilder.)
+ (.parseCaseInsensitive)
+ (.appendValue ChronoField/YEAR)
+ (.appendLiteral "-")
+ (.appendValue ChronoField/MONTH_OF_YEAR)
+ (.appendLiteral "-")
+ (.appendValue ChronoField/DAY_OF_MONTH)
+ (-> .optionalStart
+ (.appendLiteral "T")
+ (.appendValue ChronoField/HOUR_OF_DAY)
+ (.appendLiteral ":")
+ (.appendValue ChronoField/MINUTE_OF_HOUR)
+ (-> .optionalStart
+ (.appendLiteral ":")
+ (.appendValue ChronoField/SECOND_OF_MINUTE)
+ .optionalEnd)
+ .optionalEnd)
+ (-> .optionalStart
+ (.appendOffset "+HH:mm" "Z")
+ .optionalEnd)
+ (.parseDefaulting ChronoField/HOUR_OF_DAY 0)
+ (.parseDefaulting ChronoField/MINUTE_OF_HOUR 0)
+ (.parseDefaulting ChronoField/SECOND_OF_MINUTE 0)
+ (.toFormatter)
+ (.withZone java.time.ZoneOffset/UTC)))
+
+(defn parse-timestamp [string]
+ (.withOffsetSameInstant (OffsetDateTime/parse string date-time-format)
+ (java.time.ZoneOffset/UTC)))
+
+(parse-timestamp "2021-02-23")
+
+(define-simple-operator parse-timestamp
+ (-> [string] timestamp))
+
+(defn time-unit->chrono-unit [time-unit]
+ (condp #(.equalsIgnoreCase ^String %1 ^String %2)
+ time-unit
+ "second" java.time.temporal.ChronoUnit/SECONDS
+ "minute" java.time.temporal.ChronoUnit/MINUTES
+ "hour" java.time.temporal.ChronoUnit/HOURS
+ "day" java.time.temporal.ChronoUnit/DAYS
+ "month" java.time.temporal.ChronoUnit/MONTHS
+ "year" java.time.temporal.ChronoUnit/YEARS))
+(defn truncate-timestamp [^OffsetDateTime time time-unit]
+ (.truncatedTo time (time-unit->chrono-unit time-unit)))
+(defn truncate-timestamp? [time time-unit]
+ (and time time-unit (truncate-timestamp time time-unit)))
+
+(define-simple-operator truncate-timestamp
+ (-> [timestamp (enum "second" "minute" "hour" "day" "month" "year")] timestamp))
+(define-simple-operator truncate-timestamp?
+ (-> [(optional timestamp)
+ (optional (enum "second" "minute" "hour" "day" "month" "year"))]
+ (optional timestamp)))
+
+(define-simple-operator then jester.core/then
+ (∀ [x y] (-> [x y] y)))
+
+;; (alter-var-root #'operators
+;; assoc 'str {:type #(quote (-> [& (list string)] string))
+;; :name 'str})
+
+;; ;; (alter-var-root #'operators
+;; ;; assoc 'get {:type #(let [k (type-variable)
+;; ;; v (type-variable)]
+;; ;; `(-> [(map ~k ~v) ~k] (optional ~v)))})
+
+;; (alter-var-root #'operators
+;; assoc 'str? {:type #(quote (-> [& (list (optional string))] string))
+;; :name 'str})
+
+;; (alter-var-root #'operators
+;; assoc 'count {:type #(quote (-> [(list any)] number))
+;; :name 'count})
diff --git a/src/jester/match.clj b/src/jester/match.clj
new file mode 100644
index 0000000..5d18aec
--- /dev/null
+++ b/src/jester/match.clj
@@ -0,0 +1,142 @@
+(ns jester.match
+ "A simple pattern matcher, to avoid pulling in core.match. It is also
+ designed to specifically match against types, so it has some special
+ symbol handling."
+ (:require [clojure.walk :as walk]))
+
+(def ^:dynamic ^:private *seen-vars*)
+
+(defn ^:private special-type-symbol? [symbol]
+ (and (symbol? symbol)
+ (contains? #{"string" "number" "boolean" "any" "none" "_" "&"}
+ (name symbol))))
+
+(defmacro ^:private ensuring-unique-vars
+ {:style/indent 0}
+ [& body]
+ `(binding [*seen-vars* {}]
+ (let [result# (do ~@body)
+ multiple-vars# (into #{}
+ (comp (filter (fn [[_# v#]] (> v# 1)))
+ (map key))
+ *seen-vars*)]
+ (when-not (empty? multiple-vars#)
+ (throw (ex-info "Variables are bound multiple times"
+ {:names (mapv symbol multiple-vars#)})))
+ result#)))
+
+(declare pattern->condition)
+
+(defn seq-pattern->condition [pattern value]
+ (if-let [[pat-head & pat-tail] (seq pattern)]
+ (if (= pat-head '&)
+ (pattern->condition (first pat-tail) value)
+ (let [head-s (gensym "head")
+ tail-s (gensym "tail")]
+ `(let [[~head-s & ~tail-s] ~value]
+ (and ~(pattern->condition pat-head head-s)
+ ~(seq-pattern->condition pat-tail tail-s)))))
+ true))
+
+(defn ^:private pattern->condition [pattern value]
+ (cond
+ (vector? pattern)
+ (let [value-s (gensym "value")]
+ `(let [~value-s ~value]
+ (and (vector? ~value-s)
+ ~@(->> pattern
+ (take-while #(not= % '&))
+ (map-indexed (fn [i p]
+ (pattern->condition p `(get ~value-s ~i))))
+ doall)
+ ~@(when (seq (drop-while #(not= % '&) pattern))
+ [(pattern->condition (second (drop-while #(not= % '&) pattern))
+ `(subvec ~value-s
+ ~(count
+ (take-while #(not= % '&)
+ pattern))))]))))
+ (seq? pattern)
+ (let [[head & tail] pattern]
+ (assert (symbol? head) (str "Head of a seq pattern must be a symbol, not " (pr-str head)))
+ (let [head-s (gensym "head")
+ tail-s (gensym "tail")]
+ `(let [v# ~value]
+ (when (seq? v#)
+ (let [[~head-s & ~tail-s] v#]
+ (and (symbol? ~head-s)
+ (= ~(name head) (name ~head-s))
+ ~(seq-pattern->condition tail tail-s)))))))
+ (special-type-symbol? pattern)
+ (if (= (name pattern) "_")
+ true
+ `(let [v# ~value]
+ (and (symbol? v#)
+ (= ~(name pattern) (name v#)))))
+ (symbol? pattern)
+ (do (when-not (= (name pattern) "_")
+ (set! *seen-vars* (update *seen-vars* (name pattern) (fnil inc 0))))
+ true)
+ :else
+ `(= ~pattern ~value)))
+
+(defn ^:private pattern->destructuring-form [pattern]
+ (cond
+ (vector? pattern) (into [] (map pattern->destructuring-form) pattern)
+ (seq? pattern) (into ['_] (map pattern->destructuring-form) (rest pattern))
+ (symbol? pattern) (if (= pattern '&)
+ '&
+ (if (special-type-symbol? pattern)
+ '_
+ pattern))
+ :else '_))
+
+(defmacro type-match-let
+ {:style/indent 1}
+ [[pattern value] & body]
+ (ensuring-unique-vars
+ (let [value-s (gensym "value")]
+ `(let [~value-s ~value]
+ (if ~(pattern->condition pattern value-s)
+ (let [~(pattern->destructuring-form pattern) ~value-s]
+ ~@body)
+ (throw (ex-info "Value did not match pattern"
+ {:pattern '~pattern
+ :value ~value-s})))))))
+
+(defmacro type-match
+ {:style/indent 1}
+ [value & clauses]
+ (let [value-s (gensym "value")]
+ `(let [~value-s ~value]
+ (cond
+ ~@(mapcat (fn [[pattern result]]
+ (ensuring-unique-vars
+ [(pattern->condition pattern value-s)
+ `(let [~(pattern->destructuring-form pattern)
+ ~value-s]
+ ~result)]))
+ (partition 2 clauses))
+ :else
+ (throw (ex-info "Value did not match any pattern"
+ {:patterns '~(mapv first (partition 2 clauses))
+ :value ~value-s}))))))
+
+(defmacro defn-type-match [fn-name & clauses]
+ (let [[doc clauses] (if (and (string? (first clauses))
+ (odd? (count clauses)))
+ [(first clauses) (rest clauses)]
+ [nil clauses])
+ patterns (map first (partition 2 clauses))
+ all-symbols? (fn [pattern]
+ (every? (fn [s]
+ (and (symbol? s) (not (special-type-symbol? s))))
+ pattern))
+ argument-symbols (or (seq (first (filter all-symbols? patterns)))
+ (map #(do % (gensym "arg")) (first patterns)))]
+ (assert (every? vector? patterns) "All patterns in a defn-type-match must be a vector")
+ (assert (apply = (map count patterns)) "All patterns in a defn-type-match must have the same number of items")
+ `(defn ~fn-name
+ ~@(when doc [doc])
+ [~@argument-symbols]
+ (type-match [~@argument-symbols]
+ ~@clauses))))
diff --git a/src/jester/types.clj b/src/jester/types.clj
new file mode 100644
index 0000000..9451ea5
--- /dev/null
+++ b/src/jester/types.clj
@@ -0,0 +1,633 @@
+(ns jester.types
+ (:require [clojure.string :as str]
+ [jester.comparisons :refer [string= number= boolean=]]
+ [jester.match :refer [type-match-let type-match defn-type-match]]))
+
+;; Valid jester types are:
+;;
+;; These types are all "atomic":
+;; - any (supertype of all types)
+;; - none (subtype of all types)
+;; - number, string, boolean
+;; - (enum "a" ...), subtype of string
+;; - (enum 0 ...), subtype of number
+;; - (enum true ...), subtype of boolean
+
+;; These types are all "compound":
+;; - (optional α)
+;; - (list α), a list of values satisfying α
+;; - [α β], a tuple of α and β
+;; - [α β & γ], a tuple of α and β, and an additional sequence which matches γ
+;; - (map α β), a map of αs to βs
+;; - (-> [α β & γ] δ), a function taking an α, a β, and a sequence matching γ to δ
+;; - records, denoted by a map of property names to types
+
+;; Subtyping relationships are:
+;;
+;; - α ≤ any
+;; - (enum "a" ...) ≤ string
+;; - (enum 0 ...) ≤ number
+;; - (enum true ...) ≤ boolean
+;; - α ≤ (optional α)
+;; - [α α] ≤ (list α)
+;; - [α α & (list α)] ≤ (list α)
+;; - {l₁ α, l₂ β} ≤ {l₁ α}
+;; ... and more!
+
+(defn ^:private split-rest [coll]
+ (let [[main rest] (split-with #(not= % '&) coll)]
+ (if (seq rest)
+ (do (assert (= (count rest) 2) "Tail must consist of only one element")
+ [main (second rest)])
+ [main nil])))
+
+(defn ^:private assert-type*
+ [object type path root-object root-type]
+ (letfn [(type-error [message & format-args]
+ (throw (ex-info (apply format message format-args)
+ {:object object, :root-object root-object
+ :type type, :root-type root-type
+ :path path})))]
+ (type-match type
+ any true
+ none (type-error "No value can satisfy none, but got %s" (pr-str object))
+
+ string (when-not (string? object)
+ (type-error "Expected a string, but got %s" (pr-str object)))
+ number (when-not (number? object)
+ (type-error "Expected a number, but got %s" (pr-str object)))
+ boolean (when-not (boolean? object)
+ (type-error "Expected a boolean, but got %s" (pr-str object)))
+
+ (enum & values)
+ (when-not (some #(cond
+ (string? %) (and (string? object) (string= % object))
+ (number? %) (and (number? object) (number= % object))
+ (boolean? %) (and (boolean? object) (boolean= % object))
+ :else (assert false "Invalid enum value"))
+ values)
+ (if (empty? values)
+ (type-error "Empty enumeration type cannot have a value, but got %s"
+ (pr-str object))
+ (type-error "Expected %s, but got %s"
+ (cond
+ (= (count values) 1) (pr-str (first values))
+ (= (count values) 2) (str (pr-str (first values)) " or " (pr-str (second values)))
+ :else (str (str/join ", " (map pr-str (butlast values))) ", or " (pr-str (last values))))
+ (pr-str object))))
+
+ (optional t)
+ (when-not (nil? object)
+ (assert-type* object t path root-object root-type))
+
+ [& types]
+ (if (sequential? object)
+ (let [[main rest] (split-rest types)]
+ (if (< (count object) (count main))
+ (type-error "Expected at least %s item%s, but got %s" (count main) (if (> (count main) 1) "s" "") (count object))
+ (do (reduce (fn [index [item-type item]]
+ (assert-type* item item-type (conj path index) root-object root-type)
+ (inc index))
+ 0 (map vector main object))
+ (if rest
+ (assert-type* (drop (count main) object) rest (conj path (list 'drop (count main))) root-object root-type)
+ (when-not (= (count object) (count main))
+ (type-error "Expected at most %s item%s, but got %s" (count main) (if (> (count main) 1) "s" "") (count object)))))))
+ (type-error "Expected a sequential object, but got %s" (pr-str object)))
+
+ (list item-type)
+ (if (sequential? object)
+ (reduce (fn [index item]
+ (assert-type* item item-type (conj path index) root-object root-type)
+ (inc index))
+ 0 object)
+ (type-error "Expected a sequential object, but got %s" (pr-str object)))
+
+ (map k v)
+ (if (map? object)
+ (reduce-kv (fn [_ key value]
+ (assert-type* key k type root-object root-type)
+ (assert-type* value v (conj path key) root-object root-type))
+ nil object)
+ (type-error "Expected a map, but got %s" (pr-str object)))
+
+ (-> args result)
+ (type-error "Cannot verify function type of %s for %s" (pr-str type) (pr-str object))
+
+ _
+ (if (map? type)
+ (if (map? object)
+ (reduce-kv (fn [_ key key-type]
+ (assert-type* (get object key) key-type (conj path key) root-object root-type))
+ nil type)
+ (type-error "Expected an object (map), but got %s" (pr-str object)))
+ (type-error "Invalid type: %s" (pr-str type))))))
+
+(defn assert-type
+ "Assert that `object` is of type `type`. Throws an exception with
+ failure details if this is not the case. Returns nil otherwise."
+ [object type]
+ (assert-type* object type [] object type)
+ nil)
+
+(defn-type-match subtype?
+ "Returns true if every object of type `sub` also satisfies the type
+ `super`. Returns false otherwise."
+
+ [_ any] true
+ [none _] true
+
+ [(enum & values) string] (every? string? values)
+ [(enum & values) number] (every? number? values)
+ [(enum & values) boolean] (every? boolean? values)
+
+ [(enum & sub-values) (enum & super-values)]
+ (every? (fn [sub-value]
+ (cond (string? sub-value) (some #(and (string? %) (string= sub-value %))
+ super-values)
+ (number? sub-value) (some #(and (number? %) (number= sub-value %))
+ super-values)
+ (boolean? sub-value) (some #(and (boolean? %) (boolean= sub-value %))
+ super-values)
+ :else (assert false "We should not reach here.")))
+ sub-values)
+
+ [(optional subtype) (optional supertype)]
+ (subtype? subtype supertype)
+
+ [subtype (optional supertype)]
+ (subtype? subtype supertype)
+
+ ;; This is unfortunately quite complicated. :(
+ [[& subtypes] [& supertypes]]
+ (let [[sub-main sub-rest] (split-rest subtypes)
+ [super-main super-rest] (split-rest supertypes)]
+ (and
+ ;; The subtype must guarantree at least as many elements as the
+ ;; supertype.
+ (>= (count sub-main) (count super-main))
+ ;; Any compulsory types from both arrays are subtypes
+ (every? true? (map subtype? sub-main super-main))
+ (or
+ ;; If there are no excess compulsory subtypes then we're good.
+ (= (count sub-main) (count super-main))
+ ;; Otherwise, we need to check them against the super-rest.
+ (if super-rest
+ (subtype? (->> sub-main
+ (drop (count super-main))
+ vec)
+ super-rest)
+ ;; If there's no super-rest, then we just have excess types,
+ ;; which means there's no subtype relationship here.
+ false))
+ ;; If the subtype has a rest, then the supertype does too and
+ ;; they have an appropriate subtyping relationship
+ (if sub-rest
+ (and super-rest
+ (subtype? sub-rest super-rest))
+ true)))
+
+ [[& subtypes] (list supertype)]
+ (let [[sub-main sub-rest] (split-rest subtypes)]
+ (and (every? #(subtype? % supertype) sub-main)
+ (if sub-rest
+ (subtype? sub-rest `(list ~supertype))
+ true)))
+
+ [(list subtype) (list supertype)]
+ (subtype? subtype supertype)
+
+ [(map sub-key sub-val) (map super-key super-val)]
+ (and (subtype? super-key sub-key)
+ (subtype? sub-val super-val))
+
+ [(-> sub-args sub-result) (-> super-args super-result)]
+ (and (subtype? super-args sub-args)
+ (subtype? sub-result super-result))
+
+ [subtype supertype]
+ (cond
+ (and (map? subtype) (map? supertype))
+ (reduce-kv (fn [_ prop prop-type]
+ (or (subtype? (get subtype prop) prop-type)
+ (reduced false)))
+ nil supertype)
+
+ (and (symbol? subtype) (symbol? supertype))
+ (= (name subtype) (name supertype))
+
+ :else
+ false))
+
+(def ^:private ^:dynamic *upper-bounds*)
+(def ^:private ^:dynamic *lower-bounds*)
+
+(defn call-in-constraint-environment [thunk]
+ (binding [*upper-bounds* {}
+ *lower-bounds* {}]
+ (thunk)))
+
+(defmacro in-constraint-environment
+ {:style/indent 0}
+ [& body]
+ `(call-in-constraint-environment #(do ~@body)))
+
+(defn-type-match constrain
+ "Add a constraint that `sub` is a subtype of `super` within the
+ current constraint environment (see `in-constraint-environment`).
+ The return value of this function is meaningless, and it will throw
+ an exception if the provided constraint leads to a contradiction."
+
+ ;; No point doing anything for the constraints that aren't really constraints.
+ [_ any] nil
+ [none _] nil
+
+ [subtype (var n)]
+ (when-not (contains? (get *lower-bounds* n) subtype)
+ (set! *lower-bounds* (update *lower-bounds* n (fnil conj #{}) subtype))
+ (doseq [upper (get *upper-bounds* n)]
+ (constrain subtype upper))
+ ;; The type hierarchy splits for all of the "atomic" types, so if
+ ;; we're constrained to be a supertype of one of the divisions
+ ;; then add a constraint on the top as well. While there is still
+ ;; the `any` type at the top of the hierachy, we only want it to
+ ;; show up for truly unconstrained types. This makes things like
+ ;; (? a 10) infer `a` to be (optional number) rather than the less
+ ;; accurate/helpful (optional any).
+ (type-match subtype
+ string (constrain `(var ~n) `(optional string))
+ number (constrain `(var ~n) `(optional number))
+ boolean (constrain `(var ~n) `(optional boolean))
+ (enum & values) (do (when (some string? values)
+ (constrain `(var ~n) `(optional string)))
+ (when (some number? values)
+ (constrain `(var ~n) `(optional number)))
+ (when (some boolean? values)
+ (constrain `(var ~n) `(optional boolean))))
+ _ nil))
+
+ [(var n) supertype]
+ (when-not (contains? (get *upper-bounds* n) supertype)
+ (set! *upper-bounds* (update *upper-bounds* n (fnil conj #{}) supertype))
+ (doseq [lower (get *lower-bounds* n)]
+ (constrain lower supertype))
+ ;; If we're a subtype of an optional type then add an artificial
+ ;; "unoptional" constraint to track the constraint against the
+ ;; wrapped type variable.
+ (type-match supertype
+ (optional x) (constrain `(unoptional (var ~n))
+ x)
+ _ nil))
+
+ [subtype (optional super)]
+ (constrain (loop [subtype subtype]
+ (type-match subtype
+ (optional sub) (recur sub)
+ _ subtype))
+ super)
+
+ [(unoptional _) _]
+ nil ;; This is a weird extra constraint we add to propagate the
+ ;; restrictions imposed by having an optional on the RHS. It
+ ;; doesn't impose any other constraint, so just ingore it.
+
+ [[& subtypes] [& supertypes]]
+ (let [[sub-main sub-rest] (split-rest subtypes)
+ [super-main super-rest] (split-rest supertypes)]
+ (when (< (count sub-main) (count super-main))
+ (throw (ex-info "Constraint error: cannot solve type relation"
+ {:subtype (vec subtypes)
+ :supertype (vec supertypes)})))
+ (doall (map constrain sub-main super-main))
+ (when-not (= (count sub-main) (count super-main))
+ (if super-rest
+ (constrain (->> sub-main
+ (drop (count super-main))
+ vec)
+ super-rest)
+ (throw (ex-info "Constraint error: cannot solve type relation"
+ {:subtype (vec subtypes)
+ :supertype (vec supertypes)}))))
+ (when sub-rest
+ (if super-rest
+ (constrain sub-rest super-rest)
+ (throw (ex-info "Constraint error: cannot solve type relation"
+ {:subtype (vec subtypes)
+ :supertype (vec supertypes)})))))
+
+ [[& subtypes] (list supertype)]
+ (let [[sub-main sub-rest] (split-rest subtypes)]
+ (doall (map #(constrain % supertype) sub-main))
+ (when sub-rest
+ (constrain sub-rest `(list ~supertype))))
+
+ [(list sub) (list super)]
+ (constrain sub super)
+
+ [(map subk subv) (map superk superv)]
+ (do (constrain superk subk)
+ (constrain subv superv))
+
+ [(-> sub-args sub-result) (-> super-args super-result)]
+ (do (constrain super-args sub-args)
+ (constrain sub-result super-result))
+
+ [subtype supertype]
+ (cond
+ (and (map? subtype) (map? supertype))
+ (doseq [[property super-value] supertype
+ :let [sub-value (get subtype property)]]
+ (if (nil? sub-value)
+ (throw (ex-info "Object type does not meet subtype constraint"
+ {:subtype subtype
+ :supertype supertype}))
+ (constrain sub-value super-value)))
+
+ (not (subtype? subtype supertype))
+ (throw (ex-info "Constraint error: cannot solve type relation"
+ {:subtype subtype
+ :supertype supertype}))))
+
+;; ;; This helper just prints out all the constraints that get added.
+;; (def constrain (let [c constrain]
+;; (fn [a b]
+;; (prn a '≤ b)
+;; (c a b))))
+
+(declare union-types)
+
+(defn-type-match ^:private intersect-types
+ "Return a type that is a subtype of both `t1` and `t2`. The
+ resulting type should be the greatest such type (that is, for all x
+ that are subtypes of both t1 and t2, x is a subtype of, or equal
+ to, (intersect-types t1 t2))."
+
+ [any t] t
+ [t any] t
+ [none _] 'none
+ [_ none] 'none
+
+ [(enum & t1values) (enum & t2values)]
+ (if-let [common-values (seq (filter (fn [t1value]
+ (cond (string? t1value) (some #(and (string? %) (string= t1value %))
+ t2values)
+ (number? t1value) (some #(and (number? %) (number= t1value %))
+ t2values)
+ (boolean? t1value) (some #(and (boolean? %) (boolean= t1value %))
+ t2values)
+ :else (assert false "We should not reach here.")))
+ t1values))]
+ (cons 'enum common-values)
+ 'none)
+
+ [(optional t1) (optional t2)]
+ (list 'optional (intersect-types t1 t2))
+
+ [(optional t1) t2]
+ (intersect-types t1 t2)
+ [t1 (optional t2)]
+ (intersect-types t1 t2)
+
+ [[& t1s] [& t2s]]
+ (let [[t1-main t1-rest] (split-rest t1s)
+ [t2-main t2-rest] (split-rest t2s)]
+ (cond
+ (empty? t1-main)
+ (intersect-types t1-rest t2s)
+ (empty? t2-main)
+ (intersect-types t1s t2-rest)
+ (= (count t1-main) (count t2-main))
+ (into (mapv intersect-types t1-main t2-main)
+ (when (and t1-rest t2-rest)
+ ['& (intersect-types t1-rest t2-rest)]))
+ (< (count t1-main) (count t2-main))
+ (if t1-rest
+ (into (mapv intersect-types t1-main t2-main)
+ (concat (intersect-types t1-rest
+ (->> t2-main
+ (drop (count t1-main))
+ vec))
+ (when t2-rest
+ ['& (intersect-types t1-rest t2-rest)])))
+ (throw (ex-info "No value can possible satisfy type"
+ {:type (list 'intersect t1 t2)})))
+ (> (count t1-main) (count t2-main))
+ (if t2-rest
+ (into (mapv intersect-types t1-main t2-main)
+ (concat (intersect-types (->> t1-main
+ (drop (count t2-main))
+ vec)
+ t2-rest)
+ (when t1-rest
+ ['& (intersect-types t1-rest t2-rest)])))
+ (throw (ex-info "No value can possible satisfy type"
+ {:type (list 'intersect t1 t2)})))
+ :else
+ (throw (ex-info "No value can possible satisfy type"
+ {:type (list 'intersect t1 t2)}))))
+
+ [[& t1s] (list t2)]
+ (mapv #(intersect-types % t2) t1s)
+
+ [(list t1) [& t2s]]
+ (mapv #(intersect-types t1 %) t2s)
+
+ [(list t1) (list t2)]
+ (list 'list (intersect-types t1 t2))
+
+ [(map t1k t1v) (map t2k t2v)]
+ (list 'map
+ (union-types t1k t2k)
+ (intersect-types t1v t2v))
+
+ [(-> t1k t1v) (-> t2k t2v)]
+ (list '->
+ (union-types t1k t2k)
+ (intersect-types t1v t2v))
+
+ [t1 t2]
+ (cond
+ (and (map? t1) (map? t2)) (merge-with intersect-types t1 t2)
+ (subtype? t1 t2) t1
+ (subtype? t2 t1) t2
+ :else (throw (ex-info "No value can possible satisfy type"
+ {:type (list 'intersect t1 t2)}))))
+
+(defn-type-match ^:private union-types
+ "Return a type that is a supertype of both `t1` and `t2`. The
+ resulting type should be the least such type (that is, for all x
+ that are supertypes of both t1 and t2, x is a supertype of, or equal
+ to, (union-types t1 t2))."
+
+ [any _] 'any
+ [_ any] 'any
+ [none t] t
+ [t none] t
+
+ [(enum & t1values) (enum & t2values)]
+ (cons 'enum
+ (concat (remove (fn [t1value]
+ (cond (string? t1value) (some #(and (string? %) (string= t1value %))
+ t2values)
+ (number? t1value) (some #(and (number? %) (number= t1value %))
+ t2values)
+ (boolean? t1value) (some #(and (boolean? %) (boolean= t1value %))
+ t2values)
+ :else (assert false "We should not reach here.")))
+ t1values)
+ t2values))
+
+ [(optional t1) (optional t2)]
+ (list 'optional (union-types t1 t2))
+
+ [(optional t1) t2]
+ (list 'optional (union-types t1 t2))
+ [t1 (optional t2)]
+ (list 'optional (union-types t1 t2))
+
+ [[& t1s] [& t2s]]
+ (let [[t1-main t1-rest] (split-rest t1s)
+ [t2-main t2-rest] (split-rest t2s)]
+ (cond
+ (empty? t1-main)
+ (union-types t1-rest t2s)
+ (empty? t2-main)
+ (union-types t1s t2-rest)
+ (= (count t1-main) (count t2-main))
+ (into (mapv union-types t1-main t2-main)
+ (when (and t1-rest t2-rest)
+ ['& (union-types t1-rest t2-rest)]))
+ (< (count t1-main) (count t2-main))
+ (if t1-rest
+ (into (mapv union-types t1-main t2-main)
+ (concat (union-types t1-rest
+ (->> t2-main
+ (drop (count t1-main))
+ vec))
+ (when t2-rest
+ ['& (union-types t1-rest t2-rest)])))
+ (throw (ex-info "Cannot construct type union"
+ {:type (list 'union t1 t2)})))
+ (> (count t1-main) (count t2-main))
+ (if t2-rest
+ (into (mapv union-types t1-main t2-main)
+ (concat (union-types (->> t1-main
+ (drop (count t2-main))
+ vec)
+ t2-rest)
+ (when t1-rest
+ ['& (union-types t1-rest t2-rest)])))
+ (throw (ex-info "Cannot construct type union"
+ {:type (list 'union t1 t2)})))
+ :else
+ (throw (ex-info "Cannot construct type union"
+ {:type (list 'union t1 t2)}))))
+
+ [[& t1s] (list t2)]
+ (let [[t1-main t1-rest] (split-rest t1s)]
+ (into (mapv #(union-types % t2) t1-main)
+ (if t1-rest
+ ['& (union-types t1-rest t2)]
+ ['& `(list ~t2)])))
+
+ [(list t1) [& t2s]]
+ (let [[t2-main t2-rest] (split-rest t2s)]
+ (into (mapv #(union-types t1 %) t2-main)
+ (if t2-rest
+ ['& (union-types t1 t2-rest)]
+ ['& `(list ~t1)])))
+
+ [(list t1) (list t2)]
+ (list 'list (union-types t1 t2))
+
+ [(map t1k t1v) (map t2k t2v)]
+ (list 'map
+ (intersect-types t1k t2k)
+ (union-types t1v t2v))
+
+ [(-> t1k t1v) (-> t2k t2v)]
+ (list '->
+ (intersect-types t1k t2k)
+ (union-types t1v t2v))
+
+ [t1 t2]
+ (cond
+ (subtype? t1 t2) t2
+ (subtype? t2 t1) t1
+ :else (throw (ex-info "Cannot construct type union"
+ {:type (list 'union t1 t2)}))))
+
+(defn-type-match ground-type
+ "Attempt to eliminate type-variables from `type` by solving any
+ constraints that can be solved. If `position` is :argument then
+ variables will be solved for their least upper bound, if `position`
+ is :return
+ bound."
+
+ [(var n) position]
+ (do (when-not (or (= position :argument) (= position :return))
+ (throw (ex-info (str "Invalid position: " (pr-str position))
+ {:position position})))
+ (if-let [type-bounds (->> (get (case position
+ :argument *upper-bounds*
+ :return *lower-bounds*)
+ n)
+ (map #(ground-type % position))
+ seq)]
+ (reduce (case position
+ :argument intersect-types
+ :return union-types)
+ type-bounds)
+ 'any))
+
+ [(enum & values) _]
+ (cons 'enum values)
+
+ [(optional t) position]
+ (list 'optional
+ (type-match (ground-type t position)
+ (optional t2) t2
+ t2 t2))
+
+ [(unoptional t) position]
+ (type-match (ground-type t position)
+ (optional t2) t2
+ t2 t2)
+
+ [[& ts] position]
+ (let [[t-main t-rest] (split-rest ts)]
+ (into (mapv #(ground-type % position) t-main)
+ (when t-rest
+ ['& (ground-type t-rest position)])))
+
+ [(list t) position]
+ (list 'list (ground-type t position))
+
+ [(map k v) position]
+ (list 'map
+ (ground-type k (case position
+ :argument :return
+ :return :argument))
+ (ground-type v position))
+
+ [(-> k v) position]
+ (list '->
+ (ground-type k (case position
+ :argument :return
+ :return :argument))
+ (ground-type v position))
+
+ [type position]
+ (cond
+ (map? type) (persistent!
+ (reduce-kv (fn [acc prop prop-type]
+ (assoc! acc prop (ground-type prop-type position)))
+ (transient {}) type))
+ (symbol? type) (symbol (name type))
+ :else (throw (ex-info "Cannot ground invalid type" {:type type}))))
+
+(defn type-variable
+ "Create a new type variable, distinct from all existing type
+ variables."
+ []
+ (list 'var (gensym "type")))
diff --git a/test/jester/types_test.clj b/test/jester/types_test.clj
new file mode 100644
index 0000000..84b28fe
--- /dev/null
+++ b/test/jester/types_test.clj
@@ -0,0 +1,281 @@
+(ns jester.types-test
+ (:require [clojure.test :refer [deftest testing is are]]
+ [clojure.test.check :refer [quick-check]]
+ [clojure.test.check.generators :as gen]
+ [clojure.test.check.properties :refer [for-all]]
+ [jester.types :as sut]))
+
+(def string-enum-gen (gen/fmap #(cons 'enum %) (gen/vector gen/string)))
+(def number-enum-gen (gen/fmap #(cons 'enum %) (gen/vector gen/double)))
+(def boolean-enum-gen (gen/fmap #(cons 'enum %) (gen/vector gen/boolean)))
+(def enum-gen (gen/one-of [string-enum-gen
+ number-enum-gen
+ boolean-enum-gen]))
+
+(def atomic-type-gen
+ (gen/one-of [(gen/return 'any)
+ (gen/return 'none)
+ (gen/return 'string)
+ (gen/return 'number)
+ (gen/return 'boolean)
+ enum-gen]))
+
+(defn optional-type-gen [subgen]
+ (gen/fmap #(list 'optional %) subgen))
+
+(defn list-type-gen [subgen]
+ (gen/fmap #(list 'list %) subgen))
+
+(defn array-type-gen [subgen]
+ (gen/recursive-gen (fn [gen]
+ (gen/fmap (fn [[main rest]]
+ (into main (when rest ['& rest])))
+ (gen/tuple (gen/vector gen)
+ (gen/one-of
+ [(gen/return nil)
+ (array-type-gen gen)
+ (list-type-gen gen)]))))
+ subgen))
+
+(defn map-type-gen [keygen valgen]
+ (gen/fmap #(cons 'map %) (gen/tuple keygen valgen)))
+
+(defn func-type-gen [argsgen resultgen]
+ (gen/fmap #(cons '-> %) (gen/tuple argsgen resultgen)))
+
+(defn record-type-gen [subgen]
+ (gen/map gen/symbol subgen))
+
+(defn compound-type-gen [subgen]
+ (gen/one-of [(optional-type-gen subgen)
+ (list-type-gen subgen)
+ (array-type-gen subgen)
+ (map-type-gen subgen subgen)
+ (func-type-gen (array-type-gen subgen) subgen)
+ (record-type-gen subgen)]))
+
+(gen/sample (compound-type-gen atomic-type-gen))
+
+(deftest assert-type
+ (testing "happy paths"
+ (are [object type]
+ (nil? (sut/assert-type 'object 'type))
+
+ nil any
+ "a" any
+ 10 any
+ true any
+
+ "b" string
+ 12 number
+ false boolean
+
+ "b" (enum "b")
+ 14 (enum 14)
+ true (enum true)
+
+ nil (optional string)
+ "a" (optional string)
+
+ nil (optional number)
+ 16 (optional number)
+
+ nil (optional boolean)
+ false (optional boolean)
+
+ ["a" "b"] [string string]
+ ["a" false] [string boolean]
+ ["a" false] [string & (list boolean)]
+
+ [true false] (list boolean)
+ [10 20] (list number)
+
+ {"a" 30} (map string number)
+ {a 30} {a number}))
+
+ (testing "failure cases"
+ (are [object type msg]
+ (thrown-with-msg? clojure.lang.ExceptionInfo msg (sut/assert-type 'object 'type))
+
+ nil none #"No value can satisfy none"
+ "a" none #"No value can satisfy none"
+ 10 none #"No value can satisfy none"
+ true none #"No value can satisfy none"
+
+ "b" number #"Expected a number, but got \"b\""
+ 12 boolean #"Expected a boolean, but got 12"
+ true string #"Expected a string, but got true"
+
+ "b" (enum "a") #"Expected \"a\", but got \"b\""
+ 2 (enum 1) #"Expected 1, but got 2"
+ false (enum true) #"Expected true, but got false"
+
+ "b" (enum "a" "c" "d") #"Expected \"a\", \"c\", or \"d\", but got \"b\""
+ 2 (enum 1 3) #"Expected 1 or 3, but got 2"
+
+ "a" (optional number) #"Expected a number, but got \"a\""
+
+ ["a" "b"] [string number] #"Expected a number, but got \"b\""
+ ["a" "b"] [string & (list number)] #"Expected a number, but got \"b\""
+ ["a" "b"] [string] #"Expected at most 1 item, but got 2"
+ ["a" "b" "c"] [string string] #"Expected at most 2 items, but got 3"
+ [] [string] #"Expected at least 1 item, but got 0"
+ ["a"] [string string] #"Expected at least 2 items, but got 1"
+
+ ["a" 2] (list string) #"Expected a string, but got 2"
+
+ {"a" 20} (map number number) #"Expected a number, but got \"a\""
+ {"a" 20} (map string string) #"Expected a string, but got 20"
+
+ {a 10} {a string} #"Expected a string, but got 10")))
+
+
+(defn ^:privat grounded-bounds
+ "Return the current [lower upper] bounds of `type`."
+ [type]
+ [(sut/ground-type type :argument)
+ (sut/ground-type type :return)])
+
+(deftest any-type
+ (testing 'assert-type
+ (are [object] (nil? (sut/assert-type 'object 'any))
+ nil
+ 1
+ "a"
+ true
+ [1]
+ {a "a"}
+ {"a" "b"}))
+ (testing 'subtype?
+ (are [type] (sut/subtype? 'type 'any)
+ none
+ string
+ number
+ boolean
+ (list string)
+ (map number boolean)
+ (-> [string] number)
+ {a number})
+ (are [type] (not (sut/subtype? 'any 'type))
+ none
+ string
+ number
+ boolean
+ (list string)
+ (map number boolean)
+ (-> [string] number)
+ {a number}))
+ (testing '(constrain ground)
+ (sut/in-constraint-environment
+ (sut/constrain '(var n) 'any)
+ (is (= ['any 'any] (grounded-bounds '(var n)))))
+ (sut/in-constraint-environment
+ (sut/constrain 'any '(var n))
+ (is (= ['any 'any] (grounded-bounds '(var n)))))
+ (sut/in-constraint-environment
+ (is (= ['any 'any] (grounded-bounds '(var n)))))))
+
+(deftest enum-type
+ (testing 'assert-type
+ (are [object type] (nil? (sut/assert-type 'object 'type))
+ "a" (enum "a")
+ "a" (enum "a" "b")
+ 1 (enum 1)
+ 1 (enum 1 2)
+ true (enum true)
+ true (enum false true))
+ (are [object type msg]
+ (thrown-with-msg? clojure.lang.ExceptionInfo msg
+ (sut/assert-type 'object 'type))
+ "a" (enum "b" "c" "d") #"Expected \"b\", \"c\", or \"d\", but got \"a\""
+ 1 (enum 2 3) #"Expected 2 or 3, but got 1"
+ true (enum false) #"Expected false, but got true"
+ nil (enum 1) #"Expected 1, but got nil"
+ [] (enum 1) #"Expected 1, but got \[\]"
+ {} (enum 1) #"Expected 1, but got \{\}"))
+ (testing 'subtype?
+ (are [sub super] (sut/subtype? 'sub 'super)
+ none (enum)
+ (enum) any
+ (enum "a") (enum "a")
+ (enum "a") (enum "a" "b")
+ (enum 1) (enum 1)
+ (enum 1) (enum 1 2)
+ (enum true) (enum true)
+ (enum true) (enum true false)
+ (enum) (enum "a"))
+ (are [sub super] (not (sut/subtype? 'sub 'super))
+ (enum "a") (enum "b")
+ (enum 1) (enum 2)
+ (enum true) (enum false)
+ (enum "a") (enum)))
+ (testing '(constrain ground)
+ (sut/in-constraint-environment
+ (sut/constrain '(var a) '(enum "a" "b" "c"))
+ (sut/constrain '(var a) '(enum "a"))
+ (is (= '[(enum "a") any] (grounded-bounds '(var a)))))
+ (sut/in-constraint-environment
+ (sut/constrain '(enum "a" "b" "c") '(var a))
+ (sut/constrain '(enum "a") '(var a))
+ (is (= '[(optional string) (enum "b" "c" "a")] (grounded-bounds '(var a)))))
+ (sut/in-constraint-environment
+ (sut/constrain '(var a) '(enum "a" "b" "c"))
+ (sut/constrain '(var a) '(enum "x"))
+ (is (= '[none any] (grounded-bounds '(var a)))))))
+
+(deftest subtype?
+ (testing "when there is a subtyping relationship"
+ (are [subtype supertype]
+ (is (sut/subtype? 'subtype 'supertype))
+ string any
+ none string
+
+ string string
+ number number
+ boolean boolean
+ (enum "a") (enum "a")
+ (enum 1) (enum 1)
+ (enum 2) (enum 2)
+
+ (enum "a") string
+ (enum 1) number
+ (enum false) boolean
+
+ string (optional string)
+ (enum "a") (optional string)
+ number (optional number)
+ (enum 1) (optional number)
+ boolean (optional boolean)
+ (enum true) (optional boolean)
+
+ (optional string) (optional string)
+ (optional (enum "a")) (optional string)
+ (optional number) (optional number)
+ (optional (enum 1)) (optional number)
+ (optional boolean) (optional boolean)
+ (optional (enum true)) (optional boolean)
+
+ [string] [string]
+ [string string] [string string]
+ [string] [string & (list string)]
+ [string string] [string & (list string)]
+ [number string] [number & (list string)]
+ [number & (list string)] [number & (list string)]
+ [number string & (list string)] [number & (list string)]
+ (map string number) (map string number)
+ (map string (enum 1)) (map (enum "a") number)
+
+ (-> [string number] boolean) (-> [string number] boolean)
+ (-> [string & (list number)] (enum true)) (-> [string number] boolean)
+
+ {a string} {a string}
+ {a string, b string} {a string}
+ {a string, b string} {a (optional string)}))
+ (testing "when there isn't a subtyping relationship"
+ ))
+
+(deftest failing-case
+ (sut/in-constraint-environment
+ (sut/constrain '[(var a) & (var n)] '[(enum 1) & [(enum 3) (enum 4)]])
+ (is (= '[(enum 1) any] (grounded-bounds '(var a))))
+ (is (= '[[(enum 3) (enum 4)] any] (grounded-bounds '(var n))))))