浏览代码

Merge branch 'master' into feat/format-matcher

# Conflicts:
#	include/jvalidate/validation_visitor.h
#	include/jvalidate/validator.h
#	tests/selfvalidate_test.cxx
Sam Jaffe 3 月之前
父节点
当前提交
cdf837a3a6
共有 50 个文件被更改,包括 4087 次插入994 次删除
  1. 33 6
      Makefile
  2. 168 4
      include/jvalidate/adapter.h
  3. 7 5
      include/jvalidate/adapters/jsoncpp.h
  4. 4 1
      include/jvalidate/detail/compare.h
  5. 74 0
      include/jvalidate/compat/enumerate.h
  6. 827 128
      include/jvalidate/constraint.h
  7. 9 42
      include/jvalidate/constraint/array_constraint.h
  8. 0 30
      include/jvalidate/constraint/constraint.h
  9. 25 0
      include/jvalidate/constraint/extension_constraint.h
  10. 10 88
      include/jvalidate/constraint/general_constraint.h
  11. 4 19
      include/jvalidate/constraint/number_constraint.h
  12. 9 61
      include/jvalidate/constraint/object_constraint.h
  13. 5 21
      include/jvalidate/constraint/string_constraint.h
  14. 0 51
      include/jvalidate/constraint/visitor.h
  15. 26 1
      include/jvalidate/detail/anchor.h
  16. 19 11
      include/jvalidate/detail/array_iterator.h
  17. 5 0
      include/jvalidate/detail/deref_proxy.h
  18. 59 0
      include/jvalidate/detail/dynamic_reference_context.h
  19. 58 0
      include/jvalidate/detail/expect.h
  20. 19 0
      include/jvalidate/detail/number.h
  21. 32 9
      include/jvalidate/detail/object_iterator.h
  22. 11 0
      include/jvalidate/detail/on_block_exit.h
  23. 78 8
      include/jvalidate/detail/out.h
  24. 119 6
      include/jvalidate/detail/pointer.h
  25. 60 20
      include/jvalidate/detail/reference.h
  26. 93 0
      include/jvalidate/detail/reference_cache.h
  27. 207 25
      include/jvalidate/detail/reference_manager.h
  28. 55 0
      include/jvalidate/detail/relative_pointer.h
  29. 91 0
      include/jvalidate/detail/scoped_state.h
  30. 53 4
      include/jvalidate/detail/simple_adapter.h
  31. 39 2
      include/jvalidate/detail/string.h
  32. 77 0
      include/jvalidate/detail/string_adapter.h
  33. 101 0
      include/jvalidate/detail/tribool.h
  34. 86 46
      include/jvalidate/detail/vocabulary.h
  35. 42 0
      include/jvalidate/document_cache.h
  36. 63 0
      include/jvalidate/extension.h
  37. 107 61
      include/jvalidate/forward.h
  38. 110 1
      include/jvalidate/schema.h
  39. 2 54
      include/jvalidate/status.h
  40. 64 1
      include/jvalidate/uri.h
  41. 0 1
      include/jvalidate/validation_config.h
  42. 256 19
      include/jvalidate/validation_result.h
  43. 387 220
      include/jvalidate/validation_visitor.h
  44. 100 19
      include/jvalidate/validator.h
  45. 119 0
      include/jvalidate/vocabulary.h
  46. 126 0
      tests/annotation_test.cxx
  47. 34 0
      tests/custom_filter.h
  48. 149 0
      tests/extension_test.cxx
  49. 64 0
      tests/matchers.h
  50. 1 30
      tests/selfvalidate_test.cxx

+ 33 - 6
Makefile

@@ -1,3 +1,4 @@
+SHELL=/bin/bash -o pipefail
 INTERACTIVE:=$(shell [ -t 0 ] && echo 1)
 
 ifdef INTERACTIVE
@@ -10,9 +11,9 @@ CXX := clang++
 
 CXX_FLAGS := -Wall -Wextra -Werror -std=c++20 \
 	     -isystem include/ -I/opt/homebrew/opt/icu4c/include \
-	     -DJVALIDATE_USE_EXCEPTIONS
+	     -DJVALIDATE_USE_EXCEPTIONS -DJVALIDATE_LOAD_FAILURE_AS_FALSE_SCHEMA
 
-LD_FLAGS := -L/opt/local/lib -L/opt/homebrew/opt/icu4c/lib -licuuc
+LD_FLAGS := -L/opt/homebrew/lib -L/opt/homebrew/opt/icu4c/lib -licuuc
 
 TEST_DIR := tests/
 INCLUDE_DIR := include/
@@ -22,9 +23,10 @@ HEADERS := $(shell find $(INCLUDE_DIR) -name *.h)
 TEST_HEADERS := $(wildcard $(TEST_DIR)*.h)
 TEST_SOURCES := $(wildcard $(TEST_DIR)*.cxx)
 TEST_OBJECTS := $(patsubst %.cxx, .build/%.o, $(TEST_SOURCES))
-TEST_BINARIES := .build/bin/selfvalidate
+TEST_BINARIES := .build/bin/selfvalidate .build/bin/annotation_test .build/bin/extension_test
+EXECUTE_TESTS := $(patsubst %, %.done, $(TEST_BINARIES))
 
-EXCLUDED_TESTS := format* content ecmascript_regex zeroTerminatedFloats
+EXCLUDED_TESTS := format* content ecmascript_regex zeroTerminatedFloats non_bmp_regex
 EXCLUDED_TESTS := $(shell printf ":*optional_%s" $(EXCLUDED_TESTS) | cut -c2-)
 
 all: run-test
@@ -39,9 +41,9 @@ clean:
 test: $(TEST_BINARIES)
 
 run-test: test
-run-test:
-	.build/bin/selfvalidate --gtest_filter=-$(EXCLUDED_TESTS) $(CLEAN_ANSI)
+run-test: $(EXECUTE_TESTS)
 
+# Actual Definitions (non-phony)
 .build/tests/%.o: tests/%.cxx $(HEADERS) $(TEST_HEADERS)
 	@ mkdir -p .build/tests
 	$(CXX) $(CXX_FLAGS) -c $< -o $@
@@ -49,4 +51,29 @@ run-test:
 
 .build/bin/selfvalidate: .build/tests/selfvalidate_test.o
 	@ mkdir -p .build/bin
+	@ rm -f $@.done
 	$(CXX) $< -o $@ $(LD_FLAGS) -ljsoncpp -lgmock -lcurl -lgtest
+
+.build/bin/selfvalidate.done: .build/bin/selfvalidate
+	.build/bin/selfvalidate --gtest_filter=-$(EXCLUDED_TESTS) $(CLEAN_ANSI)
+	@ touch $@
+
+
+.build/bin/annotation_test: .build/tests/annotation_test.o
+	@ mkdir -p .build/bin
+	@ rm -f .build/test/annotation_test.done
+	$(CXX) $< -o $@ $(LD_FLAGS) -ljsoncpp -lgmock -lgtest
+
+.build/bin/annotation_test.done: .build/bin/annotation_test
+	.build/bin/annotation_test $(CLEAN_ANSI)
+	@ touch $@
+
+
+.build/bin/extension_test: .build/tests/extension_test.o
+	@ mkdir -p .build/bin
+	@ rm -f .build/test/extension_test.done
+	$(CXX) $< -o $@ $(LD_FLAGS) -ljsoncpp -lgmock -lgtest
+
+.build/bin/extension_test.done: .build/bin/extension_test
+	.build/bin/extension_test $(CLEAN_ANSI)
+	@ touch $@

+ 168 - 4
include/jvalidate/adapter.h

@@ -1,11 +1,7 @@
 #pragma once
 
-#include <cmath>
 #include <cstdint>
-#include <limits>
-#include <map>
 #include <optional>
-#include <string_view>
 
 #include <jvalidate/detail/array_iterator.h>
 #include <jvalidate/detail/number.h>
@@ -15,26 +11,124 @@
 #include <jvalidate/status.h>
 
 namespace jvalidate::adapter {
+/**
+ * @brief An interface for a type-erased reference-wrapper around a JSON node.
+ *
+ * Unlike languages like python, there are dozens of different C++ Libraries
+ * for JSON parsing/construction. Each of these libraries has its own set of
+ * getter functions, rules for handling missing values, and degree to which it
+ * can engage in fuzziness of types.
+ *
+ * Adapter has two main groups of methods:
+ * - as_*() and *_size() virtual functions
+ * - maybe_*() concrete functions
+ *
+ * Most interaction with Adapter will be done via the maybe_*() functions,
+ * with or without strictness enabled depending on what constraint is being
+ * checked.
+ */
 class Adapter {
 public:
   virtual ~Adapter() = default;
 
+  /**
+   * @brief Get the jvalidate::adapter::Type that this adapter represents.
+   * This represents the types recognized by json-schema:
+   *    null, bool, integer, number, string, array, object
+   * This function is meant to be used internally - and not by any of the
+   * Constraint objects.
+   */
   virtual Type type() const = 0;
+
+  /**
+   * @brief Obtain an immutable copy of the current node.
+   * Because an Adapter stores a reference to the underlying JSON, it cannot
+   * be stored by e.g. a Const/Enum Constraint without risking a Segfault.
+   */
   virtual std::unique_ptr<Const const> freeze() const = 0;
 
+  /**
+   * @brief Extract a boolean value from this JSON node.
+   * @pre type() == Type::Boolean
+   *
+   * @throws If the pre-condition is not valid, then this function may throw
+   * or produce other undefined behavior, depending on the implementation
+   * details of the underlying type.
+   */
   virtual bool as_boolean() const = 0;
+  /**
+   * @brief Extract an integer value from this JSON node.
+   * @pre type() == Type::Integer
+   *
+   * @throws If the pre-condition is not valid, then this function may throw
+   * or produce other undefined behavior, depending on the implementation
+   * details of the underlying type.
+   */
   virtual int64_t as_integer() const = 0;
+  /**
+   * @brief Extract a decimal value from this JSON node.
+   * @pre type() == Type::Number
+   *
+   * @throws If the pre-condition is not valid, then this function may throw
+   * or produce other undefined behavior, depending on the implementation
+   * details of the underlying type.
+   */
   virtual double as_number() const = 0;
+  /**
+   * @brief Extract a string value from this JSON node.
+   * @pre type() == Type::String
+   *
+   * @throws If the pre-condition is not valid, then this function may throw
+   * or produce other undefined behavior, depending on the implementation
+   * details of the underlying type.
+   */
   virtual std::string as_string() const = 0;
 
+  /**
+   * @brief Get the size of the JSON array in this node.
+   * @pre type() == Type::Array
+   *
+   * @throws If the pre-condition is not valid, then this function may throw
+   * or produce other undefined behavior, depending on the implementation
+   * details of the underlying type.
+   */
   virtual size_t array_size() const = 0;
+  /**
+   * @brief Get the size of the JSON object in this node.
+   * @pre type() == Type::Object
+   *
+   * @throws If the pre-condition is not valid, then this function may throw
+   * or produce other undefined behavior, depending on the implementation
+   * details of the underlying type.
+   */
   virtual size_t object_size() const = 0;
 
+  /**
+   * @brief Loop through every element of the JSON array in this node, applying
+   * the given callback function to them.
+   *
+   * @param cb A callback of the form Adapter => Status
+   *
+   * @return Status::Accept iff there are no errors
+   */
   virtual Status apply_array(AdapterCallback const & cb) const = 0;
+  /**
+   * @brief Loop through every element of the JSON object in this node, applying
+   * the given callback function to them.
+   *
+   * @param cb A callback of the form (string, Adapter) => Status
+   *
+   * @return Status::Accept iff there are no errors
+   */
   virtual Status apply_object(ObjectAdapterCallback const & cb) const = 0;
 
   virtual bool equals(Adapter const & lhs, bool strict) const = 0;
 
+  /**
+   * @brief Test if this object is null-like
+   *
+   * @param strict Does this function allow for fuzzy comparisons with strings?
+   */
   bool maybe_null(bool strict) const {
     switch (type()) {
     case Type::Null:
@@ -46,6 +140,14 @@ public:
     }
   }
 
+  /**
+   * @brief Attempts to extract a boolean value from this JSON node
+   *
+   * @param strict Does this function allow for fuzzy comparisons with strings?
+   *
+   * @return The boolean value contained if it is possible to deduce
+   * (or type() == Type::Boolean), else nullopt.
+   */
   std::optional<bool> maybe_boolean(bool strict) const {
     switch (type()) {
     case Type::Boolean:
@@ -66,6 +168,15 @@ public:
     }
   }
 
+  /**
+   * @brief Attempts to extract a integer value from this JSON node
+   *
+   * @param strict Does this function allow for fuzzy comparisons with strings
+   * and/or booleans?
+   *
+   * @return The integer value contained if it is possible to deduce from an
+   * integer, number, boolean, or string. Else nullopt
+   */
   std::optional<int64_t> maybe_integer(bool strict) const {
     switch (type()) {
     case Type::Number:
@@ -95,6 +206,14 @@ public:
     }
   }
 
+  /**
+   * @brief Attempts to extract a number value from this JSON node
+   *
+   * @param strict Does this function allow for fuzzy comparisons with strings?
+   *
+   * @return The number value contained if it is possible to deduce
+   * (or type() == Type::Integer || type() == Type::Number), else nullopt.
+   */
   std::optional<double> maybe_number(bool strict) const {
     switch (type()) {
     case Type::Number:
@@ -116,6 +235,15 @@ public:
     }
   }
 
+  /**
+   * @brief Attempts to extract a string value from this JSON node
+   *
+   * @param strict Does this function allow for fuzzy comparisons with other
+   * types?
+   *
+   * @return The string value contained if it is possible to deduce from a
+   * scalar type, else nullopt.
+   */
   std::optional<std::string> maybe_string(bool strict) const {
     switch (type()) {
     case Type::Null:
@@ -142,6 +270,15 @@ public:
     }
   }
 
+  /**
+   * @brief Attempts to extract the array length from this JSON node
+   *
+   * @param strict Does this function allow for fuzzy comparisons with other
+   * types?
+   *
+   * @return array_size() if this is an array, else 0 or nullopt, depending
+   * on some factors.
+   */
   std::optional<size_t> maybe_array_size(bool strict) const {
     switch (type()) {
     case Type::Array:
@@ -155,6 +292,15 @@ public:
     }
   }
 
+  /**
+   * @brief Attempts to extract the object length from this JSON node
+   *
+   * @param strict Does this function allow for fuzzy comparisons with other
+   * types?
+   *
+   * @return object_size() if this is an object, else 0 or nullopt, depending
+   * on some factors.
+   */
   std::optional<size_t> maybe_object_size(bool strict) const {
     switch (type()) {
     case Type::Object:
@@ -169,14 +315,32 @@ public:
   }
 };
 
+/**
+ * @brief An interface for an immutable, owning handle to a type-erased JSON
+ * node. {@see Adapter::freeze} for more explaination why this is necessary.
+ */
 class Const {
 public:
   virtual ~Const() = default;
+  /**
+   * @brief Perform an action on this object, such as copying or testing
+   * equality.
+   *
+   * @param cb A callback function of the form Adapter => Status
+   *
+   * @return the result of cb on the contained JSON
+   */
   virtual Status apply(AdapterCallback const & cb) const = 0;
 };
 }
 
 namespace jvalidate::adapter::detail {
+/**
+ * @brief The simplest implementation of Const.
+ * Depends on the AdapterTraits struct.
+ *
+ * @tparam JSON The type being adapted
+ */
 template <typename JSON> class GenericConst final : public Const {
 public:
   explicit GenericConst(JSON const & value) : value_(value) {}

+ 7 - 5
include/jvalidate/adapters/jsoncpp.h

@@ -1,6 +1,4 @@
 #pragma once
-#include <compare>
-#include <memory>
 #include <type_traits>
 
 #include <json/value.h>
@@ -34,7 +32,8 @@ public:
   }
 
   void assign(std::string const & key, Const const & frozen) const
-      requires(not std::is_const_v<JSON>) {
+    requires(not std::is_const_v<JSON>)
+  {
     (*this)[key].assign(frozen);
   }
 };
@@ -76,7 +75,9 @@ public:
   static std::string key(auto it) { return it.key().asString(); }
 
   using detail::SimpleAdapter<JSON>::assign;
-  void assign(Adapter const & adapter) const requires(not std::is_const_v<JSON>) {
+  void assign(Adapter const & adapter) const
+    requires(not std::is_const_v<JSON>)
+  {
     switch (adapter.type()) {
     case Type::Null:
       *value() = Json::nullValue;
@@ -109,7 +110,8 @@ public:
     }
   }
 
-public : using JsonCppAdapter::SimpleAdapter::value;
+public:
   using JsonCppAdapter::SimpleAdapter::const_value;
+  using JsonCppAdapter::SimpleAdapter::value;
 };
 }

+ 4 - 1
include/jvalidate/detail/compare.h

@@ -2,8 +2,10 @@
 
 #include <compare>
 
+// Apple Clang does not properly support <=> in the STL - so we need to force it
+#if __cpp_lib_three_way_comparison < 201907L
 namespace std {
-template <typename T> std::strong_ordering operator<=>(T const & lhs, T const & rhs) {
+template <typename T> auto operator<=>(T const & lhs, T const & rhs) {
   if (lhs < rhs) {
     return std::strong_ordering::less;
   }
@@ -13,3 +15,4 @@ template <typename T> std::strong_ordering operator<=>(T const & lhs, T const &
   return std::strong_ordering::equal;
 }
 }
+#endif

+ 74 - 0
include/jvalidate/compat/enumerate.h

@@ -0,0 +1,74 @@
+#pragma once
+
+#if __cplusplus >= 202302L
+#include <ranges>
+#if __cpp_lib_ranges_enumerate >= 202302L
+#define JVALIDATE_USE_STD_RANGES_ENUMERATE
+#endif
+#endif
+
+#ifdef JVALIDATE_USE_STD_RANGES_ENUMERATE
+namespace jvalidate::detail {
+using std::ranges::views::enumerate;
+}
+#else
+
+#include <iterator>
+#include <utility>
+
+#include <jvalidate/detail/deref_proxy.h>
+
+namespace jvalidate::detail {
+
+/**
+ * @brief A replacement for std::ranges::views::enumerate in C++20 (as enumerate
+ * is a C++23 feature).
+ * Much like python's enumerate() function, this is an iterator adapter that
+ * attaches the "index" of the iteration to each element - incrementing it as
+ * we go.
+ */
+template <typename It> class enumurate_iterator {
+public:
+  using traits_t = std::iterator_traits<It>;
+
+  using value_type = std::pair<size_t, typename traits_t::value_type>;
+  using reference = std::pair<size_t const &, typename traits_t::reference>;
+  using pointer = DerefProxy<reference>;
+  using difference_type = typename traits_t::difference_type;
+  using iterator_category = std::forward_iterator_tag;
+
+private:
+  size_t index_ = 0;
+  It iter_;
+
+public:
+  enumurate_iterator(It iter) : iter_(iter) {}
+
+  reference operator*() const { return {index_, *iter_}; }
+  pointer operator->() const { return operator*(); }
+
+  enumurate_iterator & operator++() {
+    ++index_;
+    ++iter_;
+    return *this;
+  }
+
+  friend bool operator==(enumurate_iterator<It> rhs, enumurate_iterator<It> lhs) {
+    return rhs.iter_ == lhs.iter_;
+  }
+  friend bool operator!=(enumurate_iterator<It> rhs, enumurate_iterator<It> lhs) {
+    return rhs.iter_ != lhs.iter_;
+  }
+};
+
+template <typename C> auto enumerate(C && container) {
+  struct {
+    auto begin() const { return enumurate_iterator(c.begin()); }
+    auto end() const { return enumurate_iterator(c.end()); }
+
+    C c;
+  } rval{std::forward<C>(container)};
+  return rval;
+}
+}
+#endif

文件差异内容过多而无法显示
+ 827 - 128
include/jvalidate/constraint.h


+ 9 - 42
include/jvalidate/constraint/array_constraint.h

@@ -1,71 +1,38 @@
 #pragma once
 
-#include <jvalidate/detail/expect.h>
 #include <optional>
 #include <vector>
 
 #include <jvalidate/adapter.h>
-#include <jvalidate/constraint/constraint.h>
 #include <jvalidate/forward.h>
 
 namespace jvalidate::constraint {
-class AdditionalItemsConstraint : public SimpleConstraint<AdditionalItemsConstraint> {
-public:
+struct AdditionalItemsConstraint {
   schema::Node const * subschema;
   size_t applies_after_nth;
-
-public:
-  AdditionalItemsConstraint(schema::Node const * subschema, size_t applies_after_nth)
-      : subschema(subschema), applies_after_nth(applies_after_nth) {}
 };
 
-class ContainsConstraint : public SimpleConstraint<ContainsConstraint> {
-public:
+struct ContainsConstraint {
   schema::Node const * subschema;
-  std::optional<size_t> minimum;
-  std::optional<size_t> maximum;
-
-public:
-  ContainsConstraint(schema::Node const * subschema) : subschema(subschema) {}
-
-  ContainsConstraint(schema::Node const * subschema, std::optional<size_t> minimum,
-                     std::optional<size_t> maximum)
-      : subschema(subschema), minimum(minimum), maximum(maximum) {}
+  std::optional<size_t> minimum = std::nullopt;
+  std::optional<size_t> maximum = std::nullopt;
 };
 
-class MaxItemsConstraint : public SimpleConstraint<MaxItemsConstraint> {
-public:
+struct MaxItemsConstraint {
   int64_t value;
-
-public:
-  MaxItemsConstraint(int64_t value) : value(value) {}
 };
 
-class MinItemsConstraint : public SimpleConstraint<MinItemsConstraint> {
-public:
+struct MinItemsConstraint {
   int64_t value;
-
-public:
-  MinItemsConstraint(int64_t value) : value(value) {}
 };
 
-class TupleConstraint : public SimpleConstraint<TupleConstraint> {
-public:
+struct TupleConstraint {
   std::vector<schema::Node const *> items;
-
-public:
-  TupleConstraint(std::vector<schema::Node const *> const & items) : items(items) {}
 };
 
-class UnevaluatedItemsConstraint : public SimpleConstraint<UnevaluatedItemsConstraint> {
-public:
+struct UnevaluatedItemsConstraint {
   schema::Node const * subschema;
-
-public:
-  UnevaluatedItemsConstraint(schema::Node const * subschema) : subschema(subschema) {}
 };
 
-class UniqueItemsConstraint : public SimpleConstraint<UniqueItemsConstraint> {
-public:
-};
+struct UniqueItemsConstraint {};
 }

+ 0 - 30
include/jvalidate/constraint/constraint.h

@@ -1,30 +0,0 @@
-#pragma once
-
-#include <jvalidate/constraint/visitor.h>
-#include <jvalidate/detail/pointer.h>
-#include <jvalidate/enum.h>
-#include <jvalidate/forward.h>
-#include <jvalidate/status.h>
-
-namespace jvalidate::constraint {
-class Constraint {
-public:
-  virtual ~Constraint() = default;
-  virtual Status accept(ConstraintVisitor const & visitor) const = 0;
-};
-
-template <typename CRTP> class SimpleConstraint : public Constraint {
-public:
-  Status accept(ConstraintVisitor const & visitor) const final {
-    return visitor.visit(*static_cast<CRTP const *>(this));
-  }
-};
-
-class ExtensionConstraint : public Constraint {
-public:
-  Status accept(ConstraintVisitor const & visitor) const final { return visitor.visit(*this); }
-
-  virtual Status validate(adapter::Adapter const & json, detail::Pointer const & where,
-                          ValidationResult * result) const = 0;
-};
-}

+ 25 - 0
include/jvalidate/constraint/extension_constraint.h

@@ -0,0 +1,25 @@
+#pragma once
+
+#include <memory>
+
+#include <jvalidate/forward.h>
+#include <jvalidate/status.h>
+
+namespace jvalidate::constraint {
+class ExtensionConstraint {
+public:
+  struct Impl {
+    virtual ~Impl() = default;
+    virtual Status visit(extension::VisitorBase const &) const = 0;
+  };
+
+public:
+  template <typename T, typename... Args> static std::unique_ptr<Constraint> make(Args &&... args) {
+    return std::make_unique<Constraint>(
+        ExtensionConstraint{std::make_unique<T>(std::forward<Args>(args)...)});
+  }
+
+public:
+  std::unique_ptr<Impl> pimpl;
+};
+}

+ 10 - 88
include/jvalidate/constraint/general_constraint.h

@@ -2,117 +2,39 @@
 
 #include <memory>
 #include <set>
-#include <utility>
 #include <vector>
 
-#include <jvalidate/constraint/constraint.h>
 #include <jvalidate/forward.h>
 #include <jvalidate/status.h>
 
 namespace jvalidate::constraint {
-class PolyConstraint : public Constraint {
-private:
-  std::vector<std::unique_ptr<Constraint>> children_;
-  bool match_all_;
-  bool invert_{false};
-
-public:
-  template <typename... Cs> static auto AllOf(Cs &&... cs) {
-    return std::make_unique<PolyConstraint>(PolyConstraint(true, false, std::forward<Cs>(cs)...));
-  }
-
-  template <typename... Cs> static auto AnyOf(Cs &&... cs) {
-    return std::make_unique<PolyConstraint>(PolyConstraint(false, false, std::forward<Cs>(cs)...));
-  }
-
-  static auto Not(std::unique_ptr<Constraint> child) {
-    return std::make_unique<PolyConstraint>(PolyConstraint(false, true, std::move(child)));
-  }
-
-  Status accept(ConstraintVisitor const & visitor) const final {
-    Status rval = Status::Noop;
-    for (auto const & child : children_) {
-      if (match_all_) {
-        rval &= child->accept(visitor);
-      } else {
-        rval |= child->accept(visitor);
-      }
-    }
-    return invert_ ? !rval : rval;
-  }
-
-private:
-  template <typename... Cs>
-  PolyConstraint(bool match_all, bool invert, Cs &&... cs)
-      : match_all_(match_all), invert_(invert) {
-    (children_.push_back(std::forward<Cs>(cs)), ...);
-  }
+struct AllOfConstraint {
+  std::vector<SubConstraint> children;
 };
 
-class AllOfConstraint : public SimpleConstraint<AllOfConstraint> {
-public:
-  std::vector<schema::Node const *> children;
-
-public:
-  AllOfConstraint(std::vector<schema::Node const *> const & children) : children(children) {}
+struct AnyOfConstraint {
+  std::vector<SubConstraint> children;
 };
 
-class AnyOfConstraint : public SimpleConstraint<AnyOfConstraint> {
-public:
-  std::vector<schema::Node const *> children;
-
-public:
-  AnyOfConstraint(std::vector<schema::Node const *> const & children) : children(children) {}
-};
-
-class EnumConstraint : public SimpleConstraint<EnumConstraint> {
-public:
+struct EnumConstraint {
   std::vector<std::unique_ptr<adapter::Const const>> enumeration;
-
-public:
-  EnumConstraint(std::unique_ptr<adapter::Const const> && constant) {
-    enumeration.push_back(std::move(constant));
-  }
-
-  EnumConstraint(std::vector<std::unique_ptr<adapter::Const const>> && enums)
-      : enumeration(std::move(enums)) {}
 };
 
-class OneOfConstraint : public SimpleConstraint<OneOfConstraint> {
-public:
+struct OneOfConstraint {
   std::vector<schema::Node const *> children;
-
-public:
-  OneOfConstraint(std::vector<schema::Node const *> const & children) : children(children) {}
 };
 
-class ConditionalConstraint : public SimpleConstraint<ConditionalConstraint> {
-public:
+struct ConditionalConstraint {
   schema::Node const * if_constraint;
   schema::Node const * then_constraint;
   schema::Node const * else_constraint;
-
-public:
-  ConditionalConstraint(schema::Node const * if_constraint, schema::Node const * then_constraint,
-                        schema::Node const * else_constraint)
-      : if_constraint(if_constraint), then_constraint(then_constraint),
-        else_constraint(else_constraint) {}
 };
 
-class NotConstraint : public SimpleConstraint<NotConstraint> {
-public:
-  schema::Node const * child;
-
-public:
-  NotConstraint(schema::Node const * child) : child(child) {}
+struct NotConstraint {
+  SubConstraint child;
 };
 
-class TypeConstraint : public SimpleConstraint<TypeConstraint> {
-public:
+struct TypeConstraint {
   std::set<adapter::Type> types;
-
-public:
-  TypeConstraint(adapter::Type type) : types{type} {}
-  TypeConstraint(std::set<adapter::Type> const & types) : types(types) {}
 };
 }

+ 4 - 19
include/jvalidate/constraint/number_constraint.h

@@ -1,44 +1,29 @@
 #pragma once
 
 #include <cmath>
-#include <iostream>
+#include <limits>
 
-#include <jvalidate/adapter.h>
-#include <jvalidate/constraint/constraint.h>
 #include <jvalidate/detail/number.h>
 #include <jvalidate/forward.h>
-#include <limits>
 
 namespace jvalidate::constraint {
-class MaximumConstraint : public SimpleConstraint<MaximumConstraint> {
-public:
+struct MaximumConstraint {
   double value;
   bool exclusive;
 
-public:
-  MaximumConstraint(double value, bool exclusive) : value(value), exclusive(exclusive) {}
-
   bool operator()(double arg) const { return exclusive ? arg < value : arg <= value; }
 };
 
-class MinimumConstraint : public SimpleConstraint<MinimumConstraint> {
-public:
+struct MinimumConstraint {
   double value;
   bool exclusive;
 
-public:
-  MinimumConstraint(double value, bool exclusive) : value(value), exclusive(exclusive) {}
-
   bool operator()(double arg) const { return exclusive ? arg > value : arg >= value; }
 };
 
-class MultipleOfConstraint : public SimpleConstraint<MultipleOfConstraint> {
-public:
+struct MultipleOfConstraint {
   double value;
 
-public:
-  MultipleOfConstraint(double value) : value(value) {}
-
   bool operator()(double arg) const {
     if (std::fabs(std::remainder(arg, value)) <= std::numeric_limits<double>::epsilon()) {
       return true;

+ 9 - 61
include/jvalidate/constraint/object_constraint.h

@@ -1,102 +1,50 @@
 #pragma once
 
 #include <map>
-#include <optional>
 #include <string>
 #include <unordered_set>
 #include <utility>
 #include <vector>
 
-#include <jvalidate/constraint/constraint.h>
 #include <jvalidate/forward.h>
 
 namespace jvalidate::constraint {
-class AdditionalPropertiesConstraint : public SimpleConstraint<AdditionalPropertiesConstraint> {
-public:
+struct AdditionalPropertiesConstraint {
   schema::Node const * subschema;
   std::unordered_set<std::string> properties;
   std::vector<std::string> patterns;
-
-public:
-  AdditionalPropertiesConstraint(schema::Node const * subschema,
-                                 std::unordered_set<std::string> const & properties,
-                                 std::vector<std::string> const & patterns)
-      : subschema(subschema), properties(properties), patterns(patterns) {}
 };
 
-class DependenciesConstraint : public SimpleConstraint<DependenciesConstraint> {
-public:
+struct DependenciesConstraint {
   std::map<std::string, schema::Node const *> subschemas;
   std::map<std::string, std::unordered_set<std::string>> required;
-
-public:
-  DependenciesConstraint(std::map<std::string, schema::Node const *> const & subschemas)
-      : subschemas(subschemas) {}
-
-  DependenciesConstraint(std::map<std::string, std::unordered_set<std::string>> const & required)
-      : required(required) {}
-
-  DependenciesConstraint(std::map<std::string, schema::Node const *> const & subschemas,
-                         std::map<std::string, std::unordered_set<std::string>> const & required)
-      : subschemas(subschemas), required(required) {}
 };
 
-class MaxPropertiesConstraint : public SimpleConstraint<MaxPropertiesConstraint> {
-public:
+struct MaxPropertiesConstraint {
   int64_t value;
-
-public:
-  MaxPropertiesConstraint(int64_t value) : value(value) {}
 };
 
-class MinPropertiesConstraint : public SimpleConstraint<MinPropertiesConstraint> {
-public:
+struct MinPropertiesConstraint {
   int64_t value;
-
-public:
-  MinPropertiesConstraint(int64_t value) : value(value) {}
 };
 
-class PatternPropertiesConstraint : public SimpleConstraint<PatternPropertiesConstraint> {
-public:
+struct PatternPropertiesConstraint {
   std::vector<std::pair<std::string, schema::Node const *>> properties;
-
-public:
-  PatternPropertiesConstraint(
-      std::vector<std::pair<std::string, schema::Node const *>> const & properties)
-      : properties(properties) {}
 };
 
-class PropertiesConstraint : public SimpleConstraint<PropertiesConstraint> {
-public:
+struct PropertiesConstraint {
   std::map<std::string, schema::Node const *> properties;
-
-public:
-  PropertiesConstraint(std::map<std::string, schema::Node const *> const & properties)
-      : properties(properties) {}
 };
 
-class PropertyNamesConstraint : public SimpleConstraint<PropertyNamesConstraint> {
-public:
+struct PropertyNamesConstraint {
   schema::Node const * key_schema;
-
-public:
-  PropertyNamesConstraint(schema::Node const * key_schema) : key_schema(key_schema) {}
 };
 
-class RequiredConstraint : public SimpleConstraint<RequiredConstraint> {
-public:
+struct RequiredConstraint {
   std::unordered_set<std::string> properties;
-
-public:
-  RequiredConstraint(std::unordered_set<std::string> const & properties) : properties(properties) {}
 };
 
-class UnevaluatedPropertiesConstraint : public SimpleConstraint<UnevaluatedPropertiesConstraint> {
-public:
+struct UnevaluatedPropertiesConstraint {
   schema::Node const * subschema;
-
-public:
-  UnevaluatedPropertiesConstraint(schema::Node const * subschema) : subschema(subschema) {}
 };
 }

+ 5 - 21
include/jvalidate/constraint/string_constraint.h

@@ -2,40 +2,24 @@
 
 #include <string>
 
-#include <jvalidate/constraint/constraint.h>
 #include <jvalidate/detail/string.h>
 #include <jvalidate/forward.h>
 
 namespace jvalidate::constraint {
-class MinLengthConstraint : public SimpleConstraint<MinLengthConstraint> {
-public:
+struct MinLengthConstraint {
   int64_t value;
-
-public:
-  MinLengthConstraint(int64_t value) : value(value) {}
 };
 
-class MaxLengthConstraint : public SimpleConstraint<MaxLengthConstraint> {
-public:
+struct MaxLengthConstraint {
   int64_t value;
-
-public:
-  MaxLengthConstraint(int64_t value) : value(value) {}
 };
 
-class PatternConstraint : public SimpleConstraint<PatternConstraint> {
-public:
+struct PatternConstraint {
   std::string regex;
-
-public:
-  PatternConstraint(std::string const & regex) : regex(regex) {}
 };
 
-class FormatConstraint : public SimpleConstraint<FormatConstraint> {
-public:
+struct FormatConstraint {
   std::string format;
-
-public:
-  FormatConstraint(std::string const & format) : format(format) {}
+  bool is_assertion;
 };
 }

+ 0 - 51
include/jvalidate/constraint/visitor.h

@@ -1,51 +0,0 @@
-#pragma once
-
-#include <jvalidate/forward.h>
-
-namespace jvalidate::constraint {
-struct ConstraintVisitor {
-  virtual ~ConstraintVisitor() = default;
-
-  virtual Status visit(ExtensionConstraint const &) const = 0;
-
-  virtual Status visit(TypeConstraint const &) const = 0;
-  virtual Status visit(EnumConstraint const &) const = 0;
-  virtual Status visit(AllOfConstraint const &) const = 0;
-  virtual Status visit(AnyOfConstraint const &) const = 0;
-  virtual Status visit(OneOfConstraint const &) const = 0;
-  virtual Status visit(NotConstraint const &) const = 0;
-  virtual Status visit(ConditionalConstraint const &) const = 0;
-
-  virtual Status visit(MaximumConstraint const &) const = 0;
-  virtual Status visit(MinimumConstraint const &) const = 0;
-  virtual Status visit(MultipleOfConstraint const &) const = 0;
-
-  virtual Status visit(MaxLengthConstraint const &) const = 0;
-  virtual Status visit(MinLengthConstraint const &) const = 0;
-  virtual Status visit(PatternConstraint const &) const = 0;
-  virtual Status visit(FormatConstraint const &) const = 0;
-
-  virtual Status visit(AdditionalItemsConstraint const &) const = 0;
-  virtual Status visit(ContainsConstraint const &) const = 0;
-  virtual Status visit(MaxItemsConstraint const &) const = 0;
-  virtual Status visit(MinItemsConstraint const &) const = 0;
-  virtual Status visit(TupleConstraint const &) const = 0;
-  virtual Status visit(UniqueItemsConstraint const &) const = 0;
-
-  virtual Status visit(AdditionalPropertiesConstraint const &) const = 0;
-  virtual Status visit(DependenciesConstraint const &) const = 0;
-  virtual Status visit(MaxPropertiesConstraint const &) const = 0;
-  virtual Status visit(MinPropertiesConstraint const &) const = 0;
-  virtual Status visit(PatternPropertiesConstraint const &) const = 0;
-  virtual Status visit(PropertiesConstraint const &) const = 0;
-  virtual Status visit(PropertyNamesConstraint const &) const = 0;
-  virtual Status visit(RequiredConstraint const &) const = 0;
-
-  virtual Status visit(UnevaluatedItemsConstraint const &) const = 0;
-  virtual Status visit(UnevaluatedPropertiesConstraint const &) const = 0;
-};
-
-template <typename Cons> struct ExtensionConstraintVisitor {
-  virtual Status visit(Cons const &) const = 0;
-};
-}

+ 26 - 1
include/jvalidate/detail/anchor.h

@@ -6,10 +6,35 @@
 #include <string>
 #include <string_view>
 
-#include <jvalidate/detail/compare.h>
+#include <jvalidate/compat/compare.h>
 #include <jvalidate/detail/expect.h>
 
 namespace jvalidate::detail {
+/**
+ * @brief An Anchor is a simple name that refers to a named location (shorhand)
+ * in a JSON-schema. As compared to a URI - which can refer to either internal
+ * starting-points in the active schema or external documents on disk or at an
+ * external URL-location.
+ *
+ * Anchors are useful in cases where there is a complicated or long path to a
+ * commonly used definition. Consider for example:
+ * { "$ref": "#/properties/Column/definitions/Row" }
+ * vs.
+ * { "$ref": "#ColRow" }
+ *
+ * This can be much easier to read, or find an object when doing a quick lookup,
+ * since editors generally don't have a "lookup this JSON-Pointer" option.
+ *
+ * An anchor may only be a plain-name fragment (first character is alpha or '_',
+ * all other characters are alphanumeric, '_', '.', or '-').
+ * When defining an anchor using the "$anchor" or "$dynamicAnchor" tags, only
+ * this fragment is used. When defining an anchor as part of an "$id" tag, the
+ * form is `<URI>#<ANCHOR>`, the same as when accessing the anchor through a
+ * "$ref". In the same document - you can reference the anchor by `#<ANCHOR>`,
+ * just like how you can eschew the URI in a JSON-Pointer within the same doc.
+ *
+ * @see https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-01#section-8.2.2
+ */
 class Anchor {
 private:
   std::string content_;

+ 19 - 11
include/jvalidate/detail/array_iterator.h

@@ -1,37 +1,45 @@
 #pragma once
 
 #include <iterator>
-#include <string>
 
 #include <jvalidate/detail/deref_proxy.h>
 
 namespace jvalidate::adapter::detail {
-
-template <typename It, typename Adapter> class JsonObjectIterator : public It {
+/**
+ * @brief An iterator for binding JSON values of type Array - which are
+ * congruent to a vector<JSON>.
+ *
+ * @tparam It The underlying iterator type being operated on
+ * @tparam Adapter The owning adapter type, must fulfill the following
+ * contracts:
+ *    - is constructible from the value_type of It
+ * Additionally, Adapter is expected to conform to the jvalidate::Adapter
+ * concept.
+ */
+template <typename It, typename Adapter> class JsonArrayIterator : public It {
 public:
-  using value_type = std::pair<std::string, Adapter>;
-  using reference = std::pair<std::string, Adapter>;
+  using value_type = Adapter;
+  using reference = Adapter;
   using pointer = ::jvalidate::detail::DerefProxy<reference>;
   using difference_type = std::ptrdiff_t;
   using iterator_category = std::forward_iterator_tag;
 
-  JsonObjectIterator() = default;
-  JsonObjectIterator(It it) : It(it) {}
+  JsonArrayIterator() = default; // Sentinel for handling null objects
+  JsonArrayIterator(It it) : It(it) {}
 
-  reference operator*() const { return {Adapter::key(*this), Adapter(It::operator->())}; }
+  reference operator*() const { return Adapter(It::operator*()); }
 
   pointer operator->() const { return {operator*()}; }
 
-  JsonObjectIterator operator++(int) {
+  JsonArrayIterator operator++(int) {
     auto tmp = *this;
     ++*this;
     return tmp;
   }
 
-  JsonObjectIterator & operator++() {
+  JsonArrayIterator & operator++() {
     It::operator++();
     return *this;
   }
 };
-
 }

+ 5 - 0
include/jvalidate/detail/deref_proxy.h

@@ -1,6 +1,11 @@
 #pragma once
 
 namespace jvalidate::detail {
+/**
+ * @brief An object that acts like a pointer to an rvalue - without requiring us
+ * to heap allocate a unique_ptr.
+ * @tparam T the type being pointer to.
+ */
 template <typename T> struct DerefProxy {
   T & operator*() { return value; }
   T const & operator*() const { return value; }

+ 59 - 0
include/jvalidate/detail/dynamic_reference_context.h

@@ -10,22 +10,50 @@
 #include <jvalidate/uri.h>
 
 namespace jvalidate::detail {
+/**
+ * @brief Starting with Draft 2019-09, it is possible to set $recursiveAnchor
+ * and $recursiveRef options in schemas. In Draft 2020-12 this changes to the
+ * more powerful/generic $dynamicAnchor and $dynamicRef.
+ *
+ * The rules of handling these anchors is that we maintain a stack of all of the
+ * loaded anchors for each given name in the order that they are loaded. But the
+ * resolved reference that they point to is the first anchor by that name to be
+ * loaded. This means that we can create recursive/self-referential chains.
+ *
+ * When we encounter the appropriate $dynamicRef/$recursiveRef tag, we fetch
+ * the most appropriate anchored location - which is usually the prior mentioned
+ * first path that registered the anchor.
+ */
 class DynamicReferenceContext {
 private:
   std::deque<URI> sources_;
   std::map<Anchor, std::deque<std::optional<Reference>>> data_;
 
 public:
+  /**
+   * @brief Add all dynamic anchors contained in a given document (as defined
+   * by a common URI) to the current stack, pointing them to the earliest loaded
+   * parent reference and unregistering all of the anchors that are in context,
+   * but not in this specific document.
+   */
   OnBlockExit scope(URI const & source, std::map<Anchor, Reference> const & frame) {
+    // No-Op loading, for convenience
     if (frame.empty() && data_.empty()) {
       return nullptr;
     }
 
     sources_.push_back(source);
     for (auto const & [k, v] : frame) {
+      // If we have not currently registered this anchor, use the input
+      // reference path, else use the first reference path registered in the
+      // stack.
       data_[k].push_back(data_[k].empty() ? v : data_[k].front());
     }
 
+    // For all of the anchors that are not being pushed onto the stack, push a
+    // nullopt onto the stack (as well as ensuring that all stacks are
+    // equal-sized). This allows us to disable certain anchors in a given
+    // document (i.e. that doc does not define a specific $dynamicAnchor).
     for (auto & [k, stack] : data_) {
       if (not frame.contains(k)) {
         stack.push_back(std::nullopt);
@@ -35,6 +63,9 @@ public:
       }
     }
 
+    // Scope object the pops all of the elements on this object, due to the
+    // equal-size promise of the above while loop, we can just blindly loop
+    // through all elements to pop instead of dealing with
     return [this]() {
       sources_.pop_back();
       for (auto it = data_.begin(); it != data_.end();) {
@@ -48,8 +79,29 @@ public:
     };
   }
 
+  /**
+   * @brief Is the given anchor in the current $dynamicRef lookup context
+   * (including suppressed anchors). This check is necessary in part because we
+   * permit using $dynamicRef to refer regular $anchor objects if there is no
+   * $dynamicAnchor in the current context.
+   *
+   * TODO(samjaffe): May be able to add a nullopt check...
+   */
   bool contains(Anchor const & key) const { return data_.contains(key); }
 
+  /**
+   * @brief Safely fetch the closest matching $dynamicAnchor to the given
+   * arguments. Because $dynamicRef permits including a URI, it is techinically
+   * possible to "jump" to an anchor that is not the top-level one, this can
+   * be useful if the current scope does not generate a bookmark $dynamicAnchor.
+   *
+   * @param source The owning source, which is either the URI of the currently
+   * operating schema document, or a URI specified in the $dynamicRef value.
+   * Using this information lets us jump past suppressed anchors by explicitly
+   * stating the owning context.
+   *
+   * @param key The actual anchor being searched for.
+   */
   std::optional<Reference> lookup(URI const & source, Anchor const & key) const {
     if (auto it = data_.find(key); it != data_.end()) {
       return it->second.at(index_of(source));
@@ -57,7 +109,14 @@ public:
     return std::nullopt;
   }
 
+  /**
+   * @brief Finds the (index of the) dynamic anchor directly associated with the
+   * given URI; or the final registered anchor.
+   */
   size_t index_of(URI const & source) const {
+    // Start at the end because most commonly source will refer to the currently
+    // operating schema, which is also going to be the top item in the sources
+    // stack.
     for (size_t i = sources_.size(); i-- > 0;) {
       if (sources_[i] == source) {
         return i;

+ 58 - 0
include/jvalidate/detail/expect.h

@@ -12,6 +12,15 @@
 #endif
 
 #if defined(JVALIDATE_USE_EXCEPTIONS)
+/**
+ * @brief Throw an exception after construcing the error message.
+ *
+ * @param extype A subtype of std::exception that can be constructed using a
+ * std::string.
+ *
+ * @param message The error "message" to be emit - in the form of an iostream
+ * output chain (e.g. `"unsupported index " << i << ", valid items " << items`).
+ */
 #define JVALIDATE_THROW(extype, message)                                                           \
   do {                                                                                             \
     std::stringstream ss;                                                                          \
@@ -19,6 +28,14 @@
     throw extype(ss.str());                                                                        \
   } while (false)
 #else
+/**
+ * @brief Print an error message and then terminate execution
+ *
+ * @param extype[ignored]
+ *
+ * @param message The error "message" to be emit - in the form of an iostream
+ * output chain (e.g. `"unsupported index " << i << ", valid items " << items`).
+ */
 #define JVALIDATE_THROW(extype, message)                                                           \
   do {                                                                                             \
     std::cerr << message << std::endl;                                                             \
@@ -26,15 +43,56 @@
   } while (false)
 #endif
 
+/**
+ * @brief Assert a certain pre/post-condition is true, else emit an error of a
+ * specified type and message.
+ *
+ * @param condition A boolean or boolean-like expression that should be TRUE.
+ * If the condition is FALSE, then the other params are used to produce errors.
+ *
+ * @param extype A subtype of std::exception that can be constructed using a
+ * std::string. If exceptions are enabled, and condition is FALSE - then this
+ * is the type that will be thrown.
+ *
+ * @param message The error "message" to be emit - in the form of an iostream
+ * output chain (e.g. `"unsupported index " << i << ", valid items " << items`).
+ */
 #define EXPECT_T(condition, extype, message)                                                       \
   if (JVALIDATE_UNLIKELY(!(condition))) {                                                          \
     JVALIDATE_THROW(extype, message);                                                              \
   }
 
+/**
+ * @brief Assert a certain pre/post-condition is true, else emit an error of a
+ * specified message.
+ *
+ * @param condition A boolean or boolean-like expression that should be TRUE.
+ * If the condition is FALSE, then the other params are used to produce errors.
+ *
+ * @param message The error "message" to be emit - in the form of an iostream
+ * output chain (e.g. `"unsupported index " << i << ", valid items " << items`).
+ */
 #define EXPECT_M(condition, message) EXPECT_T(condition, std::runtime_error, message)
 
+/**
+ * @brief Assert a certain pre/post-condition is true, else emit a generic error.
+ *
+ * @param condition A boolean or boolean-like expression that should be TRUE.
+ * If the condition is FALSE, then the other params are used to produce errors.
+ */
 #define EXPECT(condition) EXPECT_M(condition, #condition " at " __FILE__ ":" << __LINE__)
 
+/**
+ * @brief Assert a certain pre/post-condition is true, else return the default
+ * expression (or void).
+ *
+ * @param condition A boolean or boolean-like expression that should be TRUE.
+ * If the condition is FALSE, then the other params are used to produce errors.
+ *
+ * @param ... Zero or One arguments representing the return value if the
+ * condition is FALSE. Zero arguments is equivalent to `return void();`, which
+ * doesn't need to be explicitly stated.
+ */
 #define RETURN_UNLESS(condition, ...)                                                              \
   if (JVALIDATE_UNLIKELY(!(condition))) {                                                          \
     return __VA_ARGS__;                                                                            \

+ 19 - 0
include/jvalidate/detail/number.h

@@ -1,16 +1,35 @@
+/**
+ * Utility functions for managing numeric types - such as converting between
+ * floating-point and integer types.
+ *
+ * None of these are particularly complex functions, but storing them in a
+ * single header with descriptive names helps the reader quickly recognize what
+ * is being done.
+ */
 #pragma once
 
 #include <cmath>
 #include <limits>
 
 namespace jvalidate::detail {
+/**
+ * @brief Determine if a floating point number is actually an integer (in the
+ * mathematical sense).
+ */
 inline bool is_json_integer(double number) { return std::floor(number) == number; }
 
+/**
+ * @brief Determine if a floating point number is actually an integer, and
+ * actually fits in the 64-bit integer type that we use for JSON Integer.
+ */
 inline bool fits_in_integer(double number) {
   static constexpr double g_int_max = std::numeric_limits<int64_t>::max();
   static constexpr double g_int_min = std::numeric_limits<int64_t>::min();
   return is_json_integer(number) && number <= g_int_max && number >= g_int_min;
 }
 
+/**
+ * @brief Determine if an unsigned integer fits into a signed integer
+ */
 inline bool fits_in_integer(uint64_t number) { return (number & 0x8000'0000'0000'0000) == 0; }
 }

+ 32 - 9
include/jvalidate/detail/object_iterator.h

@@ -1,33 +1,56 @@
 #pragma once
 
 #include <iterator>
+#include <string>
 
 #include <jvalidate/detail/deref_proxy.h>
 
 namespace jvalidate::adapter::detail {
-
-template <typename It, typename Adapter> class JsonArrayIterator : public It {
+/**
+ * @brief An iterator for binding JSON values of type Object - which are
+ * congruent to a map<string, JSON>.
+ *
+ * Conventionally - many JSON libraries use the same iterator object to
+ * represent both Array iteration and Object iteration, either by returning
+ * the Array index as a string-key, or by providing a special method e.g.
+ * `key()` which accesses the Object key, while dereferencing the iterator
+ * always returns the pointed-to JSON, regardless of Array/Object-ness.
+ *
+ * @tparam It The underlying iterator type being operated on
+ * @tparam Adapter The owning adapter type, must fulfill the following
+ * contracts:
+ *    - is constructible from the value_type of It
+ *    - has a static method key() which extracts the Object key from an It
+ * Additionally, Adapter is expected to conform to the jvalidate::Adapter
+ * concept.
+ */
+template <typename It, typename Adapter> class JsonObjectIterator : public It {
 public:
-  using value_type = Adapter;
-  using reference = Adapter;
+  using value_type = std::pair<std::string, Adapter>;
+  // Cannot return key by reference - because we don't know for certain
+  // that the key-extraction function on It will return a string by reference
+  // (such as if they do not store a default empty key).
+  using reference = std::pair<std::string, Adapter>;
   using pointer = ::jvalidate::detail::DerefProxy<reference>;
   using difference_type = std::ptrdiff_t;
   using iterator_category = std::forward_iterator_tag;
 
-  JsonArrayIterator() = default;
-  JsonArrayIterator(It it) : It(it) {}
+  JsonObjectIterator() = default; // Sentinel for handling null objects
+  JsonObjectIterator(It it) : It(it) {}
 
-  reference operator*() const { return {It::operator*()}; }
+  reference operator*() const {
+    return { Adapter::key(*this), Adapter(It::operator->()) };
+  }
 
   pointer operator->() const { return {operator*()}; }
 
-  JsonArrayIterator operator++(int) {
+  JsonObjectIterator operator++(int) {
     auto tmp = *this;
     ++*this;
     return tmp;
   }
 
-  JsonArrayIterator & operator++() {
+  JsonObjectIterator & operator++() {
     It::operator++();
     return *this;
   }

+ 11 - 0
include/jvalidate/detail/on_block_exit.h

@@ -1,7 +1,16 @@
 #pragma once
 
 #include <functional>
+
 namespace jvalidate::detail {
+/**
+ * @brief An object representing a cleanup function, to be called as if it was
+ * a destructor for the current function scope. Similar to e.g. D-lang's
+ * scope(exit) or @see https://en.cppreference.com/w/cpp/experimental/scope_exit
+ *
+ * Is movable - allowing us to return this scope object from its constructing
+ * context into the actual owning context that wants to control that scope.
+ */
 class OnBlockExit {
 private:
   std::function<void()> callback_;
@@ -10,8 +19,10 @@ public:
   OnBlockExit() = default;
   template <typename F> OnBlockExit(F && callback) : callback_(callback) {}
 
+  // Must be explicity implemented because function's move ctor is non-destructive
   OnBlockExit(OnBlockExit && other) { std::swap(other.callback_, callback_); }
 
+  // Must be explicity implemented because function's move-assign is non-destructive
   OnBlockExit & operator=(OnBlockExit && other) {
     std::swap(other.callback_, callback_);
     return *this;

+ 78 - 8
include/jvalidate/detail/out.h

@@ -8,20 +8,60 @@ namespace jvalidate::detail {
 constexpr struct discard_out_t {
 } discard_out;
 
+/**
+ * @brief An optional out-parameter to a function, similar to a function
+ * that takes `T* out_param = nullptr`. Unfortunately, std::optional does not
+ * support storing references - so if we want the syntactic sugar of that, we
+ * need a custom class.
+ *
+ * In addition to acting like an optional value - we have the special behavior
+ * that we wanted - namely that "if there is a contained value provided by the
+ * caller, we will update that reference", and "if there is no value, then
+ * assigning a value will have no effect" as if we performed the idiomatic:
+ * @code
+ * if (out_param) {
+ *   *out_param = ...;
+ * }
+ * @endcode
+ *
+ * @tparam T The type being returned
+ */
 template <typename T>
-requires(std::is_same_v<T, std::decay_t<T>>) class out {
+  requires(std::is_same_v<T, std::decay_t<T>>)
+class out {
 private:
   T * ref_ = nullptr;
 
 public:
+  // Construct an empty out parameter, that has no side-effects
   out() = default;
+  // Explicitly construct an empty out parameter, that has no side-effects
   out(discard_out_t) {}
+  // Construct an out parameter pointing to the given concrete value.
   out(T & ref) : ref_(&ref) {}
 
+  /**
+   * @breif On rare occasions, we still need to perform checks
+   * that an out-param holds a value.
+   */
   explicit operator bool() const { return ref_; }
 
+  /**
+   * @brief Update the value of this out parameter, if it holds a value. By
+   * convention, we assume that this function will only be called once, but
+   * there is no requirement for that.
+   *
+   * @tparam U Any type that can be used to construct the held type T
+   *
+   * @param val The new value being passed up to the caller
+   *
+   * @returns Nothing - this object does not behave like a normal object where
+   * you can do things like `if ((A = B).foo())` - since this object represents
+   * exclusively a way to pass an optional value back to the caller without
+   * returning a tuple.
+   */
   template <typename U>
-  requires std::is_constructible_v<T, U>
+    requires std::is_constructible_v<T, U>
   void operator=(U && val) {
     if (ref_) {
       *ref_ = std::forward<U>(val);
@@ -30,15 +70,34 @@ public:
   }
 };
 
+/**
+ * @brief A non-optional out-parameter to a function, similar to a function that
+ * takes a `T& out_param` argument. Unlike the standard form, this type allows
+ * passing an rvalue (temporary) or an lvalue (persistant) element, and will
+ * properly handle the assigment and updating of the object as appropriate.
+ *
+ * @tparam T The type being returned
+ */
 template <typename T>
-requires(std::is_same_v<T, std::decay_t<T>>) class inout {
+  requires(std::is_same_v<T, std::decay_t<T>>)
+class inout {
 private:
   std::variant<T, T *> ref_;
 
 public:
+  // Constructs an inout parameter from an rvalue type - whose modification will
+  // not effect the calling scope.
   inout(T && value) : ref_(std::move(value)) {}
+  // Constructs an inout parameter from an lvalue type - whose modification will
+  // propogate upwards to the caller.
   inout(T & ref) : ref_(&ref) {}
 
+  /**
+   * @brief Convert this object back into its underlying type for use.
+   *
+   * @returns A reference to the contained value/reference, to avoid the cost
+   * of performing a copy-operation if the contained object is non-trivial.
+   */
   operator T const &() const {
     struct {
       T const & operator()(T const & in) const { return in; }
@@ -47,15 +106,26 @@ public:
     return std::visit(visitor, ref_);
   }
 
+  /**
+   * @brief Update the value of this out parameter. Depending on the variation
+   * contained in this type, this will either propogate up to the caller's level
+   * or will update future uses of this object.
+   *
+   * @tparam U Any type that can be used to construct the held type T
+   *
+   * @param val The new value being set
+   *
+   * @returns The updated value
+   */
   template <typename U>
-  requires std::is_constructible_v<T, U> T const & operator=(U && val) {
+    requires std::is_constructible_v<T, U>
+  T const & operator=(U && val) {
     struct {
       U && val;
-      void operator()(T & in) const { in = std::forward<U>(val); }
-      void operator()(T * in) const { *in = std::forward<U>(val); }
+      T const & operator()(T & in) const { return in = std::forward<U>(val); }
+      T const & operator()(T * in) const { return *in = std::forward<U>(val); }
     } visitor{std::forward<U>(val)};
-    std::visit(visitor, ref_);
-    return static_cast<T const &>(*this);
+    return std::visit(visitor, ref_);
   }
 };
 }

+ 119 - 6
include/jvalidate/detail/pointer.h

@@ -2,39 +2,76 @@
 
 #include <algorithm>
 #include <cassert>
-#include <cstdint>
 #include <iostream>
 #include <string>
 #include <string_view>
 #include <variant>
 #include <vector>
 
-#include <jvalidate/detail/compare.h>
+#include <jvalidate/compat/compare.h>
 #include <jvalidate/forward.h>
 
 namespace jvalidate::detail {
+/**
+ * @brief A helper struct for use in appending elements to a json Pointer object
+ * in a way that allows it to be used as a template parameter - similar to how
+ * ostream allows operator<<(void(*)(ostream&)) to pass in a function callback
+ * for implementing various iomanip functions as piped (read:fluent) values.
+ *
+ * However, the primary usecase for this is in a template context, where I want
+ * to add 0-or-more path components to a JSON-Pointer of any type, and also want
+ * to support neighbor Pointers, instead of only child Pointers.
+ *
+ * For example, @see ValidationVisitor::visit(constraint::ConditionalConstraint)
+ * where we use parent to rewind the path back to the owning scope for
+ * if-then-else processing.
+ */
+struct parent_t {};
+constexpr parent_t parent;
 
 class Pointer {
 public:
   Pointer() = default;
   Pointer(std::vector<std::variant<std::string, size_t>> const & tokens) : tokens_(tokens) {}
+
+  /**
+   * @brief Parse a JSON-Pointer from a serialized JSON-Pointer-String. In
+   * principle, this should either be a factory function returning an optional/
+   * throwing on error - but we'll generously assume that all JSON-Pointers are
+   * valid - and therefore that an invalidly formatter pointer string will
+   * point to somewhere non-existant (since it will be used in schema handling)
+   */
   Pointer(std::string_view path) {
     if (path.empty()) {
       return;
     }
 
     auto append_with_parse = [this](std::string in) {
+      // Best-guess that the input token text represents a numeric value.
+      // Technically - this could mean that we have an object key that is also
+      // a number (e.g. the jsonized form of map<int, T>), but we can generally
+      // assume that we are not going to use those kinds of paths in a reference
+      // field. Therefore we don't need to include any clever tricks for storage
       if (not in.empty() && in.find_first_not_of("0123456789") == std::string::npos) {
         return tokens_.push_back(std::stoull(in));
       }
 
       for (size_t i = 0; i < in.size(); ++i) {
+        // Allow URL-Escaped characters (%\x\x) to be turned into their
+        // matching ASCII characters. This allows passing abnormal chars other
+        // than '/' and '~' to be handled in all contexts.
+        // TODO(samjaffe): Only do this if enc is hex-like (currently throws?)
         if (in[i] == '%') {
           char const enc[3] = {in[i + 1], in[i + 2]};
           in.replace(i, 3, 1, char(std::stoi(enc, nullptr, 16)));
         } else if (in[i] != '~') {
+          // Not a special char-sequence, does not need massaging
           continue;
         }
+        // In order to properly support '/' inside the property name of an
+        // object, we must escape it. The designers of the JSON-Pointer RFC
+        // chose to use '~' as a special signifier. Mapping '~0' to '~', and
+        // '~1' to '/'.
         if (in[i + 1] == '0') {
           in.replace(i, 2, 1, '~');
         } else if (in[i + 1] == '1') {
@@ -44,35 +81,104 @@ public:
       tokens_.push_back(std::move(in));
     };
 
+    // JSON-Pointers are required to start with a '/' although we only enforce
+    // that rule in Reference.
     path.remove_prefix(1);
-    size_t p = path.find('/');
-    for (; p != std::string::npos; path.remove_prefix(p + 1), p = path.find('/')) {
+    // The rules of JSON-Pointer is that if a token were to contain a '/' as a
+    // strict character: then that character would be escaped, using the above
+    // rules. We take advantage of string_view's sliding view to make iteration
+    // easy.
+    for (size_t p = path.find('/'); p != std::string::npos;
+         path.remove_prefix(p + 1), p = path.find('/')) {
       append_with_parse(std::string(path.substr(0, p)));
     }
 
     append_with_parse(std::string(path));
   }
 
-  template <Adapter A> A walk(A document) const {
+  /**
+   * @brief Dive into a JSON object throught the entire path of the this object
+   *
+   * @param document A JSON Adapter document - confirming to the following spec:
+   * 1. Is indexable by size_t, returning its own type
+   * 2. Is indexable by std::string, returning its own type
+   * 3. Indexing into a null/incorrect json type, or for an absent child is safe
+   *
+   * @returns A new JSON Adapter at the pointed to location, or a generic null
+   * JSON object.
+   */
+  auto walk(Adapter auto document) const {
     for (auto const & token : tokens_) {
       document = std::visit([&document](auto const & next) { return document[next]; }, token);
     }
     return document;
   }
 
+  /**
+   * @brief Fetch the last item in this pointer as a string (for easy
+   * formatting). This function is used more-or-less exclusively to support the
+   * improved annotation/error listing concepts described in the article:
+   * https://json-schema.org/blog/posts/fixing-json-schema-output
+   */
+  std::string back() const {
+    struct {
+      std::string operator()(std::string const & in) const { return in; }
+      std::string operator()(size_t in) const { return std::to_string(in); }
+    } g_as_str;
+    return tokens_.empty() ? "" : std::visit(g_as_str, tokens_.back());
+  }
+
   bool empty() const { return tokens_.empty(); }
 
+  /**
+   * @brief Determines if this JSON-Pointer is prefixed by the other
+   * JSON-Pointer. For example: `"/A/B/C"_jsptr.starts_with("/A/B") == true`
+   *
+   * This is an important thing to know when dealing with schemas that use
+   * Anchors or nest $id tags in a singular document. Consider the schema below:
+   * @code{.json}
+   *  {
+   *    "$id": "A",
+   *    "$defs": {
+   *      "B": {
+   *        "$anchor": "B"
+   *        "$defs": {
+   *          "C": {
+   *            "$anchor": "C"
+   *          }
+   *        }
+   *      }
+   *    }
+   *  }
+   * @endcode
+   *
+   * How can we deduce that "A#B" and "A#C" are related to one-another as parent
+   * and child nodes? First we translate them both into absolute (no-anchor)
+   * forms "A#/$defs/B" and "A#/$defs/B/$defs/C". Visually - these are now
+   * obviously related - but we need to expose the functionalty to make that
+   * check happen (that "/$defs/B/$defs/C" starts with "/$defs/B").
+   */
   bool starts_with(Pointer const & other) const {
     return other.tokens_.size() <= tokens_.size() &&
            std::equal(other.tokens_.begin(), other.tokens_.end(), tokens_.begin());
   }
 
+  /**
+   * @brief A corollary function to starts_with, create a "relative"
+   * JSON-Pointer to some parent. Relative pointers are only partially supported
+   * (e.g. if you tried to print it it would still emit the leading slash), so
+   * the standard use case of this function is to either use it when choosing
+   * a URI or Anchor that is a closer parent:
+   * `Reference(uri, anchor, ptr.relative_to(other))`
+   * or immediately concatenating it onto another absolute pointer:
+   * `abs /= ptr.relative_to(other)`
+   */
   Pointer relative_to(Pointer const & other) const {
     assert(starts_with(other));
     return Pointer(std::vector(tokens_.begin() + other.tokens_.size(), tokens_.end()));
   }
 
-  Pointer parent() const { return Pointer({tokens_.begin(), tokens_.end() - 1}); }
+  Pointer parent(size_t i = 1) const { return Pointer({tokens_.begin(), tokens_.end() - i}); }
 
   Pointer & operator/=(Pointer const & relative) {
     tokens_.insert(tokens_.end(), relative.tokens_.begin(), relative.tokens_.end());
@@ -81,6 +187,13 @@ public:
 
   Pointer operator/(Pointer const & relative) const { return Pointer(*this) /= relative; }
 
+  Pointer & operator/=(parent_t) {
+    tokens_.pop_back();
+    return *this;
+  }
+
+  Pointer operator/(parent_t) const { return parent(); }
+
   Pointer & operator/=(std::string_view key) {
     tokens_.emplace_back(std::string(key));
     return *this;

+ 60 - 20
include/jvalidate/detail/reference.h

@@ -12,6 +12,19 @@
 namespace jvalidate::detail {
 class Reference;
 
+/**
+ * @brief A class describing a "Reference without a JSON-Pointer" object.
+ * For the sake of avoiding code-duplication, we implement References in terms
+ * of this class, which makes this comment awkward.
+ *
+ * A RootReference refers to a URI and/or an Anchor object - and is specifically
+ * meant for use with {@see ReferenceCache} and {@see ReferenceManager} to
+ * create bindings beween the Anchor/URI points of $id/$anchor tags with their
+ * absolute reference parents.
+ *
+ * Because of this, there is an intrinsic link between RootReference and
+ * Reference objects, although it is not a 1-1 relationship.
+ */
 class RootReference {
 private:
   friend class Reference;
@@ -21,9 +34,13 @@ private:
 public:
   RootReference() = default;
 
+  // Piecewise constructor for RootReference, supports (URI) and (URI, Anchor)
   explicit RootReference(URI const & uri, Anchor const & anchor = {})
       : uri_(uri), anchor_(anchor) {}
 
+  // Parser-ctor for RootReference, implemented in terms of
+  // {@see RootReference::RootReference(std::string_view, out<size_t>)}, which
+  // is also used by Reference's parser-ctor.
   explicit RootReference(std::string_view ref) : RootReference(ref, discard_out) {}
 
   bool is_relative() const { return uri_.is_relative(); }
@@ -36,26 +53,55 @@ public:
   auto operator<=>(RootReference const &) const = default;
 
 private:
+  /**
+   * @brief Parse a RootReference out from a given textual representation.
+   *
+   * @param ref A string containing a URI and/or anchor. By convention - this
+   * parameter should be "#" if it is refering to an empty RootReference.
+   *
+   * @param[out] end An output variable that tracks the end-position of the
+   * anchor. When calling {@see Reference::Reference(std::string_view)}, this
+   * lets us properly offset the view for the JSON-Pointer component without
+   * needing to re-implement the code that scans for it.
+   */
   RootReference(std::string_view ref, out<size_t> end) {
+    // By default, RootReference will consume the entire input
     end = std::string::npos;
 
+    // As also mentioned in URI, the fragment identifier is used in a
+    // JSON-Reference to separate the URI from the Anchor/Pointer component(s)
     size_t end_of_uri = ref.find('#');
     uri_ = URI(ref.substr(0, end_of_uri));
+    // If there is not a fragment-separator, then this RootReference is all URI
+    // There will be no Anchor or JSON-Pointer components to extract.
     if (end_of_uri == std::string::npos) {
       return;
     }
 
+    // Skip past the "#"
     ref.remove_prefix(end_of_uri + 1);
+    // Anchors prohibit most characters, so we can be sure that the first "/"
+    // past the URI is the endpoint of the Anchor
     size_t const pointer_start = ref.find('/');
     anchor_ = Anchor(ref.substr(0, pointer_start));
 
+    // Prohibit a trailing JSON-Pointer unless the caller provided the out-param
     EXPECT_M(end || pointer_start == std::string::npos, "JSON-Pointer is illegal in this context");
+    // Ensure proper offset is applied: add pointer_start to end_of_uri because
+    // we called remove_prefix on ref. Add an additional +1 because of the "#"
     if (pointer_start != std::string::npos) {
       end = pointer_start + end_of_uri + 1;
     }
   }
 };
 
+/**
+ * @brief A Reference is a class describing a location of a JSON value. This may
+ * describe an external document, and anchor within a document (jump to
+ * location), and/or a path to a value from a parent location. References allow
+ * us to combine all three of these properties together - although in practice
+ * the Anchor field should be empty before it escapes the detail namespace.
+ */
 class Reference {
 private:
   RootReference root_;
@@ -64,12 +110,15 @@ private:
 public:
   Reference() = default;
 
+  // Piecewise constructor for References (URI) and (URI, Pointer)
   explicit Reference(URI const & uri, Pointer const & pointer = {})
       : root_(uri), pointer_(pointer) {}
 
+  // Piecewise constructor for References (URI, Anchor) and (URI, Anchor, Pointer)
   explicit Reference(URI const & uri, Anchor const & anchor, Pointer const & pointer = {})
       : root_(uri, anchor), pointer_(pointer) {}
 
+  // Piecewise constructor for References using RootReference
   explicit Reference(RootReference const & root, Pointer const & pointer = {})
       : root_(root), pointer_(pointer) {}
 
@@ -88,26 +137,17 @@ public:
   RootReference const & root() const { return root_; }
   Reference parent() const { return Reference(root_, pointer_.parent()); }
 
-  Reference & operator/=(Pointer const & relative) {
-    pointer_ /= relative;
-    return *this;
-  }
-
-  Reference operator/(Pointer const & relative) const { return Reference(*this) /= relative; }
-
-  Reference & operator/=(std::string_view key) {
-    pointer_ /= key;
-    return *this;
-  }
-
-  Reference operator/(std::string_view key) const { return Reference(*this) /= key; }
-
-  Reference & operator/=(size_t index) {
-    pointer_ /= index;
-    return *this;
-  }
-
-  Reference operator/(size_t index) const { return Reference(*this) /= index; }
+  /**
+   * @brief Delegate function for {@see Pointer::operator/=}, but returning
+   * this Reference.
+   */
+  Reference & operator/=(auto const & in) { return (pointer_ /= in, *this); }
+
+  /**
+   * @brief Delegate function for {@see Pointer::operator/}, but returning
+   * a Reference.
+   */
+  Reference operator/(auto const & in) const { return Reference(*this) /= in; }
 
   friend std::ostream & operator<<(std::ostream & os, Reference const & self) {
     return os << self.root_ << self.pointer_;

+ 93 - 0
include/jvalidate/detail/reference_cache.h

@@ -8,17 +8,69 @@
 #include <jvalidate/detail/reference.h>
 
 namespace jvalidate::detail {
+/**
+ * @brief An bidirectional cache of absolute references
+ * (URI + Anchor + JSON-Pointer) to root references (URI + Anchor). An object of
+ * this sort is necessary for a couple of reasons:
+ *
+ * 1. It is possible to have more than one absolute reference map to the same
+ *    root reference.
+ * 2. We need to employ some special handling to properly identify the nearest
+ *    RootReference that owns a given absolute Reference - given that that ref
+ *    may not actually be anchored to the RootReference at the time.
+ */
 class ReferenceCache {
 private:
   std::map<Reference, RootReference, std::greater<>> to_anchor_;
   std::map<RootReference, Reference, std::greater<>> to_absolute_;
 
 public:
+  /**
+   * @brief Register the entry-point of a given schema-document. This function
+   * should be called exactly one time for each "$id" tag in a given schema that
+   * is being loaded.
+   *
+   * In principle, we should also perform a uniqueness check when calling
+   * to_absolute_.emplace.
+   */
   void emplace(URI const & root) {
     to_absolute_.emplace(root, root);
     to_anchor_.emplace(root, root);
   }
 
+  /**
+   * @brief Link together the absolute and root references of a document, as
+   * well as recursively walk through all possible parent URIs that are already
+   * stored in this cache.
+   *
+   * Therefore, this function will add "exactly 1" mapping to the to_absolute_
+   * map, and "at least 1, but no more than to_absolute_.size()" mappings to
+   * to_anchor_, representing all of the parent reference paths that link to the
+   * newly added RootReference
+   *
+   * @param absolute An absolute JSON Reference, which either contains no
+   * RootReference component, or contains the previous traversed RootReference,
+   * as defined by the $id, $anchor, $recursiveAnchor, or $dynamicAnchor tags.
+   *
+   * @param canonical The current RootReference being operated on from the
+   * tags listed above.
+   *
+   * For example, if we have a json document like:
+   * @code{.json}
+   * {
+   *   "$id": "A",
+   *   "$defs": {
+   *     "Example": {
+   *       "$id": "B"
+   *     }
+   *   }
+   * }
+   * @endcode
+   *
+   * then we would end up calling this function with the arguments:
+   *    absolute="#",                canonical="A#"
+   *    absolute="A#/$defs/Example", canonical="B#"
+   */
   Reference emplace(Reference const & absolute, RootReference const & canonical) {
     for (Reference where = absolute; not where.pointer().empty();) {
       // Recursively add all possible alternative paths that are equivalent to
@@ -42,6 +94,30 @@ public:
     return Reference(canonical);
   }
 
+  /**
+   * @brief Identifies the nearest RootReference that is associated with the
+   * input.
+   *
+   * @param ref An arbitrary reference that we want to locate the nearest root
+   * for.
+   *
+   * @param for_parent_reference A flag indicating if we should prohibit exact
+   * matches of reference. For example, suppose that we have the same bindings
+   * as in the above method comment:
+   *    absolute="#",                canonical="A#"
+   *    absolute="A#/$defs/Example", canonical="B#"
+   * If I request `relative_to_nearest_anchor("A#/$defs/Example", false)` then
+   * it will return `B#` as the associated RootReference, because we have stored
+   * that exact mapping in our absolute path to anchor cache.
+   *
+   * On the other hand - suppose that we want to ensure that we've acquired
+   * strict parent of the current reference.
+   * `relative_to_nearest_anchor("A#/$defs/Example", true)` would say "an anchor
+   * cannot be its own parent, therefore we cannot resolve to B#".
+   *
+   * @returns ref, recalculated to be relative_to its nearest parent root, if
+   * one is available.
+   */
   Reference relative_to_nearest_anchor(Reference const & ref,
                                        bool for_parent_reference = false) const {
     auto it = for_parent_reference ? to_anchor_.upper_bound(ref) : to_anchor_.lower_bound(ref);
@@ -51,12 +127,24 @@ public:
 
     auto const & [absolute, anchor] = *it;
     if (not ref.pointer().starts_with(absolute.pointer())) {
+      // We've undershot our reference and landed at a cousin/neighbor node
       return ref;
     }
 
     return Reference(anchor, ref.pointer().relative_to(absolute.pointer()));
   }
 
+  /**
+   * @brief Deduces the URI part of the actual parent of this node, utilizing
+   * the "an anchor cannot be its own parent" rule described above.
+   *
+   * @param parent An arbitrarily constructed reference to the parent context
+   * of some other reference we are operating on.
+   *
+   * @returns The URI of the nearest non-equal parent if it exists and/or there
+   * is a URI part in parent. If there is no URI part, we check if there is a
+   * URI for the root (input) schema.
+   */
   URI actual_parent_uri(detail::Reference const & parent) const {
     // TODO(samjaffe): Think about this some more - there's something awkward here
     URI uri = relative_to_nearest_anchor(parent, true).uri();
@@ -65,6 +153,11 @@ public:
       return uri;
     }
 
+    // This is a special case because we prohibit exact matches in the above
+    // relative_to_nearest_anchor call. Since we can only reach this line if
+    // BOTH uri and parent.uri() are empty - that means that the appropriate
+    // parent is the root document, which might have marked its URI with an
+    // $id tag.
     if (auto it = to_anchor_.find(Reference()); it != to_anchor_.end()) {
       return it->second.uri();
     }

+ 207 - 25
include/jvalidate/detail/reference_manager.h

@@ -1,10 +1,9 @@
 #pragma once
 
-#include <functional>
 #include <map>
-#include <set>
-#include <unordered_map>
+#include <unordered_set>
 
+#include <jvalidate/compat/enumerate.h>
 #include <jvalidate/detail/anchor.h>
 #include <jvalidate/detail/dynamic_reference_context.h>
 #include <jvalidate/detail/expect.h>
@@ -19,13 +18,28 @@
 #include <jvalidate/enum.h>
 #include <jvalidate/forward.h>
 #include <jvalidate/uri.h>
-#include <unordered_set>
 
 namespace jvalidate::detail {
+/**
+ * @brief An object responsible for owning/managing the various documents,
+ * references, and related functionality for ensuring that we properly construct
+ * things.
+ *
+ * In order to support this we store information on:
+ * - A {@see jvalidate::detail::ReferenceCache} that maps various absolute
+ *   Reference paths to their Canonical forms.
+ * - "Vocabularies", which describe the the set of legal keywords for
+ *   constraint parsing.
+ * - "Anchor Locations", a non-owning store of the Adapters associated with
+ *   "$id"/"$anchor" tags to allow quick lookups without having to re-walk the
+ *   document.
+ * - "Dynamic Anchors", a list of all of the "$dynamicAnchor" tags that exist
+ *   under a given "$id" tag, and those bindings which are active in the current
+ *   scope.
+ *
+ * @tparam A The adapter type being operated upon
+ */
 template <Adapter A> class ReferenceManager {
-public:
-  using Keywords = std::unordered_map<std::string_view, std::set<schema::Wraps>>;
-
 private:
   static inline std::map<std::string_view, schema::Version> const g_schema_ids{
       {"json-schema.org/draft-03/schema", schema::Version::Draft03},
@@ -40,20 +54,46 @@ private:
   DocumentCache<A> & external_;
 
   ReferenceCache references_;
+
   std::map<schema::Version, Vocabulary<A>> vocabularies_;
   std::map<URI, Vocabulary<A>> user_vocabularies_;
+
   std::map<RootReference, A> roots_;
-  std::map<URI, std::map<Anchor, Reference>> dynamic_anchors_;
 
+  std::map<URI, std::map<Anchor, Reference>> dynamic_anchors_;
   DynamicReferenceContext active_dynamic_anchors_;
 
 public:
+  /**
+   * @brief Construct a new ReferenceManager around a given root schema
+   *
+   * @param external A cache/loader of external documents. Due to the way that
+   * {@see jvalidate::Schema} is implemented, the cache may have the same
+   * lifetime as this object, despite being owned by mutable reference.
+   *
+   * @param root The root schema being operated on.
+   *
+   * @param version The version of the schema being used for determining the
+   * base vocabulary to work with (see the definition of schema::Version for
+   * more details on how the base vocabulary changes).
+   *
+   * @param constraints A factory for turning JSON schema information into
+   * constraints.
+   */
   ReferenceManager(DocumentCache<A> & external, A const & root, schema::Version version,
                    ConstraintFactory<A> const & constraints)
       : external_(external), constraints_(constraints), roots_{{{}, root}} {
     prime(root, {}, &vocab(version));
   }
 
+  /**
+   * @brief Turn a schema version into a vocabulary, ignoring user-defined
+   * vocabularies
+   *
+   * @param version The schema version
+   *
+   * @returns The default vocabulary for a given draft version
+   */
   Vocabulary<A> const & vocab(schema::Version version) {
     if (not vocabularies_.contains(version)) {
       vocabularies_.emplace(version, constraints_.keywords(version));
@@ -61,6 +101,18 @@ public:
     return vocabularies_.at(version);
   }
 
+  /**
+   * @brief Fetch the vocabulary information associated with a given "$schema"
+   * tag. Unlike the enum version of this function, we can also load
+   * user-defined schemas using the ReferenceCache object, if supported. This
+   * allows us to define custom constraints or remove some that we want to
+   * forbid.
+   *
+   * @param schema The location of the schema being fetched
+   *
+   * @returns If schema is a draft version - then one of the default
+   * vocabularies, else a user-schema is loaded.
+   */
   Vocabulary<A> const & vocab(URI schema) {
     if (auto it = g_schema_ids.find(schema.resource()); it != g_schema_ids.end()) {
       return vocab(it->second);
@@ -75,26 +127,65 @@ public:
     EXPECT_M(external->type() == adapter::Type::Object, "meta-schema must be an object");
 
     auto metaschema = external->as_object();
-    EXPECT_M(metaschema.contains("$schema"), "meta-schema must reference an");
+    // All user-defined schemas MUST have a parent schema they point to
+    // Furthermore - in order to be well-formed, the schema chain must
+    // eventually point to one of the draft schemas. However - if a metaschema
+    // ends up in a recusive situation (e.g. A -> B -> A), it will not fail in
+    // the parsing step, but instead produce a malformed Schema object for
+    // validation.
+    EXPECT_M(metaschema.contains("$schema"),
+             "user-defined meta-schema must reference a base schema");
 
     // Initialize first to prevent recursion
     Vocabulary<A> & parent = user_vocabularies_[schema];
     parent = vocab(URI(metaschema["$schema"].as_string()));
 
     if (metaschema.contains("$vocabulary")) {
-      parent.restrict(extract_keywords(metaschema["$vocabulary"].as_object()));
+      // This is a silly thing we have to do because rather than have some kind
+      // of annotation/assertion divide marker for the format constraint, we
+      // instead use true/false in Draft2019-09, and have format-assertion/
+      // format-annotation vocabularies in Draft2020-12.
+      auto [keywords, vocabularies] = extract_keywords(metaschema["$vocabulary"].as_object());
+      parent.restrict(keywords, vocabularies);
     }
 
     return parent;
   }
 
+  /**
+   * @brief Load the current location into the stack of dynamic ref/anchors so
+   * that we are able to properly resolve them (e.g. because an anchor got
+   * disabled).
+   *
+   * @param ref The current parsing location in the schema, which should
+   * correspond with an "$id" tag.
+   *
+   * @returns A scope object that will remove this set of dynamic ref/anchor
+   * resolutions from the stack when it exits scope.
+   */
   auto dynamic_scope(Reference const & ref) {
     URI const uri =
         ref.pointer().empty() ? ref.uri() : references_.relative_to_nearest_anchor(ref).uri();
     return active_dynamic_anchors_.scope(uri, dynamic_anchors_[uri]);
   }
 
-  std::optional<A> load(Reference const & ref, schema::Version version) {
+  /**
+   * @breif "Load" a requested document reference, which may exist in the
+   * current document, or in an external one.
+   *
+   * @param ref The location to load. Since there is no guarantee of direct
+   * relation between the current scope and this reference, we treat this like a
+   * jump.
+   *
+   * @param vocab The current vocabulary being used for parsing. It may be
+   * changed when loading the new reference if there is a "$schema" tag at the
+   * root.
+   *
+   * @returns The schema corresponding to the reference, if it can be located.
+   * As long as ref contains a valid URI/Anchor, we will return an Adapter, even
+   * if that adapter might point to a null JSON.
+   */
+  std::optional<A> load(Reference const & ref, Vocabulary<A> const * vocab) {
     if (auto it = roots_.find(ref.root()); it != roots_.end()) {
       return ref.pointer().walk(it->second);
     }
@@ -104,31 +195,55 @@ public:
       return std::nullopt;
     }
 
-    // TODO(samjaffe): Change Versions if needed...
     references_.emplace(ref.uri());
-    prime(*external, ref, &vocab(version));
+    prime(*external, ref, vocab);
 
     // May have a sub-id that we map to
     if (auto it = roots_.find(ref.root()); it != roots_.end()) {
       return ref.pointer().walk(it->second);
     }
 
-    // Will get called if the external schema does not declare a root document id?
+    // Will get called if the external schema does not declare a root id?
     return ref.pointer().walk(*external);
   }
 
+  /**
+   * @brief Transform a reference into its "canonical" form, in the context of
+   * the calling context (parent).
+   *
+   * @param ref The value of a "$ref" or "$dynamicRef" token, that is being
+   * looked up.
+   *
+   * @param parent The current lexical scope being operated in.
+   *
+   * @param dynamic_reference As an input, indicates that we are requesting a
+   * dynamic reference instead of a normal $ref.
+   * As an output, indicates that we effectively did resolve a dynamicRef and
+   * therefore should alter the dynamic scope in order to prevent infinite
+   * recursions in schema parsing.
+   *
+   * @returns ref, but in its canonical/lexical form.
+   */
   Reference canonicalize(Reference const & ref, Reference const & parent,
                          inout<bool> dynamic_reference) {
     URI const uri = [this, &ref, &parent]() {
+      // If there are no URIs involed (root schema does not set "$id")
+      // then we don't need to do anything clever
       if (ref.uri().empty() && parent.uri().empty()) {
         return references_.actual_parent_uri(parent);
       }
 
+      // At least one of ref and parent have a real URI/"$id" value. If it has a
+      // "root" (e.g. file:// or http://), then we don't need to do any clever
+      // alterations to identify the root.
       URI uri = ref.uri().empty() ? parent.uri() : ref.uri();
       if (not uri.is_rootless()) {
         return uri;
       }
 
+      // Now we need to compute that URI into the context of its parent, such
+      // as if ref := "file.json" and
+      // parent := "http://localhost:8000/schemas/root.json"
       URI base = references_.actual_parent_uri(parent);
       EXPECT_M(base.resource().rfind('/') != std::string::npos,
                "Unable to deduce root for relative uri " << uri << " (" << base << ")");
@@ -142,7 +257,9 @@ public:
       return base.parent() / uri;
     }();
 
-    URI const dyn_uri = ref.uri().empty() ? ref.uri() : uri;
+    // This seems unintuitive, but we generally want to avoid providing a URI
+    // when looking up dynamic references, unless they are explicitly asked for.
+    URI const dyn_uri = ref.uri().empty() ? URI() : uri;
     if (std::optional dynref = dynamic(dyn_uri, ref, dynamic_reference)) {
       return *dynref;
     }
@@ -158,6 +275,25 @@ public:
   }
 
 private:
+  /**
+   * @brief Locate the dynamic reference being requested (if it is being
+   * requested).
+   *
+   * @param uri The dynamic reference uri being requested, generally empty.
+   *
+   * @param ref The value of a "$ref" or "$dynamicRef" token, that is being
+   * looked up. Primarily used for the anchor value, which is relevant for
+   * $dynamicRef/$dynamicAnchor.
+   *
+   * @param dynamic_reference As an input, indicates that we are requesting a
+   * dynamic reference instead of a normal $ref.
+   * As an output, indicates that we effectively did resolve a dynamicRef and
+   * therefore should alter the dynamic scope in order to prevent infinite
+   * recursions in schema parsing.
+   *
+   * @returns If there is a dynamic reference for the requested anchor, we
+   * return it.
+   */
   std::optional<Reference> dynamic(URI const & uri, Reference const & ref,
                                    inout<bool> dynamic_reference) {
     bool const anchor_is_dynamic = active_dynamic_anchors_.contains(ref.anchor());
@@ -181,36 +317,54 @@ private:
     return active_dynamic_anchors_.lookup(uri, ref.anchor());
   }
 
+  /**
+   * @brief Prepare a newly loaded document, importing schema information,
+   * ids, anchors, and dynamic anchors recursively.
+   *
+   * @param json The document being loaded
+   *
+   * @param vocab The vocabulary of legitimate keywords to iterate through to
+   * locate ids etc.
+   */
   void prime(Adapter auto const & json, Reference where, Vocabulary<A> const * vocab) {
     if (json.type() != adapter::Type::Object) {
       return;
     }
 
-    canonicalize(where, vocab->version(), json);
-
     auto schema = json.as_object();
+    // Update vocabulary to the latest form
     if (schema.contains("$schema")) {
       vocab = &this->vocab(URI(schema["$schema"].as_string()));
     }
 
+    // Load ids, anchors, etc.
+    prime_roots(where, vocab->version(), json);
+
+    // Recurse through the document
     for (auto const & [key, value] : schema) {
       if (not vocab->is_keyword(key)) {
         continue;
       }
       switch (value.type()) {
       case adapter::Type::Array: {
-        size_t index = 0;
-        for (auto const & elem : value.as_array()) {
+        // Recurse through array-type schemas, such as anyOf, allOf, and oneOf
+        // we don't actually check that the key is one of those, because if we
+        // do something stupid like "not": [] then the parsing phase will return
+        // an error.
+        for (auto const & [index, elem] : detail::enumerate(value.as_array())) {
           prime(elem, where / key / index, vocab);
-          ++index;
         }
         break;
       }
       case adapter::Type::Object:
+        // Normal schema-type data such as not, additionalItems, etc. hold a
+        // schema as their immidiate child.
         if (not vocab->is_property_keyword(key)) {
           prime(value, where / key, vocab);
           break;
         }
+        // Special schemas are key-value stores, where the key is arbitrary and
+        // the value is the schema. Therefore we need to skip over the props.
         for (auto const & [prop, elem] : value.as_object()) {
           prime(elem, where / key / prop, vocab);
         }
@@ -220,7 +374,20 @@ private:
     }
   }
 
-  void canonicalize(Reference & where, schema::Version version, A const & json) {
+  /**
+   * @brief Optionally register any root document at this location, as
+   * designated by things like the "$id" and "$anchor" tags.
+   *
+   * @param where The current lexical location in the schema - if there is an
+   * id/anchor tag, then we overwrite this value with the newly indicated root.
+   *
+   * @param version The current schema version - used to denote the name of the
+   * id tag, whether anchors are available, and how dynamic anchors function
+   * (Draft2019-09's recursiveAnchor vs. Draft2020-12's dynamicAnchor).
+   *
+   * @param json The document being primed.
+   */
+  void prime_roots(Reference & where, schema::Version version, A const & json) {
     std::string const id = version <= schema::Version::Draft04 ? "id" : "$id";
     auto const schema = json.as_object();
 
@@ -230,7 +397,7 @@ private:
       if (root.uri().empty()) {
         root = RootReference(where.uri(), root.anchor());
       } else if (not root.uri().is_rootless() || where.uri().empty()) {
-        // By definition - rootless URIs cannot be relative
+        // By definition - rooted URIs cannot be relative
       } else if (root.uri().is_relative()) {
         root = RootReference(where.uri().parent() / root.uri(), root.anchor());
       } else {
@@ -252,6 +419,8 @@ private:
       where = references_.emplace(where, root);
     }
 
+    // Unfortunately - $recursiveAnchor and $dynamicAnchor use very different
+    // handling mechanisms, so it is not convenient to merge together
     if (version == schema::Version::Draft2019_09 && schema.contains("$recursiveAnchor") &&
         schema["$recursiveAnchor"].as_boolean()) {
       Anchor anchor;
@@ -280,20 +449,33 @@ private:
     }
   }
 
-  std::unordered_set<std::string> extract_keywords(ObjectAdapter auto const & vocabularies) const {
+  /**
+   * @brief Extract the supported keywords of a given selection of vocabularies
+   *
+   * @param vocabularies A map of the form (VocabularyURI => Enabled)
+   *
+   * @returns A pair containing:
+   * - All of the enabled keywords in the vocabulary
+   * - The list of enabled vocabulary metaschema (used for is_format_assertion)
+   */
+  auto extract_keywords(ObjectAdapter auto const & vocabularies) const
+      -> std::pair<std::unordered_set<std::string>, std::unordered_set<std::string>> {
     std::unordered_set<std::string> keywords;
+    std::unordered_set<std::string> vocab_docs;
     for (auto [vocab, enabled] : vocabularies) {
       if (not enabled.as_boolean()) {
         continue;
       }
 
-      vocab.replace(vocab.find("/vocab/"), 7, "/meta/");
+      size_t n = vocab.find("/vocab/");
+      vocab_docs.emplace(vocab.substr(n));
+      vocab.replace(n, 7, "/meta/");
       auto vocab_object = external_.try_load(URI(vocab));
       for (auto const & [keyword, _] : vocab_object->as_object()["properties"].as_object()) {
         keywords.insert(keyword);
       }
     }
-    return keywords;
+    return std::make_pair(keywords, vocab_docs);
   }
 };
 }

+ 55 - 0
include/jvalidate/detail/relative_pointer.h

@@ -0,0 +1,55 @@
+#pragma once
+
+#include <ostream>
+#include <string>
+#include <string_view>
+
+#include <jvalidate/detail/expect.h>
+#include <jvalidate/detail/pointer.h>
+#include <jvalidate/forward.h>
+
+namespace jvalidate::detail {
+class RelativePointer {
+public:
+  RelativePointer(std::string_view path) {
+    if (path == "0") {
+      return;
+    }
+    if (auto pos = path.find('/'); pos != path.npos) {
+      pointer_ = Pointer(path.substr(pos));
+      path.remove_suffix(path.size() - pos);
+    } else {
+      EXPECT_M(not path.empty() && path.back() == '#',
+               "RelativePointer must end in a pointer, or a '#'");
+      requests_key_ = true;
+      path.remove_suffix(1);
+    }
+    parent_steps_ = std::stoull(std::string(path));
+  }
+
+  template <Adapter A>
+  std::variant<std::string, A> inspect(Pointer const & where, A const & root) const {
+    if (requests_key_) {
+      return where.parent(parent_steps_).back();
+    }
+    auto rval = where.parent(parent_steps_).walk(root);
+    return pointer_ ? pointer_->walk(rval) : rval;
+  }
+
+  friend std::ostream & operator<<(std::ostream & os, RelativePointer const & rel) {
+    os << rel.parent_steps_;
+    if (rel.requests_key_) {
+      return os << '#';
+    }
+    if (rel.pointer_) {
+      os << *rel.pointer_;
+    }
+    return os;
+  }
+
+private:
+  size_t parent_steps_ = 0;
+  bool requests_key_ = false;
+  std::optional<Pointer> pointer_ = std::nullopt;
+};
+}

+ 91 - 0
include/jvalidate/detail/scoped_state.h

@@ -0,0 +1,91 @@
+#pragma once
+
+#include <functional>
+#include <type_traits>
+
+#define JVALIDATE_CONCAT2(A, B) A##B
+#define JVALIDATE_CONCAT(A, B) JVALIDATE_CONCAT2(A, B)
+
+/**
+ * @breif Create an anonymous scoped state object, which represents a temporary
+ * change of value. Since we only need to give ScopedState a name to ensure that
+ * its lifetime isn't for only a single line, this macro allows us to be more
+ * appropriately terse.
+ *
+ * @code
+ * {
+ *   scoped_state(property_, value...);
+ *   // do some things...
+ * }
+ * @endcode
+ *
+ * but this one provides exit guards in the same way that @see OnBlockExit does.
+ *
+ * @param prop A reference to a property that should be altered in the current
+ * function-scope. Is immediately modified to {@see value}, and will be returned
+ * to its original value when the current scope exits.
+ *
+ * @param value The new value to be set into prop.
+ */
+#define scoped_state(prop, value)                                                                  \
+  auto JVALIDATE_CONCAT(scoped_state_, __LINE__) = detail::ScopedState(prop, value)
+
+namespace jvalidate::detail {
+/**
+ * @brief An object that alters a given value to a provided temporary, and then
+ * restores it to the original value upon being destructed. Because of this
+ * characteristic, the following two pieces of code are equivalent:
+ *
+ * @code
+ * T tmp = value...;
+ * std::swap(property_, tmp);
+ * // do some things...
+ * std::swap(property_, tmp);
+ * @endcode
+ *
+ * @code
+ * {
+ *   ScopedState tmp(property_, value...);
+ *   // do some things...
+ * }
+ * @endcode
+ */
+class ScopedState {
+private:
+  std::function<void()> reset_;
+
+public:
+  /**
+   * @brief Initialize a scoped change-in-value to a property, properly guarded
+   * against early-returns, exceptions, and forgetting to reset the property.
+   *
+   * @tparam T The type of the value being updated
+   * @tparam S Any type that is compatible with T
+   *
+   * @param prop A reference to a property that should be altered in the current
+   * function-scope. Is immediately modified to {@see value}, and will be returned
+   * to its original value when the current scope exits.
+   *
+   * @param value The new value to be set into prop.
+   */
+  template <typename T, typename S>
+    requires(std::is_constructible_v<T, S>)
+  ScopedState(T & prop, S value) : reset_([reset = prop, &prop]() { prop = reset; }) {
+    prop = std::move(value);
+  }
+
+  ~ScopedState() { reset_(); }
+
+  /**
+   * @brief By providing an explicit operator bool, it is possible to use
+   * ScopedState in an if statement, allowing you to write something like:
+   *
+   * @code
+   * if (scoped_state(property_, value...)) {
+   *   // do some things...
+   * }
+   * @endcode
+   */
+  explicit operator bool() const { return true; }
+};
+}

+ 53 - 4
include/jvalidate/detail/simple_adapter.h

@@ -1,6 +1,6 @@
 #pragma once
 
-#include <unordered_map>
+#include <map>
 #include <vector>
 
 #include <jvalidate/adapter.h>
@@ -11,6 +11,14 @@
 #include <jvalidate/status.h>
 
 namespace jvalidate::adapter::detail {
+/**
+ * @brief A basic implementation of an Adapter for object JSON, which acts like
+ * a map<string, Adapter>. Implements the ObjectAdapter constraint.
+ *
+ * @tparam JSON The actual underlying json type
+ * @tparam Adapter The concrete implementation class for the Adapter. Used to
+ * proxy the mapped_type of the underlying JSON Object.
+ */
 template <typename JSON, typename Adapter = AdapterFor<JSON>> class SimpleObjectAdapter {
 public:
   using underlying_iterator_t = decltype(std::declval<JSON>().begin());
@@ -21,7 +29,7 @@ public:
   size_t size() const { return value_ ? value_->size() : 0; }
 
   const_iterator find(std::string const & key) const {
-    return std::find_if(begin(), end(), [key](auto const & kv) { return kv.first == key; });
+    return std::find_if(begin(), end(), [&key](auto const & kv) { return kv.first == key; });
   }
 
   bool contains(std::string const & key) const { return find(key) != end(); }
@@ -50,6 +58,14 @@ private:
   JSON * value_;
 };
 
+/**
+ * @brief A basic implementation of an Adapter for array JSON, which acts like
+ * a vector<Adapter>. Implements the ArrayAdapter constraint.
+ *
+ * @tparam JSON The actual underlying json type
+ * @tparam Adapter The concrete implementation class for the Adapter. Used to
+ * proxy the mapped_type of the underlying JSON Array.
+ */
 template <typename JSON, typename Adapter = AdapterFor<JSON>> class SimpleArrayAdapter {
 public:
   using underlying_iterator_t = decltype(std::declval<JSON>().begin());
@@ -87,6 +103,32 @@ private:
   JSON * value_;
 };
 
+/**
+ * @brief A basic implementation of an Adapter, for any JSON type that
+ * implements standard components.
+ *
+ * Provides final implementations of the virtual methods of
+ * jvalidate::detail::Adapter:
+ * - apply_array(AdapterCallback)        -> Status
+ * - array_size()                        -> size_t
+ * - apply_object(ObjectAdapterCallback) -> Status
+ * - object_size()                       -> size_t
+ * - equals(Adapter, bool)               -> bool
+ * - freeze()                            -> unique_ptr<Const const>
+ *
+ * Fulfills the constraint jvalidate::Adapter by providing default
+ * implementations of:
+ * - as_array()                          -> SimpleArrayAdapter<JSON>
+ * - as_object()                         -> SimpleObjectAdapter<JSON>
+ *
+ * Fulfills one third of the constraint jvalidate::MutableAdapter, iff CRTP
+ * implements the other two constraints.
+ * - assign(Const)                       -> void
+ *
+ * @tparam JSON The actual underlying json type
+ * @tparam CRTP The concrete implementation class below this, using the
+ * "Curiously Recursive Template Pattern".
+ */
 template <typename JSON, typename CRTP = AdapterFor<JSON>> class SimpleAdapter : public Adapter {
 public:
   static constexpr bool is_mutable =
@@ -131,7 +173,9 @@ public:
     return std::make_unique<GenericConst<value_type>>(const_value());
   }
 
-  void assign(Const const & frozen) const requires(is_mutable) {
+  void assign(Const const & frozen) const
+    requires(is_mutable)
+  {
     EXPECT_M(value_ != nullptr, "Failed to create a new element in parent object");
     if (auto * this_frozen = dynamic_cast<GenericConst<value_type> const *>(&frozen)) {
       *value_ = this_frozen->value();
@@ -144,11 +188,15 @@ public:
     });
   }
 
-  auto operator<=>(SimpleAdapter const & rhs) const requires std::totally_ordered<JSON> {
+  auto operator<=>(SimpleAdapter const & rhs) const
+    requires std::totally_ordered<JSON>
+  {
     using ord = std::strong_ordering;
+    // Optimization - first we compare pointers
     if (value_ == rhs.value_) {
       return ord::equal;
     }
+    // TODO: Can I implement this as `return *value_ <=> *rhs.value_`?
     if (value_ && rhs.value_) {
       if (*value_ < *rhs.value_) {
         return ord::less;
@@ -158,6 +206,7 @@ public:
       }
       return ord::equal;
     }
+    // Treat JSON(null) and nullptr as equivalent
     if (value_) {
       return type() == Type::Null ? ord::equivalent : ord::greater;
     }

+ 39 - 2
include/jvalidate/detail/string.h

@@ -1,3 +1,7 @@
+/**
+ * Utility functions for managing strings, specifically because C++'s
+ * std::string/std::regex is not well suited for UTF8 comprehensions.
+ */
 #pragma once
 
 #if __has_include(<unicode/std_string.h>)
@@ -5,8 +9,19 @@
 #include <unicode/brkiter.h>
 #include <unicode/unistr.h>
 #endif
+#include <iostream>
 
 namespace jvalidate::detail {
+/**
+ * @brief Calclates the string-length of the argument, treating multi-byte
+ * characters an unicode graphemes as single characters (which std::string
+ * cannot do).
+ *
+ * @param arg Any UTF8 compatible string (including a standard ASCII string)
+ *
+ * @returns A number no greater than arg.size(), depending on the number of
+ * graphemes/codepoints in the string.
+ */
 inline size_t length(std::string_view arg) {
 #ifdef JVALIDATE_HAS_ICU
   icu::UnicodeString ucs = icu::UnicodeString::fromUTF8(icu::StringPiece(arg));
@@ -16,17 +31,37 @@ inline size_t length(std::string_view arg) {
 #endif
 }
 
+/**
+ * @brief Ensures that any codepoints/graphemes in the given regular expression
+ * are wrapped in parenthesis in order to ensure that e.g. <PIRATE-EMOJI>*
+ * properly matches the entire emoji multiple times, instead of just the last
+ * byte of the string.
+ *
+ * Because we are only performing a regex search, and not matching/capturing
+ * groups - we don't care that all of these extra parenthesis cause us to
+ * generate new capture-groups or push some of the groups to a later point.
+ *
+ * @param arg A regular expression string, to be sanitized for UTF8 pattern-
+ * matching.
+ *
+ * @returns The regular expression, with some more parenthesis added.
+ */
 inline std::string regex_escape(std::string_view arg) {
 #ifdef JVALIDATE_HAS_ICU
   icu::UnicodeString const ucs = icu::UnicodeString::fromUTF8(icu::StringPiece(arg));
+  // Short-circuit if there are no multi-byte codepoints or graphemes, since
+  // C++ regexes don't have any problems with those.
   if (ucs.countChar32() == arg.size()) {
     return std::string(arg);
   }
 
   UErrorCode status = U_ZERO_ERROR;
+  // createCharacterInstance directly uses new - without any special allocation
+  // rules or cleanup, since the first argument is NULL.
   std::unique_ptr<icu::BreakIterator> iter(
       icu::BreakIterator::createCharacterInstance(NULL, status));
 
+  // This should never occur - unless there's like an alloc error
   if (U_FAILURE(status)) {
     return std::string(arg);
   }
@@ -36,14 +71,16 @@ inline std::string regex_escape(std::string_view arg) {
   int32_t start = iter->first();
   int32_t end = iter->next();
   while (end != icu::BreakIterator::DONE) {
+    // 0-or-1, 1-or-more, 0-or-more markings
+    // This could be optimized to only operate when on a multibyte character
     if (std::strchr("?*+", ucs.charAt(end))) {
       rval.append('(');
-      rval.append(ucs, start, end - 1);
+      rval.append(ucs, start, end - start);
       rval.append(')');
       rval.append(ucs.char32At(end));
       end = iter->next();
     } else {
-      rval.append(ucs, start, end - 1);
+      rval.append(ucs, start, end - start);
     }
     start = end;
     end = iter->next();

+ 77 - 0
include/jvalidate/detail/string_adapter.h

@@ -0,0 +1,77 @@
+#pragma once
+
+#include <map>
+#include <stdexcept>
+#include <string_view>
+#include <vector>
+
+#include <jvalidate/adapter.h>
+#include <jvalidate/detail/number.h>
+#include <jvalidate/detail/simple_adapter.h>
+#include <jvalidate/enum.h>
+#include <jvalidate/status.h>
+
+namespace jvalidate::detail {
+template <typename CRTP> class UnsupportedArrayAdapter {
+public:
+  size_t size() const { return 0; }
+  CRTP operator[](size_t) const { throw std::runtime_error("stub implementation"); }
+  std::vector<CRTP>::const_iterator begin() const { return {}; }
+  std::vector<CRTP>::const_iterator end() const { return {}; }
+};
+
+template <typename CRTP> class UnsupportedObjectAdapter {
+public:
+  size_t size() const { return 0; }
+  bool contains(std::string_view) const { return false; }
+  CRTP operator[](std::string_view) const { throw std::runtime_error("stub implementation"); }
+  std::map<std::string, CRTP>::const_iterator begin() const { return {}; }
+  std::map<std::string, CRTP>::const_iterator end() const { return {}; }
+};
+
+class StringAdapter final : public adapter::Adapter {
+public:
+  using value_type = std::string_view;
+
+  StringAdapter(std::string_view value) : value_(value) {}
+
+  adapter::Type type() const { return adapter::Type::String; }
+  bool as_boolean() const { die("boolean"); }
+  int64_t as_integer() const { die("integer"); }
+  double as_number() const { die("number"); }
+  std::string as_string() const { return std::string(value_); }
+
+  size_t array_size() const { die("array"); }
+  UnsupportedArrayAdapter<StringAdapter> as_array() const { die("array"); }
+  Status apply_array(adapter::AdapterCallback const &) const { return Status::Noop; }
+
+  size_t object_size() const { die("object"); }
+  UnsupportedObjectAdapter<StringAdapter> as_object() const { die("object"); }
+  Status apply_object(adapter::ObjectAdapterCallback const &) const { return Status::Noop; }
+
+  bool equals(adapter::Adapter const & rhs, bool strict) const {
+    if (std::optional str = rhs.maybe_string(strict)) {
+      return str == value_;
+    }
+    return false;
+  }
+
+  std::unique_ptr<adapter::Const const> freeze() const final {
+    return std::make_unique<adapter::detail::GenericConst<std::string_view>>(value_);
+  }
+
+private:
+  [[noreturn]] static void die(std::string expected) {
+    throw std::runtime_error("StringAdapter is not an " + expected);
+  }
+
+private:
+  std::string_view value_;
+};
+
+}
+
+template <> struct jvalidate::adapter::AdapterTraits<std::string_view> {
+  template <typename> using Adapter = jvalidate::detail::StringAdapter;
+  using ConstAdapter = jvalidate::detail::StringAdapter;
+};

+ 101 - 0
include/jvalidate/detail/tribool.h

@@ -0,0 +1,101 @@
+#pragma once
+
+#include <compare>
+
+/**
+ * @brief Generator-macro for creating instant tri-bools, a boolean-like type
+ * that has a "True" state, a "False" state, and a "Maybe"/"Indeterminate"
+ * state. {@see boost::tribool} for an example of this functionality.
+ *
+ * TriBool types obey the following rules of behavior (with T := True,
+ * F := False, M := Maybe):
+ *
+ * Unary operators operate as follows:
+ * | op \ in | T | F | M |
+ * |---------|---|---|---|
+ * |  bool() | T | F | T |
+ * |       ! | F | T | M |
+ * |---------|---|---|---|
+ *
+ * AND operates as follows:
+ * |   | T | F | M |
+ * |---|---|---|---|
+ * | T | T | F | T |
+ * | F | F | F | F |
+ * | M | T | F | M |
+ * |---|---|---|---|
+ *
+ * OR operates as follows:
+ * |   | T | F | M |
+ * |---|---|---|---|
+ * | T | T | T | T |
+ * | F | T | F | M |
+ * | M | T | M | M |
+ * |---|---|---|---|
+ *
+ * @param TypeName the name of the class being declared
+ * @param True the name of the truthy enumeration
+ * @param False the name of the falsy enumeration
+ * @param Maybe the name of the indeterminate enumeration
+ */
+#define JVALIDATE_TRIBOOL_TYPE(TypeName, True, False, Maybe)                                       \
+  class TypeName {                                                                                 \
+  public:                                                                                          \
+    enum Enum { True, False, Maybe };                                                              \
+                                                                                                   \
+  private:                                                                                         \
+    Enum state_;                                                                                   \
+                                                                                                   \
+  public:                                                                                          \
+    /* Translate a boolean into a tribool value, will never be Maybe */                            \
+    constexpr TypeName(bool state) : state_(state ? True : False) {}                               \
+    constexpr TypeName(Enum state) : state_(state) {}                                              \
+                                                                                                   \
+    /* Convert to enum for use in switch() statements */                                           \
+    constexpr Enum operator*() const { return state_; }                                            \
+    /* Convert to bool for use in if()/while() statements, requires static_cast otherwise */       \
+    constexpr explicit operator bool() const { return state_ != False; }                           \
+                                                                                                   \
+    /* Inverts the tribool's value if it is already a concrete boolean type */                     \
+    friend constexpr TypeName operator!(TypeName val) {                                            \
+      if (val.state_ == Maybe) {                                                                   \
+        return Maybe;                                                                              \
+      }                                                                                            \
+      return val.state_ == False ? True : False;                                                   \
+    }                                                                                              \
+                                                                                                   \
+    /* Combines two tribools as if performing boolean-OR */                                        \
+    friend constexpr TypeName operator|(TypeName lhs, TypeName rhs) {                              \
+      if (lhs.state_ == True || rhs.state_ == True) {                                              \
+        return True;                                                                               \
+      }                                                                                            \
+      if (lhs.state_ == Maybe || rhs.state_ == Maybe) {                                            \
+        return Maybe;                                                                              \
+      }                                                                                            \
+      return False;                                                                                \
+    }                                                                                              \
+                                                                                                   \
+    /* Combines two tribools as if performing boolean-AND */                                       \
+    friend constexpr TypeName operator&(TypeName lhs, TypeName rhs) {                              \
+      if (lhs.state_ == False || rhs.state_ == False) {                                            \
+        return False;                                                                              \
+      }                                                                                            \
+      if (lhs.state_ == Maybe && rhs.state_ == Maybe) {                                            \
+        return Maybe;                                                                              \
+      }                                                                                            \
+      return True;                                                                                 \
+    }                                                                                              \
+                                                                                                   \
+    constexpr TypeName & operator&=(TypeName rhs) { return *this = *this & rhs; }                  \
+    constexpr TypeName & operator|=(TypeName rhs) { return *this = *this | rhs; }                  \
+                                                                                                   \
+    friend constexpr auto operator==(TypeName lhs, TypeName::Enum rhs) {                           \
+      return static_cast<int>(lhs.state_) == static_cast<int>(rhs);                                \
+    }                                                                                              \
+    friend constexpr auto operator!=(TypeName lhs, TypeName::Enum rhs) {                           \
+      return static_cast<int>(lhs.state_) != static_cast<int>(rhs);                                \
+    }                                                                                              \
+    friend constexpr auto operator<=>(TypeName lhs, TypeName rhs) {                                \
+      return static_cast<int>(lhs.state_) <=> static_cast<int>(rhs.state_);                        \
+    }                                                                                              \
+  }

+ 86 - 46
include/jvalidate/detail/vocabulary.h

@@ -6,59 +6,45 @@
 
 #include <jvalidate/enum.h>
 #include <jvalidate/forward.h>
+#include <jvalidate/vocabulary.h>
 
 namespace jvalidate::detail {
 template <Adapter A> struct ParserContext;
-template <Adapter A> class Vocabulary {
-public:
-  friend class ConstraintFactory<A>;
-  using pConstraint = std::unique_ptr<constraint::Constraint>;
-  using MakeConstraint = std::function<pConstraint(ParserContext<A> const &)>;
 
+template <Adapter A> class Vocabulary {
 private:
   schema::Version version_;
-  std::unordered_map<std::string_view, MakeConstraint> make_;
+  std::unordered_map<std::string_view, vocabulary::Metadata<A>> metadata_;
   std::unordered_set<std::string_view> permitted_;
-
-  // TODO(samjaffe): Migrate this back to constraintsfactory
-  std::unordered_set<std::string_view> keywords_{"$defs",
-                                                 "additionalItems",
-                                                 "additionalProperties",
-                                                 "allOf",
-                                                 "anyOf",
-                                                 "definitions",
-                                                 "dependencies",
-                                                 "dependentSchemas",
-                                                 "else",
-                                                 "extends",
-                                                 "if",
-                                                 "items",
-                                                 "not",
-                                                 "oneOf",
-                                                 "patternProperties",
-                                                 "prefixItems",
-                                                 "properties",
-                                                 "then",
-                                                 "unevaluatedItems",
-                                                 "unevaluatedProperties"};
-  std::unordered_set<std::string_view> property_keywords_{
-      "$defs",     "definitions", "dependencies", "dependentSchemas", "patternProperties",
-      "properties"};
-  std::unordered_set<std::string_view> post_constraints_{"unevaluatedItems",
-                                                         "unevaluatedProperties"};
+  std::unordered_set<std::string> vocabularies_;
 
 public:
   Vocabulary() = default;
-  Vocabulary(schema::Version version, std::unordered_map<std::string_view, MakeConstraint> make)
-      : version_(version), make_(std::move(make)) {
-    for (auto const & [keyword, _] : make_) {
+  Vocabulary(schema::Version version,
+             std::unordered_map<std::string_view, vocabulary::Metadata<A>> metadata)
+      : version_(version), metadata_(std::move(metadata)) {
+    for (auto const & [keyword, _] : metadata_) {
       permitted_.emplace(keyword);
     }
   }
 
-  void restrict(std::unordered_set<std::string> const & permitted_keywords) & {
+  /**
+   * @brief Reset the list of keywords that Vocabulary actually respects
+   *
+   * @param permitted_keywords The selection of keywords to allow for
+   * searches/constraint building. Note that a constraint might be
+   * registered to a null function for compatibility with this.
+   *
+   * @param vocabularies An optional selection of vocabulary schemas, used
+   * as metadata, and deducing {@see is_format_assertion}.
+   */
+  void restrict(std::unordered_set<std::string> const & permitted_keywords,
+                std::unordered_set<std::string> const & vocabularies = {}) & {
     permitted_.clear();
-    for (auto const & [keyword, _] : make_) {
+    vocabularies_ = vocabularies;
+    for (auto const & [keyword, _] : metadata_) {
+      // We only file permitted_keywords into this Vocabulary if we have defined
+      // bindings for that keyword
       if (permitted_keywords.contains(std::string(keyword))) {
         permitted_.insert(keyword);
       }
@@ -67,29 +53,83 @@ public:
 
   schema::Version version() const { return version_; }
 
+  bool is_format_assertion() const {
+    // In Draft07 and prior - format assertions were considered enabled by
+    // default. This is - of course - problematic because very few
+    // implementations actually had full support for format constraints.
+    if (version_ < schema::Version::Draft2019_09) {
+      return true;
+    }
+
+    // Some implementations wouldn't even bother with format constraints, and
+    // others would provide implementations that either missed a number of edge
+    // cases or were flat-out wrong on certain matters.
+    // Therefore - starting in Draft 2019-09, the format keyword is an
+    // annotation by default, instead of an assertion.
+    if (version_ == schema::Version::Draft2019_09) {
+      return vocabularies_.contains("/vocab/format");
+    }
+
+    // Draft 2020-12 makes this even more explicit - having separate vocabulary
+    // documents for "format as assertion" and "format as annotation". Allowing
+    // validators to add format constraints that are only used for annotating
+    // results.
+    return vocabularies_.contains("/vocab/format-assertion");
+  }
+
   /**
    * @brief Is the given "key"word actually a keyword? As in, would
-   * I expect to resolve a constraint out of it.
+   * I expect to resolve a constraint out of it. This is a slightly more
+   * lenient version of {@see is_constraint} - since it allows keywords that
+   * have a null factory, as long as they've been registered (e.g. then/else).
+   *
+   * @param word The "key"word being looked up (e.g. "if", "properties", ...)
    */
   bool is_keyword(std::string_view word) const {
-    return permitted_.contains(word) && make_.contains(word) && keywords_.contains(word);
+    return has(word) && metadata_.at(word).is_keyword;
   }
 
   /**
    * @brief Does the given "key"word represent a property object - that is to
    * say, an object containing some number of schemas mapped by arbitrary keys
+   *
+   * @param word The "key"word being looked up (e.g. "if", "properties", ...)
    */
   bool is_property_keyword(std::string_view word) const {
-    return is_keyword(word) && property_keywords_.contains(word);
+    return has(word) && metadata_.at(word).is_keyword_map;
   }
 
-  bool is_constraint(std::string_view word) const {
-    return permitted_.contains(word) && make_.contains(word) && make_.at(word);
-  }
+  /**
+   * @brief Is the given word a real constraint in the Vocabulary. In essence,
+   * it must be an enabled keyword AND it must have a non-null factory function.
+   *
+   * @param word The "key"word being looked up (e.g. "if", "properties", ...)
+   */
+  bool is_constraint(std::string_view word) const { return has(word) && metadata_.at(word).make; }
 
+  /**
+   * @brief Fabricate the given constraint if real from the current context
+   *
+   * @param word The "key"word being looked up (e.g. "if", "properties", ...)
+   *
+   * @param context The current context of schema parsing, used for re-entrancy.
+   *
+   * @returns A pair whose first element is either a pointer to a constraint
+   * (if word represents a supported constraint AND the constraint resolves to
+   * something meaningful), else null.
+   *
+   * The second element is a boolean indicating if the constraint needs to be
+   * evaluated after other constraints to use their tracking/annotations.
+   * See the above comments on s_post_constraints for more info.
+   */
   auto constraint(std::string_view word, ParserContext<A> const & context) const {
-    return std::make_pair(is_constraint(word) ? make_.at(word)(context) : nullptr,
-                          post_constraints_.contains(word));
+    return std::make_pair(is_constraint(word) ? metadata_.at(word).make(context) : nullptr,
+                          has(word) && metadata_.at(word).is_post_constraint);
+  }
+
+private:
+  bool has(std::string_view word) const {
+    return permitted_.contains(word) && metadata_.contains(word);
   }
 };
 }

+ 42 - 0
include/jvalidate/document_cache.h

@@ -8,6 +8,22 @@
 #include <jvalidate/uri.h>
 
 namespace jvalidate {
+/**
+ * @brief An Adapter-specific owning cache of documents that we need to load
+ * from an external resource. Because Adapter objects do not actually own the
+ * JSON objects that they wrap, we need some method of holding them in cache
+ * to prevent any use-after-free issues.
+ *
+ * As you can see from the constructor chain of {@see jvalidate::Schema},
+ * the user can either provide their own DocumentCache, which can then be shared
+ * between multiple root Schemas. Alternatively, they can provide a URIResolver,
+ * which is a function that takes a URI as input, a JSON as an out-parameter,
+ * and returns a boolean indicating success.
+ * If the URIResolver is provided, then we automatically construct a temporary
+ * DocumentCache around it for use in building the Schema. If neither the
+ * URIResolver nor a DocumentCache are provided, then we will be unable to
+ * resolve any external documents (even those on-disk).
+ */
 template <Adapter A> class DocumentCache {
 public:
   using value_type = typename A::value_type;
@@ -17,18 +33,44 @@ private:
   std::map<URI, value_type> cache_;
 
 public:
+  /**
+   * @brief Constructs an empty (read: cannot resolve anything) cache for
+   * external documents. Because there is no URIResolver, this object will
+   * always return a nullopt when trying to load anything.
+   */
   DocumentCache() = default;
+  /**
+   * @brief Construct a new DocumentCache from the given URIResolver function/
+   * function-object.
+   *
+   * @param resolve A function that consumes a URI and returns a boolean status
+   * code and concrete JSON object that can be stored in the Adapter type A as
+   * an out-parameter.
+   * This function is under no oblications to load any specific schemes from
+   * input URIs, so it is necessary to think carefully about the domain of
+   * schema references that you will be working on when implementing it.
+   * @see tests/selfvalidate_test.cxx#load_external_for_test for an example
+   * supporting http requests with libcurl and file requests with fstreams.
+   */
   DocumentCache(URIResolver<A> const & resolve) : resolve_(resolve) {}
 
   operator bool() const { return resolve_; }
 
   std::optional<A> try_load(URI const & uri) {
+    // Short circuit - without a URIResolver, we can always return nullopt,
+    // because this library doesn't promise to know how to load external
+    // schemas from any source (including files).
     if (not resolve_) {
       return std::nullopt;
     }
 
     auto [it, created] = cache_.try_emplace(uri);
     if (created && not resolve_(uri, it->second)) {
+      // Doing it this way skips out on a move operation for the JSON object,
+      // which could be useful if someone is using a legacy JSON object type.
+      // Since std::map promises stability we don't need to concern ourselves
+      // with reference invalidation even in a multi-threaded context - although
+      // this code is not threadsafe.
       cache_.erase(it);
       return std::nullopt;
     }

+ 63 - 0
include/jvalidate/extension.h

@@ -0,0 +1,63 @@
+#pragma once
+
+#include <type_traits>
+
+#include <jvalidate/constraint/extension_constraint.h>
+#include <jvalidate/forward.h>
+#include <jvalidate/status.h>
+
+namespace jvalidate::extension {
+struct VisitorBase {
+  virtual ~VisitorBase() = default;
+};
+
+template <typename E>
+concept Constraint = std::is_base_of_v<constraint::ExtensionConstraint::Impl, E>;
+
+namespace detail {
+template <Constraint E> struct TypedVisitor : VisitorBase {
+  virtual Status visit(E const & cons) const = 0;
+};
+
+template <Constraint E, typename CRTP> struct TypedVisitorImpl : TypedVisitor<E> {
+  Status visit(E const & cons) const final {
+    return static_cast<CRTP const *>(this)->dispatch(cons);
+  }
+};
+}
+
+template <typename CRTP> struct ConstraintBase : constraint::ExtensionConstraint::Impl {
+  Status visit(VisitorBase const & visitor) const final {
+    return dynamic_cast<detail::TypedVisitor<CRTP> const &>(visitor).visit(
+        static_cast<CRTP const &>(*this));
+  }
+};
+
+template <typename CRTP, typename... Es> class Visitor {
+private:
+  template <Adapter A, typename V> class Impl : public detail::TypedVisitorImpl<Es, Impl<A, V>>... {
+  public:
+    Impl(Visitor const * self, A const & document, V const & visitor)
+        : self_(self), document_(document), visitor_(visitor) {}
+
+    using detail::TypedVisitorImpl<Es, Impl>::visit...;
+
+    template <Constraint E> Status dispatch(E const & cons) const {
+      // static_assert(Visitable<CRTP, E, A, V>, "Must implement all visitation functions");
+      return static_cast<CRTP const *>(self_)->visit(cons, document_, visitor_);
+    }
+
+  private:
+    Visitor const * self_;
+    A const & document_;
+    V const & visitor_;
+  };
+
+public:
+  template <Adapter A, typename V>
+  Status operator()(constraint::ExtensionConstraint const & cons, A const & document,
+                    V const & visitor) const {
+    return cons.pimpl->visit(Impl<A, V>{this, document, visitor});
+  }
+};
+}

+ 107 - 61
include/jvalidate/forward.h

@@ -3,6 +3,14 @@
 #include <functional>
 #include <string>
 #include <type_traits>
+#include <variant>
+
+#define DISCARD1_IMPL(_, ...) __VA_ARGS__
+#define DISCARD1(...) DISCARD1_IMPL(__VA_ARGS__)
+
+#define FORWARD_DECLARE_STRUCT(TYPE) struct TYPE;
+
+#define COMMA_NAME(X) , X
 
 namespace jvalidate {
 class Schema;
@@ -26,66 +34,70 @@ template <typename V> struct AdapterTraits<V const> : AdapterTraits<V> {};
 template <typename JSON> using AdapterFor = typename AdapterTraits<JSON>::template Adapter<JSON>;
 }
 
-namespace jvalidate::constraint {
-class ConstraintVisitor;
-class Constraint;
-template <typename> class SimpleConstraint;
-class ExtensionConstraint;
-
-class TypeConstraint;
-class EnumConstraint;
-class AllOfConstraint;
-class AnyOfConstraint;
-class OneOfConstraint;
-class NotConstraint;
-class ConditionalConstraint;
-
-class MaximumConstraint;
-class MinimumConstraint;
-class MultipleOfConstraint;
-
-class MaxLengthConstraint;
-class MinLengthConstraint;
-class PatternConstraint;
-class FormatConstraint;
-
-class AdditionalItemsConstraint;
-class ContainsConstraint;
-class MaxItemsConstraint;
-class MinItemsConstraint;
-class TupleConstraint;
-class UnevaluatedItemsConstraint;
-class UniqueItemsConstraint;
-
-class AdditionalPropertiesConstraint;
-class DependenciesConstraint;
-class MaxPropertiesConstraint;
-class MinPropertiesConstraint;
-class PatternPropertiesConstraint;
-class PropertiesConstraint;
-class PropertyNamesConstraint;
-class RequiredConstraint;
-class UnevaluatedPropertiesConstraint;
-}
-
 namespace jvalidate::schema {
 enum class Version : int;
 class Node;
 }
 
+namespace jvalidate::constraint {
+#define CONSTRAINT_IMPLEMENTATION_LIST(X)                                                          \
+  /* General Constraints - See jvalidate/constraint/general_constraint.h */                        \
+  X(TypeConstraint)                                                                                \
+  X(EnumConstraint)                                                                                \
+  X(AllOfConstraint)                                                                               \
+  X(AnyOfConstraint)                                                                               \
+  X(OneOfConstraint)                                                                               \
+  X(NotConstraint)                                                                                 \
+  X(ConditionalConstraint)                                                                         \
+  /* Number Constraints - See jvalidate/constraint/number_constraint.h */                          \
+  X(MaximumConstraint)                                                                             \
+  X(MinimumConstraint)                                                                             \
+  X(MultipleOfConstraint)                                                                          \
+  /* String Constraints - See jvalidate/constraint/string_constraint.h */                          \
+  X(MaxLengthConstraint)                                                                           \
+  X(MinLengthConstraint)                                                                           \
+  X(PatternConstraint)                                                                             \
+  X(FormatConstraint)                                                                              \
+  /* Array Constraints - See jvalidate/constraint/array_constraint.h */                            \
+  X(AdditionalItemsConstraint)                                                                     \
+  X(ContainsConstraint)                                                                            \
+  X(MaxItemsConstraint)                                                                            \
+  X(MinItemsConstraint)                                                                            \
+  X(TupleConstraint)                                                                               \
+  X(UnevaluatedItemsConstraint)                                                                    \
+  X(UniqueItemsConstraint)                                                                         \
+  /* Object Constraints - See jvalidate/constraint/object_constraint.h */                          \
+  X(AdditionalPropertiesConstraint)                                                                \
+  X(DependenciesConstraint)                                                                        \
+  X(MaxPropertiesConstraint)                                                                       \
+  X(MinPropertiesConstraint)                                                                       \
+  X(PatternPropertiesConstraint)                                                                   \
+  X(PropertiesConstraint)                                                                          \
+  X(PropertyNamesConstraint)                                                                       \
+  X(RequiredConstraint)                                                                            \
+  X(UnevaluatedPropertiesConstraint)                                                               \
+  /* ExtensionConstraint - A special constraint for all user-defined constraints */                \
+  X(ExtensionConstraint)
+
+CONSTRAINT_IMPLEMENTATION_LIST(FORWARD_DECLARE_STRUCT);
+
+using Constraint = std::variant<DISCARD1(CONSTRAINT_IMPLEMENTATION_LIST(COMMA_NAME))>;
+using SubConstraint = std::variant<schema::Node const *, std::unique_ptr<Constraint>>;
+}
+
 namespace jvalidate {
 template <typename It>
-concept ArrayIterator = std::forward_iterator<It> and std::is_default_constructible_v<It> and
-    requires(It const it) {
-  { *it } -> std::convertible_to<adapter::Adapter const &>;
-};
+concept ArrayIterator =
+    std::forward_iterator<It> and std::is_default_constructible_v<It> and requires(It const it) {
+      { *it } -> std::convertible_to<adapter::Adapter const &>;
+    };
 
 template <typename It>
-concept ObjectIterator = std::forward_iterator<It> and std::is_default_constructible_v<It> and
-    requires(It const it) {
-  { it->first } -> std::convertible_to<std::string_view>;
-  { it->second } -> std::convertible_to<adapter::Adapter const &>;
-};
+concept ObjectIterator =
+    std::forward_iterator<It> and std::is_default_constructible_v<It> and requires(It const it) {
+      { it->first } -> std::convertible_to<std::string_view>;
+      { it->second } -> std::convertible_to<adapter::Adapter const &>;
+    };
 
 template <typename A>
 concept ArrayAdapter = requires(A const a) {
@@ -104,6 +116,16 @@ concept ObjectAdapter = requires(A const a) {
   { a.end() } -> ObjectIterator;
 };
 
+/**
+ * @brief A concept representing the actual adapter form. Used in most code
+ * that actually cares to interact with JSON, instead of having to define a
+ * template like AdapterInterface<ArrayAdapter, ObjectAdapter> for the basic
+ * adapter, or return pointers-to-interfaces in order to access array/object
+ * data.
+ *
+ * This concept functionally defines an Adapter being an AdapterInterface with
+ * an as_array and as_object method added on.
+ */
 template <typename A>
 concept Adapter = std::is_base_of_v<adapter::Adapter, A> && requires(A const a) {
   typename A::value_type;
@@ -121,32 +143,56 @@ concept Adapter = std::is_base_of_v<adapter::Adapter, A> && requires(A const a)
 
 template <typename A>
 concept MutableObject = ObjectAdapter<A> && requires(A const a) {
-  {a.assign("", std::declval<adapter::Const>())};
+  { a.assign("", std::declval<adapter::Const>()) };
 };
 
+/**
+ * @brief An extension of Adapter that is capable of altering the underlying
+ * JSON node. In general, that means that a non-const ref is captured when
+ * building an Adapter.
+ *
+ * Requires that we can assign both to "this", and to values at a key (in the
+ * case of object type).
+ * Implies that as_array is also a wrapper for mutable JSON.
+ */
 template <typename A>
 concept MutableAdapter = Adapter<A> && requires(A const a) {
-  {a.assign(std::declval<adapter::Const>())};
-  {a.assign(std::declval<adapter::Adapter>())};
+  { a.assign(std::declval<adapter::Const>()) };
+  { a.assign(std::declval<adapter::Adapter>()) };
   { a.as_object() } -> MutableObject;
 };
 
 template <typename R>
-concept RegexEngine = std::constructible_from<std::string> && requires(R const regex) {
-  { regex.search("") } -> std::same_as<bool>;
+concept RegexEngine = requires(R & regex) {
+  { regex.search("" /* pattern */, "" /* text */) } -> std::same_as<bool>;
+};
+
+template <typename E, typename A, typename B, typename V>
+concept Visitable = Adapter<A> && requires(V & v, E const & c, A const & doc, B const & base) {
+  { v.visit(c, doc, base) } -> std::same_as<Status>;
 };
+
+template <typename T, typename S>
+concept Not = not std::is_same_v<std::decay_t<T>, std::decay_t<S>>;
 }
 
 namespace jvalidate {
 template <Adapter A> class ConstraintFactory;
 template <Adapter A> class DocumentCache;
-template <Adapter A, RegexEngine RE> class ValidationVisitor;
 
-template <RegexEngine RE> class ValidatorT;
-class Validator;
+template <RegexEngine RE, typename ExtensionVisitor> class ValidationVisitor;
+
+template <RegexEngine RE, typename ExtensionVisitor> class Validator;
 
 template <Adapter A> using URIResolver = bool (*)(URI const &, typename A::value_type &);
+}
 
-template <typename T, typename S>
-concept Not = not std::is_convertible_v<std::decay_t<S>, T>;
+namespace jvalidate::extension {
+struct VisitorBase;
+template <typename CRTP, typename... Es> class Visitor;
 }
+
+#undef FORWARD_DECLARE_STRUCT
+#undef COMMA_NAME
+#undef DISCARD1_IMPL
+#undef DISCARD1

+ 110 - 1
include/jvalidate/schema.h

@@ -19,19 +19,48 @@
 #include <jvalidate/forward.h>
 
 namespace jvalidate::schema {
+/**
+ * @brief The real "Schema" class, representing a resolved node in a schema
+ * object. Each node is analogous to one layer of the schema json, and can
+ * represent either a "rejects all" schema, an "accepts all" schema, or a
+ * schema that has some selection of constraints and other features.
+ */
 class Node {
 private:
+  // Annotations for this schema...
   std::string description_;
+
+  // The default value to apply to an object if if does not exist - is invoked
+  // by the parent schema node, rather than this node itself.
   std::unique_ptr<adapter::Const const> default_{nullptr};
 
+  // Rejects-all can provide a custom reason under some circumstances.
   std::optional<std::string> rejects_all_;
+
+  // Actual constraint information
   std::optional<schema::Node const *> reference_{};
   std::unordered_map<std::string, std::unique_ptr<constraint::Constraint>> constraints_{};
   std::unordered_map<std::string, std::unique_ptr<constraint::Constraint>> post_constraints_{};
 
 public:
   Node() = default;
+  /**
+   * @brief Construct a schema that rejects all values, with a custom reason
+   *
+   * @param A user-safe justification of why this schema rejects everything.
+   * Depending on the compiler settings, this might be used to indicate things
+   * such as attempting to load a non-existant schema.
+   */
   Node(std::string const & rejection_reason) : rejects_all_(rejection_reason) {}
+
+  /**
+   * @brief Actually initialize this schema node. Unfortunately, we cannot use
+   * RAII for initializing this object because of certain optimizations and
+   * guardrails make reference captures breakable.
+   *
+   * @param context The currently operating context, including the actual JSON
+   * document being parsed at this moment.
+   */
   template <Adapter A> void construct(detail::ParserContext<A> context);
 
   bool is_pure_reference() const {
@@ -50,7 +79,30 @@ public:
   adapter::Const const * default_value() const { return default_.get(); }
 
 private:
+  /**
+   * @brief Resolve any dynamic anchors that are children of the current schema
+   * (if this is the root node of a schema). If it is not a root node (does not
+   * define "$id"), then this function does nothing.
+   *
+   * @tparam A The Adapter type for the JSON being worked with.
+   *
+   * @param context The currently operating context, including the actual JSON
+   * document being parsed at this moment.
+   *
+   * @returns If this is a root schema - a scope object to pop the dynamic scope
+   */
   template <Adapter A> detail::OnBlockExit resolve_anchor(detail::ParserContext<A> const & context);
+
+  /**
+   * @brief Resolves/embeds referenced schema information into this schema node.
+   *
+   * @tparam A The Adapter type for the JSON being worked with.
+   *
+   * @param context The currently operating context, including the actual JSON
+   * document being parsed at this moment.
+   *
+   * @returns true iff there was a reference tag to follow
+   */
   template <Adapter A> bool resolve_reference(detail::ParserContext<A> const & context);
 };
 }
@@ -158,15 +210,32 @@ public:
    * into an Adapter object to allow us to walk through it w/o specialization.
    */
   template <typename JSON, typename... Args>
+    requires(not Adapter<JSON>)
   explicit Schema(JSON const & json, Args &&... args)
       : Schema(adapter::AdapterFor<JSON const>(json), std::forward<Args>(args)...) {}
 
 private:
+  /**
+   * @brief Cache an alias to a given schema, without ownership. alias_cache_ is
+   * a many-to-one association.
+   * Syntactic sugar for "add pointer to map and return".
+   *
+   * @param where The key aliasing the schema, which may also be the original
+   * lexical key.
+   *
+   * @param schema The pointer to a schema being stored
+   */
   schema::Node const * alias(detail::Reference const & where, schema::Node const * schema) {
     alias_cache_.emplace(where, schema);
     return schema;
   }
 
+  /**
+   * @brief Syntactic sugar for finding a map value as an optional instead of an
+   * iterator that may be "end".
+   *
+   * @param ref The key being looked up
+   */
   std::optional<schema::Node const *> from_cache(detail::Reference const & ref) {
     if (auto it = alias_cache_.find(ref); it != alias_cache_.end()) {
       return it->second;
@@ -175,6 +244,25 @@ private:
     return std::nullopt;
   }
 
+  /**
+   * @brief Resolve a $ref/$dynamicRef tag and construct or load from cache the
+   * schema that is being pointed to.
+   *
+   * @param context All of the context information about the schema, importantly
+   * the location information, {@see jvalidate::detail::ReferenceManager}, and
+   * {@see jvalidate::detail::Vocabulary}.
+   *
+   * @param dynamic_reference Is this request coming from a "$dynamicRef"/
+   * "$recursiveRef" tag, or a regular "$ref" tag.
+   *
+   * @returns A schema node, that will also be stored in a local cache.
+   *
+   * @throws std::runtime_error if the reference is to an unloaded URI, and we
+   * fail to load it. If the preprocessor definition
+   * JVALIDATE_LOAD_FAILURE_AS_FALSE_SCHEMA is set, then we instead return an
+   * always-false schema with a custom error message. This is primarily for use
+   * in writing tests for JSON-Schema's selfvalidation test cases.
+   */
   template <Adapter A>
   schema::Node const * resolve(detail::Reference const & ref,
                                detail::ParserContext<A> const & context, bool dynamic_reference) {
@@ -185,14 +273,25 @@ private:
       return *cached;
     }
 
-    if (std::optional root = context.ref.load(lexical, context.vocab->version())) {
+    if (std::optional root = context.ref.load(lexical, context.vocab)) {
       return fetch_schema(context.rebind(*root, lexical, dynamic));
     }
 
     std::string error = "URIResolver could not resolve " + std::string(lexical.uri());
+#ifdef JVALIDATE_LOAD_FAILURE_AS_FALSE_SCHEMA
     return alias(dynamic, &cache_.try_emplace(dynamic, error).first->second);
+#else
+    JVALIDATE_THROW(std::runtime_error, error);
+#endif
   }
 
+  /**
+   * @brief Fetch from cache or create a new schema node from the given context,
+   * which may be the result of resolving a reference {@see Schema::resolve}, or
+   * simply loading a child schema via {@see ParserContext::node}.
+   *
+   * @param context The current operating context of the schema
+   */
   template <Adapter A> schema::Node const * fetch_schema(detail::ParserContext<A> const & context) {
     // TODO(samjaffe): No longer promises uniqueness - instead track unique URI's
     if (std::optional cached = from_cache(context.dynamic_where)) {
@@ -200,15 +299,23 @@ private:
     }
 
     adapter::Type const type = context.schema.type();
+    // Boolean schemas were made universally permitted in Draft06. Before then,
+    // you could only use them for specific keywords, like additionalProperties.
     if (type == adapter::Type::Boolean && context.vocab->version() >= schema::Version::Draft06) {
       return alias(context.dynamic_where, context.schema.as_boolean() ? &accept_ : &reject_);
     }
 
+    // If the schema is not universal accept/reject, then it MUST be an object
     EXPECT_M(type == adapter::Type::Object, "invalid schema at " << context.dynamic_where);
+    // The empty object is equivalent to true, but is permitted in prior drafts
     if (context.schema.object_size() == 0) {
       return alias(context.dynamic_where, &accept_);
     }
 
+    // Because of the below alias() expression, and the above from_cache
+    // expression, it shouldn't be possible for try_emplace to not create a new
+    // schema node. We keep the check in anyway just in case somehow things have
+    // gotten into a malformed state.
     auto [it, created] = cache_.try_emplace(context.dynamic_where);
     EXPECT_M(created, "creating duplicate schema at... " << context.dynamic_where);
 
@@ -256,6 +363,8 @@ template <Adapter A> bool Node::resolve_reference(detail::ParserContext<A> const
     return true;
   }
 
+  // Prior to Draft2019-09, "$ref" was the only way to reference another
+  // schema (ignoring Draft03's extends keyword, which was more like allOf)
   if (context.vocab->version() < Version::Draft2019_09) {
     return false;
   }

+ 2 - 54
include/jvalidate/status.h

@@ -1,59 +1,7 @@
 #pragma once
 
-#include <compare>
+#include <jvalidate/detail/tribool.h>
 
 namespace jvalidate {
-class Status {
-public:
-  enum Enum { Accept, Reject, Noop };
-
-private:
-  Enum state_;
-
-public:
-  Status(bool state) : state_(state ? Accept : Reject) {}
-  Status(Enum state) : state_(state) {}
-
-  explicit operator bool() const { return state_ != Reject; }
-
-  friend Status operator!(Status val) {
-    if (val.state_ == Noop) {
-      return Status::Noop;
-    }
-    return val.state_ == Reject ? Accept : Reject;
-  }
-
-  friend Status operator|(Status lhs, Status rhs) {
-    if (lhs.state_ == Noop && rhs.state_ == Noop) {
-      return Noop;
-    }
-    if (lhs.state_ == Accept || rhs.state_ == Accept) {
-      return Accept;
-    }
-    return Reject;
-  }
-
-  friend Status operator&(Status lhs, Status rhs) {
-    if (lhs.state_ == Noop && rhs.state_ == Noop) {
-      return Noop;
-    }
-    if (lhs.state_ == Reject || rhs.state_ == Reject) {
-      return Reject;
-    }
-    return Accept;
-  }
-
-  Status & operator&=(Status rhs) { return *this = *this & rhs; }
-  Status & operator|=(Status rhs) { return *this = *this | rhs; }
-
-  friend auto operator==(Status lhs, Status::Enum rhs) {
-    return static_cast<int>(lhs.state_) == static_cast<int>(rhs);
-  }
-  friend auto operator!=(Status lhs, Status::Enum rhs) {
-    return static_cast<int>(lhs.state_) != static_cast<int>(rhs);
-  }
-  friend auto operator<=>(Status lhs, Status rhs) {
-    return static_cast<int>(lhs.state_) <=> static_cast<int>(rhs.state_);
-  }
-};
+JVALIDATE_TRIBOOL_TYPE(Status, Accept, Reject, Noop);
 }

+ 64 - 1
include/jvalidate/uri.h

@@ -3,10 +3,25 @@
 #include <string>
 #include <string_view>
 
-#include <jvalidate/detail/compare.h>
+#include <jvalidate/compat/compare.h>
 #include <jvalidate/detail/expect.h>
 
 namespace jvalidate {
+/**
+ * @brief A subsection of the Uniform Resource Identifier (URI) syntax as per
+ * RFC 3986 (https://datatracker.ietf.org/doc/html/rfc3986).
+ *
+ * This URI structure supports file paths (either as relative paths or as
+ * file:// URIs), URNs (because they are covered by the JSON-Schema test suite),
+ * and HTTP/S urls (assigning the consumer the responsibility of handling e.g.
+ * params).
+ * Additionally - this URI implementation does not support fragment parts. This
+ * is because in the context of a JSON schema - a fragment part is treated as
+ * either an Anchor, or as a JSON-Pointer.
+ *
+ * Because of these limitations, it is safe to treat this URI type as a tuple of
+ * (scheme, resource) without the connecting "://" or ":" field.
+ */
 class URI {
 private:
   std::string uri_;
@@ -17,14 +32,23 @@ public:
   URI() = default;
 
   explicit URI(std::string_view uri) : uri_(uri) {
+    // Special handling for some parsing situations where we know that an object
+    // is a URI (and thus don't need to call Reference(text).uri()) - but that
+    // URI may or may not contain a trailing hash (fragment indicator). This is the
+    // case with the "$schema" field, for example. For any given draft, the schema
+    // writer can start with "http://" OR "https://", and might end with a "#".
     if (not uri_.empty() && uri_.back() == '#') {
       uri_.pop_back();
     }
 
+    // Locate file://, http://, and https:// schemes
     if (size_t n = uri_.find("://"); n != std::string::npos) {
       scheme_ = n;
       resource_ = n + 3;
     } else if (uri_.starts_with("urn:")) {
+      // Note that we skip over the first colon, because the format of a URN
+      // token is "urn:format:data" - and therefore we want the scheme to be
+      // "urn:format", with the resource element to be "data".
       n = uri_.find(':', 4);
       scheme_ = n;
       resource_ = scheme_ + 1;
@@ -34,13 +58,52 @@ public:
   URI parent() const { return URI(std::string_view(uri_).substr(0, uri_.rfind('/'))); }
   URI root() const { return URI(std::string_view(uri_).substr(0, uri_.find('/', resource_))); }
 
+  /**
+   * @brief "Concatenate" two URIs together. Most of the logic behind this
+   * is done in {@see ReferenceManager}, rather than this class/function.
+   * Included below are some example use-cases:
+   *
+   * "file://A/B/C" / "D.json" => "file:/A/B/C/D.json"
+   * "http://example.com/foo" / "bar/baz.json" =>
+   *    "http://example.com/foo/bar/baz.json"
+   * "http://example.com/foo" / "/bar/baz.json" =>
+   *    "http://example.com/bar/baz.json" (notice that we lost foo)
+   *
+   * Note that example 3 is not achieved through this function, but through code
+   * in ReferenceManager that says something like:
+   * @code{.cpp}
+   * if (not relative.is_relative()) {
+   *    uri = uri.root() / relative;
+   * }
+   * @endcode
+   *
+   * @param relative The "relative" URI to append to this one. Even though I say
+   * relative, this URI may start with a leading "/", as long as it is rootless.
+   * In that case, this URI is expected to be an HTTP/S URI - and we are going
+   * to replace everything after the hostname with the contents of relative.
+   */
   URI operator/(URI const & relative) const {
     std::string div = uri_.ends_with("/") || relative.uri_.starts_with("/") ? "" : "/";
     return URI(uri_ + div + relative.uri_);
   }
 
+  /**
+   * @brief Synonym for "does not have a scheme", used for short-circuiting
+   * relative URI handling.
+   */
   bool is_rootless() const { return scheme_ == 0; }
+  /**
+   * @brief Even if a URI does not have a scheme, it could still be non-relative
+   * item, such as the URI "/dev/null" - which unambiguously refers to to root
+   * directory (in a *nix type filesystem) - as opposed to "dev/null", which
+   * could mean different resources in different parent contexts.
+   *
+   * Given that the "$id" that we set acts similar to `cd` in a shell, knowing
+   * this let's us know if we're looking at "an entirely separate location", or
+   * a "child/sibling location".
+   */
   bool is_relative() const { return is_rootless() && uri_[resource_] != '/'; }
+
   std::string_view scheme() const { return std::string_view(uri_).substr(0, scheme_); }
   std::string_view resource() const { return std::string_view(uri_).substr(resource_); }
 

+ 0 - 1
include/jvalidate/validation_config.h

@@ -19,7 +19,6 @@ struct ValidationConfig {
 
   // When enabled, we will validate format constraints on the document, instead
   // of using them purely as annotations.
-  // TODO(samjaffe): A schema can also indicate this using $vocabulary
   bool validate_format = false;
 };
 }

+ 256 - 19
include/jvalidate/validation_result.h

@@ -2,7 +2,8 @@
 
 #include <map>
 #include <ostream>
-#include <unordered_set>
+#include <utility>
+#include <variant>
 #include <vector>
 
 #include <jvalidate/detail/pointer.h>
@@ -11,39 +12,275 @@
 namespace jvalidate {
 class ValidationResult {
 public:
-  template <Adapter A, RegexEngine RE> friend class ValidationVisitor;
+  // Only allow ValidationVisitor to construct the elements of a validation result
+  template <RegexEngine, typename> friend class ValidationVisitor;
+
+  using DocPointer = detail::Pointer;
+  using SchemaPointer = detail::Pointer;
+  using Annotation = std::variant<std::string, std::vector<std::string>>;
+
+  /**
+   * @brief The result info at any given (DocPointer, SchemaPointer) path.
+   * The key for errors/annotations represents the leaf element of SchemaPointer
+   * instead of including it in the map.
+   *
+   * This allows better locality of error info. For example:
+   * {
+   *   "valid": false,
+   *   "evaluationPath": "/definitions/EvenPercent",
+   *   "instanceLocation": "/foo/bar/percentages/2",
+   *   "errors": {
+   *     "max": "105 > 100",
+   *     "multipleOf": "105 is not a multiple of 2"
+   *   }
+   * }
+   */
+  struct LocalResult {
+    bool valid;
+    std::map<std::string, Annotation> errors;
+    std::map<std::string, Annotation> annotations;
+  };
+
+  struct indent {
+    indent(int i) : i(i) {}
+
+    friend std::ostream & operator<<(std::ostream & os, indent id) {
+      while (id.i-- > 0)
+        os << "  ";
+      return os;
+    }
+
+    int i;
+  };
 
 private:
-  std::map<detail::Pointer, std::map<detail::Pointer, std::string>> errors_;
+  bool valid_;
+  std::map<DocPointer, std::map<SchemaPointer, LocalResult>> results_;
 
 public:
+  /**
+   * @brief Writes this object to an osteam in the list format as described in
+   * https://json-schema.org/blog/posts/interpreting-output
+   * This means that the json-schema for a ValidationResult looks like this:
+   * {
+   *   "$defs": {
+   *     "Pointer": {
+   *       "format": "json-pointer",
+   *       "type": "string"
+   *     },
+   *     "Annotation": {
+   *       "items": { "type": "string" },
+   *       "type": [ "string", "array" ]
+   *     }
+   *   },
+   *   "properties": {
+   *     "valid": { "type": "boolean" },
+   *     "details": {
+   *       "items": {
+   *         "properties": {
+   *           "valid": { "type": "boolean" },
+   *           "evaluationPath": { "$ref": "#/$defs/Pointer" },
+   *           "instanceLocation": { "$ref": "#/$defs/Pointer" },
+   *           "annotations": { "$ref": "#/$defs/Annotation" },
+   *           "errors": { "$ref": "#/$defs/Annotation" }
+   *         }
+   *         "type": "object"
+   *       },
+   *       "type": "array"
+   *     }
+   *   }
+   *   "type": "object"
+   * }
+   */
   friend std::ostream & operator<<(std::ostream & os, ValidationResult const & result) {
-    for (auto const & [where, errors] : result.errors_) {
-      if (errors.size() == 1) {
-        auto const & [schema_path, message] = *errors.begin();
-        std::cout << where << ": " << schema_path << ": " << message << "\n";
-      } else {
-        std::cout << where << "\n";
-        for (auto const & [schema_path, message] : errors) {
-          std::cout << "  " << schema_path << ": " << message << "\n";
+    char const * div = "\n";
+    os << "{\n" << indent(1) << R"("valid": )" << (result.valid_ ? "true" : "false") << ',' << '\n';
+    os << indent(1) << R"("details": [)";
+    for (auto const & [doc_path, by_schema] : result.results_) {
+      for (auto const & [schema_path, local] : by_schema) {
+        os << std::exchange(div, ",\n") << indent(2) << '{' << '\n';
+        os << indent(3) << R"("valid": )" << (local.valid ? "true" : "false") << ',' << '\n';
+        os << indent(3) << R"("evaluationPath": ")" << schema_path << '"' << ',' << '\n';
+        os << indent(3) << R"("instanceLocation": ")" << doc_path << '"';
+        print(os, local.annotations, "annotations", 3);
+        print(os, local.errors, "errors", 3);
+        os << '\n' << indent(2) << '}';
+      }
+    }
+    return os << '\n' << indent(1) << ']' << '\n' << '}';
+  }
+
+  static void print(std::ostream & os, std::map<std::string, Annotation> const & named,
+                    std::string_view name, int const i) {
+    if (named.empty()) {
+      return;
+    }
+    os << ',' << '\n';
+    os << indent(i) << '"' << name << '"' << ':' << ' ' << '{' << '\n';
+    for (auto const & [key, anno] : named) {
+      os << indent(i + 1) << '"' << key << '"' << ':' << ' ';
+      if (auto const * str = std::get_if<0>(&anno)) {
+        os << '"' << *str << '"';
+      } else if (auto const * vec = std::get_if<1>(&anno)) {
+        os << '[';
+        char const * div = "\n";
+        for (size_t n = 0; n < vec->size(); ++n) {
+          os << std::exchange(div, ",\n") << indent(i + 2) << '"' << vec->at(n) << '"';
         }
+        os << '\n' << indent(i + 1) << ']';
       }
+      os << '\n';
     }
-    return os;
+    os << indent(i) << '}';
+  }
+
+  bool valid() const { return valid_; }
+
+  /**
+   * @brief Are there any validation details associated with the given document
+   * location and schema section.
+   *
+   * @param where A path into the document being validated
+   * @param schema_path The schema path (not counting the leaf element that
+   * actually evaluates the document)
+   *
+   * @return true if the schema path has produced an annotation or error for the
+   * document path
+   */
+  bool has(detail::Pointer const & where, detail::Pointer const & schema_path) const {
+    return has(where) && results_.at(where).contains(schema_path);
+  }
+
+  /**
+   * @brief Are there any validation details associated with the given document
+   * location
+   *
+   * @param where A path into the document being validated
+   *
+   * @return true if any rule has produced an annotation or error for the
+   * document path
+   */
+  bool has(detail::Pointer const & where) const { return results_.contains(where); }
+
+  /**
+   * @brief Extracts the annotation for requested document and schema location, if it exists
+   *
+   * @param where A path into the document being validated
+   * @param schema_path The schema path, without its leaf element
+   * @param name The leaf schema path (i.e. the rule being evaluated).
+   * @pre name.empty() == schema_path.empty()
+   *
+   * @returns An Annotation for the given path info provided, or nullptr if no annotation exists
+   */
+  Annotation const * annotation(detail::Pointer const & where, detail::Pointer const & schema_path,
+                                std::string const & name) const {
+    if (not results_.contains(where)) {
+      return nullptr;
+    }
+    auto const & by_schema = results_.at(where);
+    if (not by_schema.contains(schema_path)) {
+      return nullptr;
+    }
+    auto const & local = by_schema.at(schema_path);
+    if (not local.annotations.contains(name)) {
+      return nullptr;
+    }
+    return &local.annotations.at(name);
+  }
+
+  /**
+   * @brief Extracts the error for requested document and schema location, if it exists
+   *
+   * @param where A path into the document being validated
+   * @param schema_path The schema path, without its leaf element
+   * @param name The leaf schema path (i.e. the rule being evaluated).
+   * @pre name.empty() == schema_path.empty()
+   *
+   * @returns An Annotation for the given path info provided, or nullptr if no annotation exists
+   */
+  Annotation const * error(detail::Pointer const & where, detail::Pointer const & schema_path,
+                           std::string const & name) const {
+    if (not results_.contains(where)) {
+      return nullptr;
+    }
+    auto const & by_schema = results_.at(where);
+    if (not by_schema.contains(schema_path)) {
+      return nullptr;
+    }
+    auto const & local = by_schema.at(schema_path);
+    if (not local.errors.contains(name)) {
+      return nullptr;
+    }
+    return &local.errors.at(name);
   }
 
 private:
-  void add_error(ValidationResult && result) {
-    for (auto const & [where, errors] : result.errors_) {
-      for (auto const & [schema_path, message] : errors) {
-        errors_[where][schema_path] += message;
+  /**
+   * @brief Transfer the contents of another ValidationResult into this one using
+   * {@see std::map::merge} to transfer the data minimizing the need for copy/move.
+   *
+   * @param result The ValidationResult being consumed
+   */
+  void merge(ValidationResult && result) & {
+    for (auto && [where, by_schema] : result.results_) {
+      for (auto && [schema_path, local] : by_schema) {
+        results_[where][schema_path].annotations.merge(local.annotations);
+        results_[where][schema_path].errors.merge(local.errors);
       }
     }
   }
 
-  void add_error(detail::Pointer const & where, detail::Pointer const & schema_path,
-                 std::string const & message) {
-    errors_[where][schema_path] += message;
+  /**
+   * @brief Declare that the document is accepted/rejected by the given schema
+   *
+   * @param where A path into the document being validated
+   * @param schema_path The schema path
+   * @param valid Is this location valid according to the schema
+   */
+  void valid(detail::Pointer const & where, detail::Pointer const & schema_path, bool valid) {
+    if (has(where, schema_path)) {
+      results_[where][schema_path].valid = valid;
+    }
+    if (where.empty() && schema_path.empty()) {
+      valid_ = valid;
+    }
+  }
+
+  /**
+   * @brief Attach an error message for part of the document.
+   * Because of the existance of things like "not" schemas, error() can also be
+   * called to add an Annotation for a gate that is passed, but was within a
+   * "not" schema.
+   *
+   * @param where A path into the document being validated
+   * @param schema_path The schema path, without its leaf element
+   * @param name The leaf schema path (i.e. the rule being evaluated).
+   * @pre name.empty() == schema_path.empty()
+   * @param message The annotation(s) being placed as an error
+   */
+  void error(detail::Pointer const & where, detail::Pointer const & schema_path,
+             std::string const & name, Annotation message) {
+    if (std::visit([](auto const & v) { return v.empty(); }, message)) {
+      return;
+    }
+    results_[where][schema_path].errors.emplace(name, std::move(message));
+  }
+
+  /**
+   * @brief Attach some contextual annotations for part of the document
+   *
+   * @param where A path into the document being validated
+   * @param schema_path The schema path, without its leaf element
+   * @param name The leaf schema path (i.e. the rule being evaluated).
+   * @pre name.empty() == schema_path.empty()
+   * @param message The annotation(s) being placed for context
+   */
+  void annotate(detail::Pointer const & where, detail::Pointer const & schema_path,
+                std::string const & name, Annotation message) {
+    if (std::visit([](auto const & v) { return v.empty(); }, message)) {
+      return;
+    }
+    results_[where][schema_path].annotations.emplace(name, std::move(message));
   }
 };
 }

+ 387 - 220
include/jvalidate/validation_visitor.h

@@ -1,18 +1,22 @@
 #pragma once
 
 #include <tuple>
+#include <type_traits>
 #include <unordered_map>
+#include <vector>
 
+#include <jvalidate/compat/enumerate.h>
 #include <jvalidate/constraint/array_constraint.h>
 #include <jvalidate/constraint/general_constraint.h>
 #include <jvalidate/constraint/number_constraint.h>
 #include <jvalidate/constraint/object_constraint.h>
 #include <jvalidate/constraint/string_constraint.h>
-#include <jvalidate/constraint/visitor.h>
 #include <jvalidate/detail/expect.h>
 #include <jvalidate/detail/iostream.h>
 #include <jvalidate/detail/number.h>
 #include <jvalidate/detail/pointer.h>
+#include <jvalidate/detail/scoped_state.h>
+#include <jvalidate/detail/string_adapter.h>
 #include <jvalidate/format.h>
 #include <jvalidate/forward.h>
 #include <jvalidate/schema.h>
@@ -22,8 +26,16 @@
 
 #define VISITED(type) std::get<std::unordered_set<type>>(*visited_)
 
-#define NOOP_UNLESS_TYPE(etype)                                                                    \
-  RETURN_UNLESS(adapter::Type::etype == document_.type(), Status::Noop)
+#define VALIDATE_SUBSCHEMA_AND_MARK_LOCAL_VISIT(subschema, subinstance, path, local_visited)       \
+  do {                                                                                             \
+    Status const partial = validate_subschema_on(subschema, subinstance, path);                    \
+    rval &= partial;                                                                               \
+    if (result_ and partial != Status::Noop) {                                                     \
+      local_visited.insert(local_visited.end(), path);                                             \
+    }                                                                                              \
+  } while (false)
+
+#define NOOP_UNLESS_TYPE(etype) RETURN_UNLESS(adapter::Type::etype == document.type(), Status::Noop)
 
 #define BREAK_EARLY_IF_NO_RESULT_TREE()                                                            \
   do {                                                                                             \
@@ -33,13 +45,13 @@
   } while (false)
 
 namespace jvalidate {
-template <Adapter A, RegexEngine RE>
-class ValidationVisitor : public constraint::ConstraintVisitor {
+template <RegexEngine RE, typename ExtensionVisitor> class ValidationVisitor {
 private:
+  JVALIDATE_TRIBOOL_TYPE(StoreResults, ForValid, ForInvalid, ForAnything);
   using VisitedAnnotation = std::tuple<std::unordered_set<size_t>, std::unordered_set<std::string>>;
+  friend ExtensionVisitor;
 
 private:
-  A document_;
   detail::Pointer where_;
   detail::Pointer schema_path_;
 
@@ -48,229 +60,260 @@ private:
   ValidationResult * result_;
 
   ValidationConfig const & cfg_;
-  std::unordered_map<std::string, RE> & regex_cache_;
+  ExtensionVisitor extension_;
+  RE & regex_;
 
   mutable VisitedAnnotation * visited_ = nullptr;
+  mutable StoreResults tracking_ = StoreResults::ForInvalid;
 
 public:
-  ValidationVisitor(A const & json, schema::Node const & schema, ValidationConfig const & cfg,
-                    std::unordered_map<std::string, RE> & regex_cache, ValidationResult * result)
-      : document_(json), schema_(&schema), result_(result), cfg_(cfg), regex_cache_(regex_cache) {}
+  /**
+   * @brief Construct a new ValidationVisitor
+   *
+   * @param schema The parsed JSON Schema
+   * @param cfg General configuration settings for how the run is executed
+   * @param regex A cache of string regular expressions to compiled
+   * regular expressions
+   * @param[optional] extension A special visitor for extension constraints.
+   * @param[optional] result A cache of result/annotation info for the user to
+   * receive a detailed summary of why a document is supported/unsupported.
+   */
+  ValidationVisitor(schema::Node const & schema, ValidationConfig const & cfg, RE & regex,
+                    ExtensionVisitor extension, ValidationResult * result)
+      : schema_(&schema), result_(result), cfg_(cfg), extension_(extension), regex_(regex) {}
+
+  Status visit(constraint::ExtensionConstraint const & cons, Adapter auto const & document) const {
+    if constexpr (std::is_invocable_r_v<Status, ExtensionVisitor, decltype(cons),
+                                        decltype(document), ValidationVisitor const &>) {
+      return extension_(cons, document, *this);
+    }
+    annotate("unsupported extension");
+    return Status::Noop;
+  }
+
+  Status visit(constraint::TypeConstraint const & cons, Adapter auto const & document) const {
+    adapter::Type const type = document.type();
 
-  Status visit(constraint::TypeConstraint const & cons) const {
-    adapter::Type const type = document_.type();
     for (adapter::Type const accept : cons.types) {
       if (type == accept) {
-        return Status::Accept;
+        return result(Status::Accept, type, " is in types [", cons.types, "]");
       }
       if (accept == adapter::Type::Number && type == adapter::Type::Integer) {
-        return Status::Accept;
+        return result(Status::Accept, type, " is in types [", cons.types, "]");
       }
       if (accept == adapter::Type::Integer && type == adapter::Type::Number &&
-          detail::is_json_integer(document_.as_number())) {
-        return Status::Accept;
+          detail::is_json_integer(document.as_number())) {
+        return result(Status::Accept, type, " is in types [", cons.types, "]");
       }
     }
-    add_error("type ", type, " is not one of {", cons.types, '}');
-    return Status::Reject;
+    return result(Status::Reject, type, " is not in types [", cons.types, "]");
   }
 
-  Status visit(constraint::ExtensionConstraint const & cons) const {
-    return cons.validate(document_, where_, result_);
-  }
-
-  Status visit(constraint::EnumConstraint const & cons) const {
-    auto is_equal = [this](auto const & frozen) {
-      return document_.equals(frozen, cfg_.strict_equality);
+  Status visit(constraint::EnumConstraint const & cons, Adapter auto const & document) const {
+    auto is_equal = [this, &document](auto const & frozen) {
+      return document.equals(frozen, cfg_.strict_equality);
     };
-    for (auto const & option : cons.enumeration) {
+    for (auto const & [index, option] : detail::enumerate(cons.enumeration)) {
       if (option->apply(is_equal)) {
-        return Status::Accept;
+        return result(Status::Accept, index);
       }
     }
-    add_error("equals none of the values");
     return Status::Reject;
   }
 
-  Status visit(constraint::AllOfConstraint const & cons) const {
+  Status visit(constraint::AllOfConstraint const & cons, Adapter auto const & document) const {
     Status rval = Status::Accept;
 
-    size_t i = 0;
-    for (schema::Node const * subschema : cons.children) {
-      rval &= validate_subschema(subschema, i);
-      ++i;
+    std::set<size_t> unmatched;
+    for (auto const & [index, subschema] : detail::enumerate(cons.children)) {
+      if (auto stat = validate_subschema(subschema, document, index); stat == Status::Reject) {
+        rval = Status::Reject;
+        unmatched.insert(index);
+      }
       BREAK_EARLY_IF_NO_RESULT_TREE();
     }
 
-    return rval;
+    if (rval == Status::Reject) {
+      return result(rval, "does not validate subschemas ", unmatched);
+    }
+    return result(rval, "validates all subschemas");
   }
 
-  Status visit(constraint::AnyOfConstraint const & cons) const {
-    size_t i = 0;
-
-    Status rval = Status::Reject;
-    for (schema::Node const * subschema : cons.children) {
-      if (validate_subschema(subschema, i)) {
-        rval = Status::Accept;
+  Status visit(constraint::AnyOfConstraint const & cons, Adapter auto const & document) const {
+    std::optional<size_t> first_validated;
+    for (auto const & [index, subschema] : detail::enumerate(cons.children)) {
+      if (validate_subschema(subschema, document, index)) {
+        first_validated = index;
       }
-      if (not visited_ && rval == Status::Accept) {
+      if (not visited_ && first_validated.has_value()) {
         break;
       }
-      ++i;
     }
 
-    return rval;
+    if (first_validated.has_value()) {
+      return result(Status::Accept, "validates subschema ", *first_validated);
+    }
+    return result(Status::Reject, "validates none of the subschemas");
   }
 
-  Status visit(constraint::OneOfConstraint const & cons) const {
-    size_t matches = 0;
-    size_t i = 0;
-    for (schema::Node const * subschema : cons.children) {
-      if (validate_subschema(subschema, i)) {
-        ++matches;
+  Status visit(constraint::OneOfConstraint const & cons, Adapter auto const & document) const {
+    std::set<size_t> matches;
+
+    for (auto const & [index, subschema] : detail::enumerate(cons.children)) {
+      scoped_state(tracking_, StoreResults::ForAnything);
+      if (validate_subschema(subschema, document, index)) {
+        matches.insert(index);
       }
-      ++i;
     }
 
-    return matches == 1 ? Status::Accept : Status::Reject;
+    if (matches.size() == 1) {
+      return result(Status::Accept, "validates subschema ", *matches.begin());
+    }
+    return result(Status::Reject, "validates multiple subschemas ", matches);
   }
 
-  Status visit(constraint::NotConstraint const & cons) const {
-    VisitedAnnotation * suppress = nullptr;
-    std::swap(suppress, visited_);
-    auto rval = validate_subschema(cons.child, detail::Pointer()) == Status::Reject;
-    std::swap(suppress, visited_);
-    return rval;
+  Status visit(constraint::NotConstraint const & cons, Adapter auto const & document) const {
+    scoped_state(visited_, nullptr);
+    scoped_state(tracking_, !tracking_);
+    bool const rejected = validate_subschema(cons.child, document) == Status::Reject;
+
+    return rejected;
   }
 
-  Status visit(constraint::ConditionalConstraint const & cons) const {
-    if (validate_subschema(cons.if_constraint, detail::Pointer())) {
-      return validate_subschema(cons.then_constraint, "then");
+  Status visit(constraint::ConditionalConstraint const & cons,
+               Adapter auto const & document) const {
+    Status const if_true = [this, &cons, &document]() {
+      scoped_state(tracking_, StoreResults::ForAnything);
+      return validate_subschema(cons.if_constraint, document);
+    }();
+
+    annotate(if_true ? "valid" : "invalid");
+    if (if_true) {
+      return validate_subschema(cons.then_constraint, document, detail::parent, "then");
     }
-    return validate_subschema(cons.else_constraint, "else");
+    return validate_subschema(cons.else_constraint, document, detail::parent, "else");
   }
 
-  Status visit(constraint::MaximumConstraint const & cons) const {
-    switch (document_.type()) {
+  Status visit(constraint::MaximumConstraint const & cons, Adapter auto const & document) const {
+    switch (document.type()) {
     case adapter::Type::Integer:
-      if (int64_t value = document_.as_integer(); not cons(value)) {
-        add_error("integer ", value, " exceeds ", cons.exclusive ? "exclusive " : "", "maximum of ",
-                  cons.value);
-        return false;
+      if (int64_t value = document.as_integer(); not cons(value)) {
+        return result(Status::Reject, value, cons.exclusive ? " >= " : " > ", cons.value);
+      } else {
+        return result(Status::Accept, value, cons.exclusive ? " < " : " <= ", cons.value);
       }
-      return true;
     case adapter::Type::Number:
-      if (double value = document_.as_number(); not cons(value)) {
-        add_error("number ", value, " exceeds ", cons.exclusive ? "exclusive " : "", "maximum of ",
-                  cons.value);
-        return false;
+      if (double value = document.as_number(); not cons(value)) {
+        return result(Status::Reject, value, cons.exclusive ? " >= " : " > ", cons.value);
+      } else {
+        return result(Status::Accept, value, cons.exclusive ? " < " : " <= ", cons.value);
       }
-      return true;
     default:
       return Status::Noop;
     }
   }
 
-  Status visit(constraint::MinimumConstraint const & cons) const {
-    switch (document_.type()) {
+  Status visit(constraint::MinimumConstraint const & cons, Adapter auto const & document) const {
+    switch (document.type()) {
     case adapter::Type::Integer:
-      if (int64_t value = document_.as_integer(); not cons(value)) {
-        add_error("integer ", value, " fails ", cons.exclusive ? "exclusive " : "", "minimum of ",
-                  cons.value);
-        return false;
+      if (int64_t value = document.as_integer(); not cons(value)) {
+        return result(Status::Reject, value, cons.exclusive ? " <= " : " < ", cons.value);
+      } else {
+        return result(Status::Accept, value, cons.exclusive ? " > " : " >= ", cons.value);
       }
-      return true;
     case adapter::Type::Number:
-      if (double value = document_.as_number(); not cons(value)) {
-        add_error("number ", value, " fails ", cons.exclusive ? "exclusive " : "", "minimum of ",
-                  cons.value);
-        return false;
+      if (double value = document.as_number(); not cons(value)) {
+        return result(Status::Reject, value, cons.exclusive ? " <= " : " < ", cons.value);
+      } else {
+        return result(Status::Accept, value, cons.exclusive ? " > " : " >= ", cons.value);
       }
-      return true;
     default:
       return Status::Noop;
     }
   }
 
-  Status visit(constraint::MultipleOfConstraint const & cons) const {
-    adapter::Type const type = document_.type();
+  Status visit(constraint::MultipleOfConstraint const & cons, Adapter auto const & document) const {
+    adapter::Type const type = document.type();
     RETURN_UNLESS(type == adapter::Type::Number || type == adapter::Type::Integer, Status::Noop);
 
-    if (double value = document_.as_number(); not cons(value)) {
-      add_error("number ", value, " is not a multiple of ", cons.value);
-      return false;
+    if (double value = document.as_number(); not cons(value)) {
+      return result(Status::Reject, value, " is not a multiple of ", cons.value);
+    } else {
+      return result(Status::Accept, value, " is a multiple of ", cons.value);
     }
-    return true;
   }
 
-  Status visit(constraint::MaxLengthConstraint const & cons) const {
+  Status visit(constraint::MaxLengthConstraint const & cons, Adapter auto const & document) const {
     NOOP_UNLESS_TYPE(String);
-    if (auto str = document_.as_string(); detail::length(str) > cons.value) {
-      add_error("string '", str, "' is greater than the maximum length of ", cons.value);
-      return false;
+    std::string const str = document.as_string();
+    if (int64_t len = detail::length(str); len > cons.value) {
+      return result(Status::Reject, "string of length ", len, " is >", cons.value);
+    } else {
+      return result(Status::Accept, "string of length ", len, " is <=", cons.value);
     }
-    return true;
   }
 
-  Status visit(constraint::MinLengthConstraint const & cons) const {
+  Status visit(constraint::MinLengthConstraint const & cons, Adapter auto const & document) const {
     NOOP_UNLESS_TYPE(String);
-    if (auto str = document_.as_string(); detail::length(str) < cons.value) {
-      add_error("string '", str, "' is less than the minimum length of ", cons.value);
-      return false;
+    std::string const str = document.as_string();
+    if (int64_t len = detail::length(str); len < cons.value) {
+      return result(Status::Reject, "string of length ", len, " is <", cons.value);
+    } else {
+      return result(Status::Accept, "string of length ", len, " is >=", cons.value);
     }
-    return true;
   }
 
-  Status visit(constraint::PatternConstraint const & cons) const {
+  Status visit(constraint::PatternConstraint const & cons, Adapter auto const & document) const {
     NOOP_UNLESS_TYPE(String);
 
-    RE const & regex = regex_cache_.try_emplace(cons.regex, cons.regex).first->second;
-    if (auto str = document_.as_string(); not regex.search(str)) {
-      add_error("string '", str, "' does not match pattern /", cons.regex, "/");
-      return false;
+    std::string const str = document.as_string();
+    if (regex_.search(cons.regex, str)) {
+      return result(Status::Accept, "string matches pattern /", cons.regex, "/");
     }
-    return true;
+    return result(Status::Reject, "string does not match pattern /", cons.regex, "/");
   }
 
-  Status visit(constraint::FormatConstraint const & cons) const {
+  Status visit(constraint::FormatConstraint const & cons, Adapter auto const & document) const {
     // https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#name-defined-formats
     NOOP_UNLESS_TYPE(String);
 
-    if (not cfg_.validate_format) {
+    annotate(cons.format);
+    if (not cfg_.validate_format && not cons.is_assertion) {
       return true;
     }
 
-    switch (FormatValidator()(cons.format, document_.as_string())) {
+    switch (FormatValidator()(cons.format, document.as_string())) {
     case FormatValidator::Status::Unimplemented:
-      add_error("unimplemented format '", cons.format, "'");
-      return false;
+      return result(Status::Reject, "unimplemented format '", cons.format, "'");
     case FormatValidator::Status::Invalid:
-      add_error("string '", document_.as_string(), "' does not match format '", cons.format, "'");
-      return false;
+      return result(Status::Reject, "does not match format '", cons.format, "'");
     case FormatValidator::Status::Unknown:
     case FormatValidator::Status::Valid:
-      return true;
+      return result(Status::Accept, "matches format '", cons.format, "'");
     }
   }
 
-  Status visit(constraint::AdditionalItemsConstraint const & cons) const {
+  Status visit(constraint::AdditionalItemsConstraint const & cons,
+               Adapter auto const & document) const {
     NOOP_UNLESS_TYPE(Array);
 
-    auto array = document_.as_array();
+    auto array = document.as_array();
 
     Status rval = Status::Accept;
+    std::vector<size_t> items;
     for (size_t i = cons.applies_after_nth; i < array.size(); ++i) {
-      rval &= validate_subschema_on(cons.subschema, array[i], i);
+      VALIDATE_SUBSCHEMA_AND_MARK_LOCAL_VISIT(cons.subschema, array[i], i, items);
       BREAK_EARLY_IF_NO_RESULT_TREE();
     }
 
+    annotate_list(items);
     return rval;
   }
 
-  Status visit(constraint::ContainsConstraint const & cons) const {
+  Status visit(constraint::ContainsConstraint const & cons, Adapter auto const & document) const {
     NOOP_UNLESS_TYPE(Array);
 
-    auto array = document_.as_array();
+    auto array = document.as_array();
     size_t const minimum = cons.minimum.value_or(1);
     size_t const maximum = cons.maximum.value_or(array.size());
     size_t matches = 0;
@@ -281,82 +324,82 @@ public:
     }
 
     if (matches < minimum) {
-      add_error("array does not contain at least ", minimum, " matching elements");
-      return Status::Reject;
+      return result(Status::Reject, "array contains <", minimum, " matching items");
     }
     if (matches > maximum) {
-      add_error("array contains more than ", maximum, " matching elements");
-      return Status::Reject;
+      return result(Status::Reject, "array contains >", maximum, " matching items");
     }
-    return Status::Accept;
+    return result(Status::Accept, "array contains ", matches, " matching items");
   }
 
-  Status visit(constraint::MaxItemsConstraint const & cons) const {
+  Status visit(constraint::MaxItemsConstraint const & cons, Adapter auto const & document) const {
     NOOP_UNLESS_TYPE(Array);
-    if (auto size = document_.array_size(); size > cons.value) {
-      add_error("array with ", size, " items is greater than the maximum of ", cons.value);
-      return false;
+    if (size_t size = document.array_size(); size > cons.value) {
+      return result(Status::Reject, "array of size ", size, " is >", cons.value);
+    } else {
+      return result(Status::Accept, "array of size ", size, " is <=", cons.value);
     }
-    return true;
   }
 
-  Status visit(constraint::MinItemsConstraint const & cons) const {
+  Status visit(constraint::MinItemsConstraint const & cons, Adapter auto const & document) const {
     NOOP_UNLESS_TYPE(Array);
-    if (auto size = document_.array_size(); size < cons.value) {
-      add_error("array with ", size, " items is less than the minimum of ", cons.value);
-      return false;
+    if (size_t size = document.array_size(); size < cons.value) {
+      return result(Status::Reject, "array of size ", size, " is <", cons.value);
+    } else {
+      return result(Status::Accept, "array of size ", size, " is >=", cons.value);
     }
-    return true;
   }
 
-  Status visit(constraint::TupleConstraint const & cons) const {
+  Status visit(constraint::TupleConstraint const & cons, Adapter auto const & document) const {
     NOOP_UNLESS_TYPE(Array);
 
     Status rval = Status::Accept;
 
-    auto array = document_.as_array();
-    size_t const n = std::min(cons.items.size(), array.size());
-    for (size_t i = 0; i < n; ++i) {
-      rval &= validate_subschema_on(cons.items[i], array[i], i);
+    std::vector<size_t> items;
+    for (auto const & [index, item] : detail::enumerate(document.as_array())) {
+      if (index >= cons.items.size()) {
+        break;
+      }
+      VALIDATE_SUBSCHEMA_AND_MARK_LOCAL_VISIT(cons.items[index], item, index, items);
       BREAK_EARLY_IF_NO_RESULT_TREE();
     }
 
+    annotate_list(items);
     return rval;
   }
 
-  Status visit(constraint::UniqueItemsConstraint const & cons) const {
+  template <Adapter A>
+  Status visit(constraint::UniqueItemsConstraint const & cons, A const & document) const {
     NOOP_UNLESS_TYPE(Array);
 
     if constexpr (std::totally_ordered<A>) {
-      std::set<A> cache;
-      for (A const & elem : document_.as_array()) {
-        if (not cache.insert(elem).second) {
-          add_error("array contains duplicate elements");
-          return Status::Reject;
+      std::map<A, size_t> cache;
+      for (auto const & [index, elem] : detail::enumerate(document.as_array())) {
+        if (auto [it, created] = cache.emplace(elem, index); not created) {
+          return result(Status::Reject, "items ", it->second, " and ", index, " are equal");
         }
       }
     } else {
-      auto array = document_.as_array();
+      auto array = document.as_array();
       for (size_t i = 0; i < array.size(); ++i) {
         for (size_t j = i + 1; j < array.size(); ++j) {
           if (array[i].equals(array[j], true)) {
-            add_error("array elements ", i, " and ", j, " are equal");
-            return Status::Reject;
+            return result(Status::Reject, "items ", i, " and ", j, " are equal");
           }
         }
       }
     }
 
-    return Status::Accept;
+    return result(Status::Accept, "all array items are unique");
   }
 
-  Status visit(constraint::AdditionalPropertiesConstraint const & cons) const {
+  Status visit(constraint::AdditionalPropertiesConstraint const & cons,
+               Adapter auto const & document) const {
     NOOP_UNLESS_TYPE(Object);
 
     auto matches_any_pattern = [this, &cons](std::string const & key) {
       for (auto & pattern : cons.patterns) {
-        RE const & regex = regex_cache_.try_emplace(pattern, pattern).first->second;
-        if (regex.search(key)) {
+        if (regex_.search(pattern, key)) {
           return true;
         }
       }
@@ -364,27 +407,30 @@ public:
     };
 
     Status rval = Status::Accept;
-    for (auto const & [key, elem] : document_.as_object()) {
+    std::vector<std::string> properties;
+    for (auto const & [key, elem] : document.as_object()) {
       if (not cons.properties.contains(key) && not matches_any_pattern(key)) {
-        rval &= validate_subschema_on(cons.subschema, elem, key);
+        VALIDATE_SUBSCHEMA_AND_MARK_LOCAL_VISIT(cons.subschema, elem, key, properties);
       }
       BREAK_EARLY_IF_NO_RESULT_TREE();
     }
 
+    annotate_list(properties);
     return rval;
   }
 
-  Status visit(constraint::DependenciesConstraint const & cons) const {
+  Status visit(constraint::DependenciesConstraint const & cons,
+               Adapter auto const & document) const {
     NOOP_UNLESS_TYPE(Object);
 
-    auto object = document_.as_object();
+    auto object = document.as_object();
     Status rval = Status::Accept;
     for (auto const & [key, subschema] : cons.subschemas) {
       if (not object.contains(key)) {
         continue;
       }
 
-      rval &= validate_subschema(subschema, key);
+      rval &= validate_subschema(subschema, document, key);
       BREAK_EARLY_IF_NO_RESULT_TREE();
     }
 
@@ -404,46 +450,52 @@ public:
     return rval;
   }
 
-  Status visit(constraint::MaxPropertiesConstraint const & cons) const {
+  Status visit(constraint::MaxPropertiesConstraint const & cons,
+               Adapter auto const & document) const {
     NOOP_UNLESS_TYPE(Object);
-    if (auto size = document_.object_size(); size > cons.value) {
-      add_error("object with ", size, " properties is greater than the maximum of ", cons.value);
-      return false;
+    if (size_t size = document.object_size(); size > cons.value) {
+      return result(Status::Reject, "object of size ", size, " is >", cons.value);
+    } else {
+      return result(Status::Accept, "object of size ", size, " is <=", cons.value);
     }
-    return true;
   }
 
-  Status visit(constraint::MinPropertiesConstraint const & cons) const {
+  Status visit(constraint::MinPropertiesConstraint const & cons,
+               Adapter auto const & document) const {
     NOOP_UNLESS_TYPE(Object);
-    if (auto size = document_.object_size(); size < cons.value) {
-      add_error("object with ", size, " properties is less than the minimum of ", cons.value);
-      return false;
+    if (size_t size = document.object_size(); size < cons.value) {
+      return result(Status::Reject, "object of size ", size, " is <", cons.value);
+    } else {
+      return result(Status::Accept, "object of size ", size, " is >=", cons.value);
     }
-    return true;
   }
 
-  Status visit(constraint::PatternPropertiesConstraint const & cons) const {
+  Status visit(constraint::PatternPropertiesConstraint const & cons,
+               Adapter auto const & document) const {
     NOOP_UNLESS_TYPE(Object);
 
+    std::vector<std::string> properties;
     Status rval = Status::Accept;
     for (auto const & [pattern, subschema] : cons.properties) {
-      RE const & regex = regex_cache_.try_emplace(pattern, pattern).first->second;
-      for (auto const & [key, elem] : document_.as_object()) {
-        if (regex.search(key)) {
-          rval &= validate_subschema_on(subschema, elem, key);
+      for (auto const & [key, elem] : document.as_object()) {
+        if (not regex_.search(pattern, key)) {
+          continue;
         }
+        VALIDATE_SUBSCHEMA_AND_MARK_LOCAL_VISIT(subschema, elem, key, properties);
         BREAK_EARLY_IF_NO_RESULT_TREE();
       }
     }
 
+    annotate_list(properties);
     return rval;
   }
 
-  Status visit(constraint::PropertiesConstraint const & cons) const {
+  template <Adapter A>
+  Status visit(constraint::PropertiesConstraint const & cons, A const & document) const {
     NOOP_UNLESS_TYPE(Object);
 
     Status rval = Status::Accept;
-    auto object = document_.as_object();
+    auto object = document.as_object();
 
     if constexpr (MutableAdapter<A>) {
       for (auto const & [key, subschema] : cons.properties) {
@@ -454,165 +506,280 @@ public:
       }
     }
 
+    std::vector<std::string> properties;
     for (auto const & [key, elem] : object) {
       if (auto it = cons.properties.find(key); it != cons.properties.end()) {
-        rval &= validate_subschema_on(it->second, elem, key);
+        VALIDATE_SUBSCHEMA_AND_MARK_LOCAL_VISIT(it->second, elem, key, properties);
       }
       BREAK_EARLY_IF_NO_RESULT_TREE();
     }
 
+    annotate_list(properties);
     return rval;
   }
 
-  Status visit(constraint::PropertyNamesConstraint const & cons) const {
+  template <Adapter A>
+  Status visit(constraint::PropertyNamesConstraint const & cons, A const & document) const {
     NOOP_UNLESS_TYPE(Object);
 
     Status rval = Status::Accept;
-    for (auto const & [key, _] : document_.as_object()) {
+    for (auto const & [key, _] : document.as_object()) {
       // TODO(samjaffe): Should we prefer a std::string adapter like valijson?
-      typename A::value_type key_json{key};
-      rval &= validate_subschema_on(cons.key_schema, A(key_json), std::string("$$key"));
+      rval &=
+          validate_subschema_on(cons.key_schema, detail::StringAdapter(key), std::string("$$key"));
     }
     return rval;
   }
 
-  Status visit(constraint::RequiredConstraint const & cons) const {
+  Status visit(constraint::RequiredConstraint const & cons, Adapter auto const & document) const {
     NOOP_UNLESS_TYPE(Object);
 
     auto required = cons.properties;
-    for (auto const & [key, _] : document_.as_object()) {
+    for (auto const & [key, _] : document.as_object()) {
       required.erase(key);
     }
 
     if (required.empty()) {
-      return Status::Accept;
+      return result(Status::Accept, "contains all required properties ", cons.properties);
     }
 
-    add_error("missing required properties ", required);
-    return Status::Reject;
+    return result(Status::Reject, "missing required properties ", required);
   }
 
-  Status visit(constraint::UnevaluatedItemsConstraint const & cons) const {
+  Status visit(constraint::UnevaluatedItemsConstraint const & cons,
+               Adapter auto const & document) const {
     NOOP_UNLESS_TYPE(Array);
     if (not visited_) {
       return Status::Reject;
     }
 
     Status rval = Status::Accept;
-    auto array = document_.as_array();
-    for (size_t i = 0; i < array.size(); ++i) {
-      if (not VISITED(size_t).contains(i)) {
-        rval &= validate_subschema_on(cons.subschema, array[i], i);
+    std::vector<size_t> items;
+    for (auto const & [index, item] : detail::enumerate(document.as_array())) {
+      if (not VISITED(size_t).contains(index)) {
+        VALIDATE_SUBSCHEMA_AND_MARK_LOCAL_VISIT(cons.subschema, item, index, items);
       }
       BREAK_EARLY_IF_NO_RESULT_TREE();
     }
 
+    annotate_list(items);
     return rval;
   }
 
-  Status visit(constraint::UnevaluatedPropertiesConstraint const & cons) const {
+  Status visit(constraint::UnevaluatedPropertiesConstraint const & cons,
+               Adapter auto const & document) const {
     NOOP_UNLESS_TYPE(Object);
     if (not visited_) {
       return Status::Reject;
     }
 
     Status rval = Status::Accept;
-    for (auto const & [key, elem] : document_.as_object()) {
+    std::vector<std::string> properties;
+    for (auto const & [key, elem] : document.as_object()) {
       if (not VISITED(std::string).contains(key)) {
-        rval &= validate_subschema_on(cons.subschema, elem, key);
+        VALIDATE_SUBSCHEMA_AND_MARK_LOCAL_VISIT(cons.subschema, elem, key, properties);
       }
       BREAK_EARLY_IF_NO_RESULT_TREE();
     }
 
+    annotate_list(properties);
     return rval;
   }
 
-  Status validate() {
-    if (auto const & reject = schema_->rejects_all()) {
-      add_error(*reject);
+  /**
+   * @brief The main entry point into the validator. Validates the provided
+   * document according to the schema.
+   */
+  Status validate(Adapter auto const & document) {
+    // Step 1) Check if this is an always-false schema. Sometimes, this will
+    // have a custom message.
+    if (std::optional<std::string> const & reject = schema_->rejects_all()) {
+      if (should_annotate(Status::Reject)) {
+        // This will only be run if we are interested in why something is
+        // rejected. For example - `{ "not": false }` doesn't produce a
+        // meaningful annotation...
+        result_->error(where_, schema_path_, "", *reject);
+      }
+      // ...We do always record the result if a result object is present.
+      (result_ ? result_->valid(where_, schema_path_, false) : void());
       return Status::Reject;
     }
 
     if (schema_->accepts_all()) {
       // An accept-all schema is not No-Op for the purpose of unevaluated*
+      (result_ ? result_->valid(where_, schema_path_, true) : void());
       return Status::Accept;
     }
 
+    // Begin tracking evaluations for unevaluated* keywords. The annotation
+    // object is passed down from parent visitor to child visitor to allow all
+    // schemas to mark whether they visited a certain item or property.
     VisitedAnnotation annotate;
     if (schema_->requires_result_context() and not visited_) {
       visited_ = &annotate;
     }
 
     Status rval = Status::Noop;
-    if (auto ref = schema_->reference_schema()) {
-      rval = validate_subschema(*ref, "$ref");
+    // Before Draft2019_09, reference schemas could not coexist with other
+    // constraints. This is enforced in the parsing of the schema, rather than
+    // during validation {@see jvalidate::schema::Node::construct}.
+    if (std::optional<schema::Node const *> ref = schema_->reference_schema()) {
+      rval = validate_subschema(*ref, document, "$ref");
     }
 
     detail::Pointer const current_schema = schema_path_;
     for (auto const & [key, p_constraint] : schema_->constraints()) {
       BREAK_EARLY_IF_NO_RESULT_TREE();
       schema_path_ = current_schema / key;
-      rval &= p_constraint->accept(*this);
+      rval &= std::visit([this, &document](auto & c) { return visit(c, document); }, *p_constraint);
     }
 
+    // Post Constraints represent the unevaluatedItems and unevaluatedProperties
+    // keywords.
     for (auto const & [key, p_constraint] : schema_->post_constraints()) {
       BREAK_EARLY_IF_NO_RESULT_TREE();
       schema_path_ = current_schema / key;
-      rval &= p_constraint->accept(*this);
+      rval &= std::visit([this, &document](auto & c) { return visit(c, document); }, *p_constraint);
     }
 
+    (result_ ? result_->valid(where_, current_schema, static_cast<bool>(rval)) : void());
     return rval;
   }
 
 private:
-  template <typename... Args> void add_error(Args &&... args) const {
-    if (not result_) {
-      return;
-    }
+  template <typename S>
+    requires(std::is_constructible_v<std::string, S>)
+  static std::string fmt(S const & str) {
+    return str;
+  }
+
+  static std::string fmt(auto const &... args) {
     std::stringstream ss;
     using ::jvalidate::operator<<;
-    [[maybe_unused]] int _[] = {(ss << std::forward<Args>(args), 0)...};
-    result_->add_error(where_, schema_path_, ss.str());
+    [[maybe_unused]] int _[] = {(ss << args, 0)...};
+    return ss.str();
   }
 
-  template <typename C> static void merge_visited(C & to, C const & from) {
-    to.insert(from.begin(), from.end());
+  static std::vector<std::string> fmtlist(auto const & arg) {
+    std::vector<std::string> strs;
+    for (auto const & elem : arg) {
+      strs.push_back(fmt(elem));
+    }
+    return strs;
   }
 
-  template <typename K>
-  Status validate_subschema(schema::Node const * subschema, K const & key) const {
+  bool should_annotate(Status stat) const {
+    if (not result_) {
+      return false;
+    }
+    switch (*tracking_) {
+    case StoreResults::ForAnything:
+      return stat != Status::Noop;
+    case StoreResults::ForValid:
+      return stat == Status::Accept;
+    case StoreResults::ForInvalid:
+      return stat == Status::Reject;
+    }
+  }
+
+#define ANNOTATION_HELPER(name, ADD, FMT)                                                          \
+  void name(auto const &... args) const {                                                          \
+    if (not result_) {                                                                             \
+      /* do nothing if there's no result object to append to */                                    \
+    } else if (schema_path_.empty()) {                                                             \
+      result_->ADD(where_, schema_path_, "", FMT(args...));                                        \
+    } else {                                                                                       \
+      result_->ADD(where_, schema_path_.parent(), schema_path_.back(), FMT(args...));              \
+    }                                                                                              \
+  }
+
+  ANNOTATION_HELPER(error, error, fmt)
+  ANNOTATION_HELPER(annotate, annotate, fmt)
+  ANNOTATION_HELPER(annotate_list, annotate, fmtlist)
+
+  Status result(Status stat, auto const &... args) const {
+    return (should_annotate(stat) ? error(args...) : void(), stat);
+  }
+
+  /**
+   * @brief Walking function for entering a subschema.
+   *
+   * @param subschema The "subschema" being validated. This is either another
+   * schema object (jvalidate::schema::Node), or a constraint.
+   * @param keys... The path to this subschema, relative to the current schema
+   * evaluation.
+   *
+   * @return The status of validating the current instance against the
+   * subschema.
+   */
+  template <typename... K>
+  Status validate_subschema(constraint::SubConstraint const & subschema,
+                            Adapter auto const & document, K const &... keys) const {
+    if (schema::Node const * const * ppschema = std::get_if<0>(&subschema)) {
+      return validate_subschema(*ppschema, document, keys...);
+    } else {
+      return std::visit([this, &document](auto & c) { return visit(c, document); },
+                        *std::get<1>(subschema));
+    }
+  }
+
+  /**
+   * @brief Walking function for entering a subschema. Creates a new validation
+   * visitor in order to continue evaluation.
+   *
+   * @param subschema The subschema being validated.
+   * @param keys... The path to this subschema, relative to the current schema
+   * evaluation.
+   *
+   * @return The status of validating the current instance against the
+   * subschema.
+   */
+  template <typename... K>
+  Status validate_subschema(schema::Node const * subschema, Adapter auto const & document,
+                            K const &... keys) const {
     VisitedAnnotation annotate;
 
     ValidationVisitor next = *this;
-    next.schema_path_ /= key;
+    ((next.schema_path_ /= keys), ...);
     std::tie(next.schema_, next.visited_) =
         std::forward_as_tuple(subschema, visited_ ? &annotate : nullptr);
 
-    Status rval = next.validate();
+    Status rval = next.validate(document);
 
     if (rval == Status::Accept and visited_) {
-      merge_visited(std::get<0>(*visited_), std::get<0>(annotate));
-      merge_visited(std::get<1>(*visited_), std::get<1>(annotate));
+      std::get<0>(*visited_).merge(std::get<0>(annotate));
+      std::get<1>(*visited_).merge(std::get<1>(annotate));
     }
     return rval;
   }
 
+  /**
+   * @brief Walking function for entering a subschema and child document.
+   * Creates a new validation visitor in order to continue evaluation.
+   *
+   * @param subschema The subschema being validated.
+   * @param document The child document being evaluated.
+   * @param key The path to this document instance.
+   *
+   * @return The status of validating the current instance against the
+   * subschema.
+   */
   template <typename K>
-  Status validate_subschema_on(schema::Node const * subschema, A const & document,
+  Status validate_subschema_on(schema::Node const * subschema, Adapter auto const & document,
                                K const & key) const {
     ValidationResult result;
 
     ValidationVisitor next = *this;
     next.where_ /= key;
-    std::tie(next.document_, next.schema_, next.result_, next.visited_) =
-        std::forward_as_tuple(document, subschema, result_ ? &result : nullptr, nullptr);
+    std::tie(next.schema_, next.result_, next.visited_) =
+        std::forward_as_tuple(subschema, result_ ? &result : nullptr, nullptr);
 
-    auto status = next.validate();
+    auto status = next.validate(document);
     if (status == Status::Accept and visited_) {
       VISITED(K).insert(key);
     }
     if (status == Status::Reject and result_) {
-      result_->add_error(std::move(result));
+      result_->merge(std::move(result));
     }
     return status;
   }

+ 100 - 19
include/jvalidate/validator.h

@@ -9,56 +9,137 @@
 #include <jvalidate/validation_visitor.h>
 
 namespace jvalidate::detail {
+/**
+ * @brief An implementation of a regular expression "engine", for use with
+ * constraints like "pattern" and "patternProperties".
+ * Uses std::regex as its underlying implementation.
+ *
+ * While being std::regex means that it is the most sensible choice for a
+ * default RegexEngine, the performance of std::regex is generally the worst
+ * among C++ regex utilities, and it struggles to compile several patterns.
+ * See https://stackoverflow.com/questions/70583395/ for an explaination.
+ *
+ * If you need to use complicated patterns in your json schema, provide a
+ * RegexEngine compatible wrapper for a different library, such as re2.
+ */
 class StdRegexEngine {
-public:
-  std::regex regex_;
+private:
+  std::unordered_map<std::string, std::regex> cache_;
 
 public:
-  StdRegexEngine(std::string const & regex) : regex_(detail::regex_escape(regex)) {}
-  bool search(std::string const & text) const { return std::regex_search(text, regex_); }
-
   static bool is_valid(std::string const & regex) {
     try {
-      std::regex{regex};
+      [[maybe_unused]] std::regex _{regex};
       return true;
     } catch (...) { return false; }
   }
+
+  bool search(std::string const & regex, std::string const & text) {
+    // TODO: detail::regex_escape
+    auto const & re = cache_.try_emplace(regex, regex).first->second;
+    return std::regex_search(text, re);
+  }
 };
+
+/**
+ * @brief An implementation of an "Extension Constraint Visitor" plugin that
+ * does nothing.
+ */
+struct StubExtensionVisitor {};
 }
 
 namespace jvalidate {
-template <RegexEngine RE = detail::StdRegexEngine> class ValidatorT {
+/**
+ * @brief A validator is the tool by which a JSON object is actually validated
+ * against a schema.
+ *
+ * @tparam RE A type that can be used to solve regular expressions
+ */
+template <RegexEngine RE = detail::StdRegexEngine,
+          typename ExtensionVisitor = detail::StubExtensionVisitor>
+class Validator {
 private:
   schema::Node const & schema_;
   ValidationConfig cfg_;
-  std::unordered_map<std::string, RE> regex_cache_;
+  ExtensionVisitor extension_;
+  RE regex_;
 
 public:
-  ValidatorT(schema::Node const & schema, ValidationConfig const & cfg = {})
+  /**
+   * @brief Construct a Validator
+   *
+   * @param schema The root schema being validated against. Must outlive this.
+   *
+   * @param cfg Any special (runtime) configuration rules being applied to the
+   * validator.
+   */
+  Validator(schema::Node const & schema, ExtensionVisitor extension = {},
+            ValidationConfig const & cfg = {})
+      : schema_(schema), cfg_(cfg), extension_(extension) {}
+
+  Validator(schema::Node const & schema, ValidationConfig const & cfg)
       : schema_(schema), cfg_(cfg) {}
 
+  template <typename... Args> Validator(schema::Node &&, Args &&...) = delete;
+
+  /**
+   * @brief Run validation on the given JSON
+   *
+   * @tparam A Any Adapter type, in principle a subclass of adapter::Adapter.
+   * Disallows mutation via ValidationConfig.construct_default_values
+   *
+   * @param json The value being validated
+   *
+   * @param result An optional out-param of detailed information about
+   * validation failures. If result is not provided, then the validator will
+   * terminate on the first error. Otherwise it will run through the entire
+   * schema to provide a record of all of the failures.
+   */
   template <Adapter A>
-  requires(not MutableAdapter<A>) bool validate(A const & json,
-                                                ValidationResult * result = nullptr) {
+    requires(not MutableAdapter<A>)
+  bool validate(A const & json, ValidationResult * result = nullptr) {
     EXPECT_M(not cfg_.construct_default_values,
              "Cannot perform mutations on an immutable JSON Adapter");
     return static_cast<bool>(
-        ValidationVisitor<A, RE>(json, schema_, cfg_, regex_cache_, result).validate());
+        ValidationVisitor(schema_, cfg_, regex_, extension_, result).validate(json));
   }
 
+  /**
+   * @brief Run validation on the given JSON
+   *
+   * @tparam A Any Adapter type that supports assignment, in principle a
+   * subclass of adapter::Adapter.
+   *
+   * @param json The value being validated. Because A is a reference-wrapper,
+   * the underlying value may be mutated.
+   *
+   * @param result An optional out-param of detailed information about
+   * validation failures. If result is not provided, then the validator will
+   * terminate on the first error. Otherwise it will run through the entire
+   * schema to provide a record of all of the failures.
+   */
   template <MutableAdapter A> bool validate(A const & json, ValidationResult * result = nullptr) {
     return static_cast<bool>(
-        ValidationVisitor<A, RE>(json, schema_, cfg_, regex_cache_, result).validate());
+        ValidationVisitor(schema_, cfg_, regex_, extension_, result).validate(json));
   }
 
+  /**
+   * @brief Run validation on the given JSON
+   *
+   * @tparam JSON A concrete JSON type. Will be turned into an Adapter, or a
+   * MutableAdapter (if json is non-const and exists).
+   *
+   * @param json The value being validated.
+   *
+   * @param result An optional out-param of detailed information about
+   * validation failures. If result is not provided, then the validator will
+   * terminate on the first error. Otherwise it will run through the entire
+   * schema to provide a record of all of the failures.
+   */
   template <typename JSON>
-  requires(not Adapter<JSON>) bool validate(JSON & json, ValidationResult * result = nullptr) {
+    requires(not Adapter<JSON>)
+  bool validate(JSON & json, ValidationResult * result = nullptr) {
     return validate(adapter::AdapterFor<JSON>(json), result);
   }
 };
-
-class Validator : public ValidatorT<> {
-public:
-  using Validator::ValidatorT::ValidatorT;
-};
 }

+ 119 - 0
include/jvalidate/vocabulary.h

@@ -0,0 +1,119 @@
+#pragma once
+
+#include <functional>
+#include <memory>
+#include <string_view>
+
+#include <jvalidate/forward.h>
+
+namespace jvalidate::detail {
+template <Adapter A> struct ParserContext;
+}
+
+namespace jvalidate::vocabulary {
+
+/**
+ * @brief Metadata tag for marking a keyword as no longer supported. This is
+ * needed because we store the constraints by version using
+ * std::map::lower_bound, instead of needing to pound them out for every version
+ * of the JSON Schema specification.
+ *
+ * While it is permitted to reuse keywords that have been removed, it should be
+ * avoided to minimize confusion.
+ */
+constexpr struct {
+} Removed;
+
+constexpr struct {
+} Literal;
+
+/**
+ * @brief Metadata tag for marking a keyword as containing either a single
+ * subschema, or an array of subschema (e.g. "not", "oneOf", etc.). When parsing
+ * a schema, we need to be able to identify these to search for "$id" and
+ * "$anchor" tags, as they can allow us to jump into otherwise unreachable
+ * sections of the schema.
+ */
+constexpr struct {
+} Keyword;
+
+/**
+ * @brief Metadata tag for marking a keyword as containing a map of names onto
+ * subschemas (e.g. "properties"). Because the keys in this node of the schema
+ * are arbitrary strings, we need to jump past them whe searching for the "$id"
+ * and "$anchor" tags.
+ * We cannot simply do a blind recursive-walk of the schema JSON, because you
+ * could put an "$id" tag in an "example" block, where it should not be scanned.
+ */
+constexpr struct {
+} KeywordMap;
+
+/**
+ * @brief Metadata tag for marking a keyword as needing to wait until after all
+ * other (non-PostConstraint) keywords are validated.
+ * This tag is used specifically to mark "unevaluatedItems" and
+ * "unevaluatedProperties", since the rules they use to decide where to run
+ * cannot be compiled the way that "additionalItems" and "additionalProperties"
+ * can.
+ */
+constexpr struct {
+} PostConstraint;
+
+/**
+ * @brief A Metadata tag for marking a keyword as participating as dependent on
+ * another keyword in order to generate annotations or validate instances.
+ * However - we still must evaluate the keyword for "$id" and "$anchor" tags,
+ * since jumping past the keyword is permissible.
+ * Currently the only example of this is the handling of "if"/"then"/"else",
+ * where the "then" and "else" clauses should not be directly used if the "if"
+ * clause is not present in the schema.
+ */
+struct DependentKeyword : std::string_view {};
+
+/**
+ * @brief This type represents a union of several different "parsing handler"
+ * states, aligned to a specific starting version:
+ * A) Annotation keywords
+ * A.1) Removed              - This keyword is no longer supported.
+ * A.2) Literal              - This is a pure annotation. e.g. "$comment".
+ * A.3) KeywordMap           - This keyword does not produce any constraints,
+ *      but must be evaluated for annotations and anchors. e.g. "$defs".
+ * A.4) DependentKeyword(id) - A keyword whose annotations are only evaluated
+ *      if the depended on keyword is also present in the current schema. It
+ *      may be connected to a constraint, but that constraint will be parsed
+ *      in the depended keyword.
+ *
+ * B) Constraint keywords
+ * B.1) Make                 - A parser that does not contain any subschemas.
+ * B.2) Make, Keyword        - A parser that contains either a single
+ *      subschema or an array of subschemas. e.g. "not", "oneOf".
+ * B.3) Make, KeywordMap     - A parser that contains a key-value mapping onto
+ *      subschemas. e.g. "properties".
+ * B.4) Make, PostConstraint - A parser whose constraint applies in
+ *      Unevaluated Locations (11). Assumed to be a single subschema.
+ */
+template <Adapter A> struct Metadata {
+  using pConstraint = std::unique_ptr<constraint::Constraint>;
+
+  Metadata(decltype(Removed)) {}
+  Metadata(decltype(Literal)) {}
+  Metadata(decltype(KeywordMap)) : is_keyword(true), is_keyword_map(true) {}
+  Metadata(DependentKeyword dep) : is_keyword(true) {}
+
+  template <typename F> Metadata(F make) : make(make) {}
+  template <typename F> Metadata(F make, decltype(Keyword)) : make(make), is_keyword(true) {}
+  template <typename F>
+  Metadata(F make, decltype(KeywordMap)) : make(make), is_keyword(true), is_keyword_map(true) {}
+  template <typename F>
+  Metadata(F make, decltype(PostConstraint))
+      : make(make), is_keyword(true), is_post_constraint(true) {}
+
+  explicit operator bool() const { return make || is_keyword; }
+  operator std::function<pConstraint(detail::ParserContext<A> const &)>() const { return make; }
+
+  std::function<pConstraint(detail::ParserContext<A> const &)> make = nullptr;
+  bool is_keyword = false;
+  bool is_keyword_map = false;
+  bool is_post_constraint = false;
+};
+}

+ 126 - 0
tests/annotation_test.cxx

@@ -0,0 +1,126 @@
+#include <string_view>
+
+#include <gmock/gmock.h>
+#include <gtest/gtest.h>
+
+#include <jvalidate/adapter.h>
+#include <jvalidate/adapters/jsoncpp.h>
+#include <jvalidate/enum.h>
+#include <jvalidate/schema.h>
+#include <jvalidate/status.h>
+#include <jvalidate/uri.h>
+#include <jvalidate/validation_result.h>
+#include <jvalidate/validator.h>
+
+#include "matchers.h"
+
+using enum jvalidate::schema::Version;
+using testing::Not;
+
+auto validate(Json::Value const & schema_doc, Json::Value const & instance_doc,
+              jvalidate::schema::Version version = Draft2020_12) {
+  jvalidate::Schema const schema(schema_doc, version);
+
+  jvalidate::ValidationResult result;
+  (void)jvalidate::Validator(schema).validate(instance_doc, &result);
+
+  return result;
+}
+
+TEST(Annotation, AttachesFormattingAnnotation) {
+  auto const schema = R"({
+    "format": "uri"
+  })"_json;
+
+  auto const instance = R"("http://json-schema.org")"_json;
+
+  jvalidate::ValidationResult result = validate(schema, instance);
+
+  EXPECT_THAT(result, AnnotationAt("format", "uri"));
+}
+
+TEST(Annotation, AnnotatesErrors) {
+  auto const schema = R"({
+    "minimum": 5
+  })"_json;
+
+  auto const instance = R"(4)"_json;
+
+  jvalidate::ValidationResult result = validate(schema, instance);
+
+  EXPECT_THAT(result, ErrorAt("minimum", "4 < 5"));
+}
+
+TEST(Annotation, DoesNotAnnotatesValid) {
+  auto const schema = R"({
+    "minimum": 5
+  })"_json;
+
+  auto const instance = R"(6)"_json;
+
+  jvalidate::ValidationResult result = validate(schema, instance);
+
+  EXPECT_THAT(result, Not(HasAnnotationsFor(""_jptr)));
+}
+
+TEST(Annotation, NotSchemaFlipsAnnotationRule) {
+  auto const schema = R"({
+    "not": { "minimum": 5 }
+  })"_json;
+
+  auto const instance = R"(6)"_json;
+
+  jvalidate::ValidationResult result = validate(schema, instance);
+
+  EXPECT_THAT(result, ErrorAt(""_jptr, "/not"_jptr, "minimum", "6 >= 5"));
+}
+
+TEST(Annotation, PathFollowsSchemaNotConstraintModel) {
+  auto const schema = R"({
+    "$comment": "disallow is implemented in the form of NotConstraint[TypeConstraint]",
+    "disallow": "string"
+  })"_json;
+
+  auto const instance = R"("hello")"_json;
+
+  jvalidate::ValidationResult result = validate(schema, instance, Draft03);
+
+  EXPECT_THAT(result, ErrorAt("disallow", "string is in types [string]"));
+}
+
+TEST(Annotation, SomeConstraintsAnnotateBothValidAndInvalid) {
+  auto const schema = R"({
+    "$comment": "accepts any number <= 0 or >= 10",
+    "oneOf": [
+      { "minimum": 10 },
+      { "maximum": 0 }
+    ]
+  })"_json;
+
+  auto const instance = R"(-1)"_json;
+  jvalidate::ValidationResult result = validate(schema, instance);
+
+  EXPECT_THAT(result, Not(HasAnnotationAt(""_jptr, "/oneOf"_jptr)));
+  EXPECT_THAT(result, ErrorAt(""_jptr, "/oneOf/0"_jptr, "minimum", "-1 < 10"));
+  EXPECT_THAT(result, ErrorAt(""_jptr, "/oneOf/1"_jptr, "maximum", "-1 <= 0"));
+}
+
+TEST(Annotation, AttachesAlwaysFalseSensibly) {
+  auto const schema = R"({
+    "properties": {
+      "A": false
+    }
+  })"_json;
+
+  auto const instance = R"({
+    "A": null
+  })"_json;
+  jvalidate::ValidationResult result = validate(schema, instance);
+
+  EXPECT_THAT(result, ErrorAt("/A"_jptr, "/properties"_jptr, "", "always false"));
+}
+
+int main(int argc, char ** argv) {
+  testing::InitGoogleMock(&argc, argv);
+  return RUN_ALL_TESTS();
+}

+ 34 - 0
tests/custom_filter.h

@@ -0,0 +1,34 @@
+#pragma once
+
+#include <algorithm>
+#include <regex>
+#include <string>
+#include <vector>
+
+class Glob {
+private:
+  std::regex re_;
+
+public:
+  explicit Glob(std::string glob) {
+    for (size_t i = glob.find('.'); i != std::string::npos; i = glob.find('.', i + 2)) {
+      glob.insert(glob.begin() + i, '\\');
+    }
+    for (size_t i = glob.find('*'); i != std::string::npos; i = glob.find('*', i + 2)) {
+      glob.insert(glob.begin() + i, '.');
+    }
+    re_ = std::regex(glob);
+  }
+
+  bool operator==(std::string const & str) const { return std::regex_search(str, re_); }
+};
+
+struct RecursiveTestFilter {
+  std::vector<Glob> whitelist;
+  std::vector<Glob> blacklist;
+
+  bool accepts(std::string const & str) const {
+    return std::count(blacklist.begin(), blacklist.end(), str) == 0 and
+           (whitelist.empty() or std::count(whitelist.begin(), whitelist.end(), str) > 0);
+  }
+};

+ 149 - 0
tests/extension_test.cxx

@@ -0,0 +1,149 @@
+#include <jvalidate/extension.h>
+
+#include <gmock/gmock.h>
+#include <gtest/gtest.h>
+#include <json/value.h>
+
+#include <jvalidate/adapters/jsoncpp.h>
+#include <jvalidate/constraint/extension_constraint.h>
+#include <jvalidate/detail/expect.h>
+#include <jvalidate/detail/relative_pointer.h>
+#include <jvalidate/forward.h>
+#include <jvalidate/status.h>
+#include <jvalidate/validator.h>
+
+#include "matchers.h"
+
+using enum jvalidate::schema::Version;
+using jvalidate::Status;
+using jvalidate::constraint::ExtensionConstraint;
+using testing::Not;
+
+struct IsKeyOfConstraint : jvalidate::extension::ConstraintBase<IsKeyOfConstraint> {
+  IsKeyOfConstraint(std::string_view ptr) : ptr(ptr) {
+    EXPECT_M(ptr.find('/') != std::string_view::npos,
+             "IsKeyOfConstraint requires a value-relative-pointer, not a key-relative-pointer");
+  }
+
+  jvalidate::detail::RelativePointer ptr;
+};
+
+template <jvalidate::Adapter A>
+class Visitor : public jvalidate::extension::Visitor<Visitor<A>, IsKeyOfConstraint> {
+public:
+  Visitor(A const & root_document) : root_document_(root_document) {}
+
+  template <jvalidate::Adapter A2>
+  Status visit(IsKeyOfConstraint const & cons, A2 const & document, auto const & validator) const {
+    validator.annotate(cons.ptr);
+    auto const & object =
+        std::get<1>(cons.ptr.inspect(validator.where_, root_document_)).as_object();
+    if (object.find(document.as_string()) != object.end()) {
+      return Status::Accept;
+    }
+    return Status::Reject;
+  }
+
+private:
+  A root_document_;
+};
+
+auto validate(Json::Value const & schema_doc, Json::Value const & instance_doc,
+              jvalidate::schema::Version version = Draft2020_12) {
+  using A = jvalidate::adapter::AdapterFor<Json::Value const>;
+  jvalidate::ConstraintFactory<A> factory{{"is_key_of", [](auto const & context) {
+                                             return ExtensionConstraint::make<IsKeyOfConstraint>(
+                                                 context.schema.as_string());
+                                           }}};
+  jvalidate::Schema const schema(schema_doc, version, factory);
+
+  jvalidate::ValidationResult result;
+  (void)jvalidate::Validator(schema, Visitor(A(instance_doc))).validate(instance_doc, &result);
+
+  return result;
+}
+
+TEST(ExtensionConstraint, CanReportSuccess) {
+  auto schema = R"({
+    "properties": {
+      "nodes": {
+        "type": "object"
+      },
+      "edges": {
+        "items": {
+          "properties": {
+            "destination": {
+              "is_key_of": "3/nodes",
+              "type": "string"
+            },
+            "source": {
+              "is_key_of": "3/nodes",
+              "type": "string"
+            }
+          },
+          "type": "object"
+        },
+        "type": "array"
+      }
+    },
+    "type": "object"
+  })"_json;
+
+  auto instance = R"({
+    "nodes": {
+      "A": {},
+      "B": {}
+    },
+    "edges": [
+      { "source": "A", "destination": "B" }
+    ]
+  })"_json;
+
+  jvalidate::ValidationResult result = validate(schema, instance);
+  EXPECT_THAT(result, Valid());
+}
+
+TEST(ExtensionConstraint, CanReportFailure) {
+  auto schema = R"({
+    "properties": {
+      "nodes": {
+        "type": "object"
+      },
+      "edges": {
+        "items": {
+          "properties": {
+            "destination": {
+              "is_key_of": "3/nodes",
+              "type": "string"
+            },
+            "source": {
+              "is_key_of": "3/nodes",
+              "type": "string"
+            }
+          },
+          "type": "object"
+        },
+        "type": "array"
+      }
+    },
+    "type": "object"
+  })"_json;
+
+  auto instance = R"({
+    "nodes": {
+      "A": {},
+      "B": {}
+    },
+    "edges": [
+      { "source": "A", "destination": "C" }
+    ]
+  })"_json;
+
+  jvalidate::ValidationResult result = validate(schema, instance);
+  EXPECT_THAT(result, Not(Valid()));
+}
+
+int main(int argc, char ** argv) {
+  testing::InitGoogleMock(&argc, argv);
+  return RUN_ALL_TESTS();
+}

+ 64 - 0
tests/matchers.h

@@ -0,0 +1,64 @@
+#include <gmock/gmock.h>
+#include <gtest/gtest.h>
+
+#include <jvalidate/detail/pointer.h>
+#include <jvalidate/validation_result.h>
+
+#include <json/reader.h>
+#include <json/value.h>
+
+inline auto operator""_jptr(char const * data, size_t len) {
+  return jvalidate::detail::Pointer(std::string_view{data, len});
+}
+
+inline Json::Value operator""_json(char const * data, size_t len) {
+  Json::Value value;
+
+  Json::CharReaderBuilder builder;
+  std::unique_ptr<Json::CharReader> reader(builder.newCharReader());
+
+  std::string error;
+  if (not reader->parse(data, data + len, &value, &error)) {
+    throw std::runtime_error(error);
+  }
+
+  return value;
+}
+
+MATCHER(Valid, "") { return arg.valid(); }
+
+MATCHER_P(HasAnnotationsFor, doc_path, "") { return arg.has(doc_path); }
+
+MATCHER_P2(HasAnnotationAt, doc_path, schema_path, "") { return arg.has(doc_path, schema_path); }
+
+MATCHER_P2(AnnotationAt, key, matcher, "") {
+  auto const * anno = arg.annotation({}, {}, key);
+  if (not anno) {
+    return false;
+  }
+  return testing::ExplainMatchResult(matcher, *anno, result_listener);
+}
+
+MATCHER_P4(AnnotationAt, doc_path, schema_path, key, matcher, "") {
+  auto const * anno = arg.annotation(doc_path, schema_path, key);
+  if (not anno) {
+    return false;
+  }
+  return testing::ExplainMatchResult(matcher, *anno, result_listener);
+}
+
+MATCHER_P2(ErrorAt, key, matcher, "") {
+  auto const * anno = arg.error({}, {}, key);
+  if (not anno) {
+    return false;
+  }
+  return testing::ExplainMatchResult(matcher, *anno, result_listener);
+}
+
+MATCHER_P4(ErrorAt, doc_path, schema_path, key, matcher, "") {
+  auto const * anno = arg.error(doc_path, schema_path, key);
+  if (not anno) {
+    return false;
+  }
+  return testing::ExplainMatchResult(matcher, *anno, result_listener);
+}

+ 1 - 30
tests/selfvalidate_test.cxx

@@ -3,8 +3,6 @@
 #include <filesystem>
 #include <fstream>
 #include <iostream>
-#include <regex>
-#include <unordered_set>
 
 #include <curl/curl.h>
 #include <gmock/gmock.h>
@@ -22,40 +20,13 @@
 #include <json/value.h>
 #include <json/writer.h>
 
+#include "./custom_filter.h"
 #include "./json_schema_test_suite.h"
 
 using jvalidate::schema::Version;
 
 using testing::TestWithParam;
 
-class Glob {
-private:
-  std::regex re_;
-
-public:
-  explicit Glob(std::string glob) {
-    for (size_t i = glob.find('.'); i != std::string::npos; i = glob.find('.', i + 2)) {
-      glob.insert(glob.begin() + i, '\\');
-    }
-    for (size_t i = glob.find('*'); i != std::string::npos; i = glob.find('*', i + 2)) {
-      glob.insert(glob.begin() + i, '.');
-    }
-    re_ = std::regex(glob);
-  }
-
-  bool operator==(std::string const & str) const { return std::regex_search(str, re_); }
-};
-
-struct RecursiveTestFilter {
-  std::vector<Glob> whitelist;
-  std::vector<Glob> blacklist;
-
-  bool accepts(std::string const & str) const {
-    return std::count(blacklist.begin(), blacklist.end(), str) == 0 and
-           (whitelist.empty() or std::count(whitelist.begin(), whitelist.end(), str) > 0);
-  }
-};
-
 bool load_stream(std::istream & in, Json::Value & out) {
   Json::CharReaderBuilder builder;
   std::string error;