10 Commits bf0587fd57 ... ec36ba19eb

Auteur SHA1 Bericht Datum
  Sam Jaffe ec36ba19eb fix: .value() should not be noexcept 2 weken geleden
  Sam Jaffe 2161a7d369 Merge branch 'master' into refactor/expected 2 weken geleden
  Sam Jaffe f050c8693c refactor: move formatting helpers into detail/iostream.h 2 weken geleden
  Sam Jaffe bd0bdbe8a5 refactor: remove friending of ExtensionVisitor in favor of a better API 2 weken geleden
  Sam Jaffe c41337805c refactor: store root document in ValidationVisitor, so that ExtensionVisitor no longer needs to capture state 2 weken geleden
  Sam Jaffe 5080d27ddf fix: add concepts to detail/number.h 2 weken geleden
  Sam Jaffe 684ea3fd55 docs: README.md 2 weken geleden
  Sam Jaffe d2b7cba801 docs: add more comments 2 weken geleden
  Sam Jaffe 8c30e1cf80 fix: proper formulation of RelativePointer 2 weken geleden
  Sam Jaffe 39f9f49dab fix: inverted condition 2 weken geleden

+ 228 - 0
README.md

@@ -0,0 +1,228 @@
+# JSON Validator
+
+A Header-Only JSON Schema Validator library written in C++20.
+Provides json schema validation compliant with the [json schema][1]
+  specification.  
+Is compatible with the following draft versions:
+* [draft2020-12][2]
+* [draft2019-09][3]
+* [draft-07][4]
+* [draft-06][5]
+* [draft-05][6]
+* [draft-04][7]
+* [draft-03][8]
+
+## Building
+
+``` bash
+cmake -S . -B build [options...]
+make -C build
+ctest --test-dir build/tests
+```
+
+If, for example, you have ICU provided via homebrew,
+  you will need to add the homebrew paths to your cmake command.
+
+For example: `-DICU_ROOT=/opt/homebrew/opt/icu4c`
+
+## Usage
+
+There are three main components for json validation,
+  plus an additional customization point:
+1. The adapter between `jvalidate` and the user's JSON type: `jvalidate::Adapter`
+1. The schema object: `jvalidate::Schema`
+1. The validator object: `jvalidate::Validator`
+1. User-defined constraints: `jvalidate::extension::ConstraintBase` and
+   `jvalidate::extension::Visitor`.
+
+### Creating an Adapter for custom JSON Types
+
+An adapter represents the following interface/contract for
+  a json implementation `MyJsonType`:
+* A free function with the signature
+  `bool load_stream(std::istream &, MyJsonType &, std::string &error) noexcept`
+* An implementation of `jvalidate::adapter::AdapterTraits<MyJsonType>`,
+  which must provide the following:
+    * `template <typename T> using Adapter = MyJsonTypeAdapter<T>`
+    * `using ConstAdapter = MyJsonTypeAdapter<MyJsonType const>`
+    * `static MyJsonType const &const_empty() { ... }`
+* An adapter class implementation that subclasses
+  `jvalidate::adapter::Adapter` and
+  fulfills the contract `jvalidate::Adapter`.
+    * By convention, the adapter class should not have ownership of the
+      underlying object.
+* An adapter class implementation that fulfills the contract
+  `jvalidate::ArrayAdapter`
+* An adapter class implementation that fulfills the contract
+  `jvalidate::ObjectAdapter`
+
+### Constructing a Schema
+
+The schema class describes how to validate a json document,
+  but does not contain the business logic to perform the validation.
+
+The simplest constructor for a schema has the following signature,
+  and uses `jvalidate::adapter::AdapterTraits` to deduce the adapter
+  that should wrap the document.
+``` c++
+MyJsonType schema_document = ...;
+jvalidate::Schema schema(schema_document, jvalidate::schema::Version::Draft2020_12);
+```
+
+Schemas also support additional components, which can be provided in any order.
+
+#### URI Resolver
+
+A URIResolver is a free function with the following signature:
+  `bool (*)(jvalidate::URI const &, MyJsonType &, std::string &error) noexcept`.
+
+If no uri resolver is provided,
+  then it is not possible to read schemas from the internet or from files.
+It is also not possible to read custom vocabularies.
+
+A curl based resolver is provided in `include/jvalidate/compat/curl.h`.
+
+#### Constraint Factory
+
+A ConstraintFactory is the object that maps keywords to Constraint objects.
+Because it is possible to change schema versions when following a reference,
+  ConstraintFactory *MUST* provide information on all schema drafts at once.
+
+There are two ways to provide user-defined keywords:
+1. The constructor `ConstraintFactory(std::initializer_list<std::pair<std::string_view, Versioned>> init)`
+1. An append function `ConstraintFactory::with_user_keyword(std::string_view, Versioned)`
+
+For example:
+``` c++
+jvalidate::Schema schema(
+    schema_document, jvalidate::schema::Version::Draft2020_12,
+    jvalidate::ConstraintFactory()
+      .with_user_keyword("my_keyword", create_my_constraint));
+```
+
+<!-- @see jvalidate::ConstraintFactory::Versioned -->
+In order to support multiple schema versions in a single instance of
+a ConstraintFactory, we need to be able to describe which version a keyword
+becomes part of the language vocabulary, and what (if any) version it
+leaves the vocabulary after.
+
+To do this, we store an ordered map of Version enum onto a vocabulary Metadata
+object and then use `std::map::lower_bound` to determine which one is the most
+appropriate for the schema version being evaluated.
+
+For example:
+* The "additionalProperties" constraint is the same across all versions, and
+  so can be represented using only a function pointer.
+  ```
+  {"additionalProperties", &Self::additionalProperties}
+  ```
+* The "const" constraint was not added until Draft06, so we include the
+  version when constructing its constraint bindings like so:
+  ```
+  {"const", {schema::Version::Draft06, &Self::isConstant}}
+  ```
+* The "divisibleBy" constraint was removed in favor of "multipleOf" in
+  Draft04, and therefore is represented as:
+  ```
+  {"divisibleBy", {{schema::Version::Earliest, &Self::multipleOf},
+                   {schema::Version::Draft04, Removed}}},
+  {"multipleOf",  {schema::Version::Draft04, &Self::multipleOf}}
+  ```
+* A small number of rare constraints change their meaning when moving from
+  one draft version to another in such a significant way that it makes more
+  sense to use different MakeConstraint functions for them.
+  ```
+  {"items", {{schema::Version::Earliest, &Self::itemsTupleOrVector},
+             {schema::Version::Draft2020_12, &Self::additionalItems}}}
+  ```
+* Reserved keywords that have no meaning by themselves can use the Literal
+  rule:
+  ```
+  {"contains", {schema::Version::Draft06, &Self::contains}},
+  {"maxContains", {schema::Version::Draft06, Literal}},
+  {"minContains", {schema::Version::Draft06, Literal}},
+  ```
+* Since some special words like "default", "examples", "enum", etc. may
+  contain objects which should not be evaluated as JSON schemas for things
+  like "$id" tokens, the rules Keyword and KeywordMap allow marking only
+  those keywords that are expected to hold more json schemas to be evaluated:
+  ```
+  {"$defs", {schema::Version::Draft2019_09, KeywordMap}},
+  {"additionalProperties", {{&Self::additionalProperties, Keyword}}},
+  {"allOf", {schema::Version::Draft04, {&Self::allOf, Keyword}}},
+  ```
+
+### Validating a Document
+
+Validation is performed by constructing a Validator object,
+  and calling the `validate()` function.
+The validator does not own the schema node that it operates on,
+  but it does own a copy of the provided ExtensionVisitor (if any),
+  and of the RegexEngine that it maintains internally.
+Reusing the Validator can allow for caching of regular expressions,
+  if they are commonly used in the schema.
+
+There are two main ways to call `Validator::validate`,
+  with or without a `jvalidate::ValidationResult` object.
+If validate is called without a result,
+  then the evaluation will end immediately after
+  the first rejecting constraint.
+Otherwise,
+  it will return all evaluation reasons.
+
+### Creating custom extensions
+
+Custom extensions allow the implementation of user-specific constraints that
+  either cannot naturally be described in the json schema specification
+  or are used commonly enough that the DRY principle applies.
+Some virtual function magic is used to unwrap all of the type-erasure that
+  occurs does not require the consumer to write any boilerplate.
+
+A user constraint is defined as a simple struct with the following form:
+``` c++
+struct MyCustomConstraint : jvalidate::extension::ConstraintBase<MyCustomConstraint> {
+  MyCustomConstraint(...) { ... } // Required b/c we're a subclass
+
+  // fields...
+};
+```
+
+It is added to the Schema as a new keyword through the ConstraintFactory.
+Much like builtin constraints,
+  context can be used to extract values from the schema json,
+  or to evaluate child schemas.
+``` c++
+jvalidate::ConstraintFactory factory{
+  {"my_keyword", [](auto const & context) {
+      return ExtensionConstraint::make<MyCustomConstraint>(...);
+   }},
+};
+```
+
+A validator is created as follows,
+  and then is passed in as the ExtensionVisitor argument to Validator.
+``` c++
+class Visitor : jvalidate::extension::Visitor<Visitor, MyCustomConstraint, ...> {
+public:
+  // One of these for every constraint in the template signature
+  Status visit(MyCustomConstraint const & cons,
+               jvalidate::Adapter auto const & document,
+               auto const &validator) const;
+};
+```
+
+In `tests/extension_test.cxx`,
+  an example demonstrating a json schema for a graph
+  which requires each edge's source and destination are
+  nodes in the graph.
+
+<!-- Footnotes/Appendix -->
+
+[1]: https://json-schema.org/specification
+[2]: https://json-schema.org/draft/2020-12
+[3]: https://json-schema.org/draft/2019-09
+[4]: https://json-schema.org/draft-07
+[5]: https://json-schema.org/draft-06
+[6]: https://json-schema.org/draft-05
+[7]: https://json-schema.org/draft-04/draft-zyp-json-schema-04
+[8]: https://json-schema.org/draft-03/draft-zyp-json-schema-03.pdf

+ 3 - 3
include/jvalidate/compat/expected.h

@@ -146,21 +146,21 @@ public:
   constexpr T & operator*() & noexcept { return *std::get_if<0>(&value_); }
   constexpr T && operator*() && noexcept { return std::move(*std::get_if<0>(&value_)); }
 
-  constexpr const T & value() const & noexcept {
+  constexpr const T & value() const & {
     if (JVALIDATE_LIKELY(has_value())) {
       return operator*();
     }
     throw bad_expected_access(error());
   }
 
-  constexpr T & value() & noexcept {
+  constexpr T & value() & {
     if (JVALIDATE_LIKELY(has_value())) {
       return operator*();
     }
     throw bad_expected_access(std::as_const(error()));
   }
 
-  constexpr T && value() && noexcept {
+  constexpr T && value() && {
     if (JVALIDATE_LIKELY(has_value())) {
       return std::move(*this).operator*();
     }

+ 18 - 3
include/jvalidate/constraint.h

@@ -57,9 +57,10 @@ public:
    * becomes part of the language vocabulary, and what (if any) version it
    * leaves the vocabulary after.
    *
-   * To do this, we store an ordered map of Version enum onto Make (see above),
-   * and then use {@see std::map::lower_bound} to determine which Make object is
-   * the most approriate for the schema version being evaluated.
+   * To do this, we store an ordered map of Version enum onto
+   * {@see jvalidate::vocabulary::Metadata} and then use
+   * {@see std::map::lower_bound} to determine which Metadata object is the
+   * most appropriate for the schema version being evaluated.
    *
    * For example:
    * The "additionalProperties" constraint is the same across all versions, and
@@ -81,6 +82,20 @@ public:
    * sense to use different MakeConstraint functions for them.
    *   {"items", {{schema::Version::Earliest, &Self::itemsTupleOrVector},
    *              {schema::Version::Draft2020_12, &Self::additionalItems}}}
+   *
+   * Reserved keywords that have no meaning by themselves can use the Literal
+   * rule:
+   *   {"contains", {schema::Version::Draft06, &Self::contains}},
+   *   {"maxContains", {schema::Version::Draft06, Literal}},
+   *   {"minContains", {schema::Version::Draft06, Literal}},
+   *
+   * Since some special words like "default", "examples", "enum", etc. may
+   * contain objects which should not be evaluated as JSON schemas for things
+   * like "$id" tokens, the rules Keyword and KeywordMap allow marking only
+   * those keywords that are expected to hold more json schemas to be evaluated:
+   *   {"$defs", {schema::Version::Draft2019_09, KeywordMap}},
+   *   {"additionalProperties", {{&Self::additionalProperties, Keyword}}},
+   *   {"allOf", {schema::Version::Draft04, {&Self::allOf, Keyword}}},
    */
   struct Versioned {
     template <typename M = vocabulary::Metadata<A>>

+ 27 - 0
include/jvalidate/detail/iostream.h

@@ -2,6 +2,7 @@
 
 #include <iostream>
 #include <set>
+#include <sstream>
 #include <unordered_set>
 
 #include <jvalidate/enum.h>
@@ -83,4 +84,30 @@ inline std::ostream & operator<<(std::ostream & os, std::unordered_set<T> const
   }
   return os << ' ' << ']';
 }
+
+template <typename S>
+  requires(std::is_constructible_v<std::string, S>)
+// Optimization to avoid running string-like objects through a
+// std::stringstream in fmtlist.
+static std::string to_string(S const & str) {
+  return std::string(str);
+}
+
+// Format va_args into a single string to annotate or mark an error message
+static std::string to_string(auto const &... args) {
+  std::stringstream ss;
+  using ::jvalidate::operator<<;
+  [[maybe_unused]] int _[] = {(ss << args, 0)...};
+  return ss.str();
+}
+
+// Format an iterable argument into a vector of strings to annotate or mark
+// an error.
+static std::vector<std::string> to_string_list(auto const & arg) {
+  std::vector<std::string> strs;
+  for (auto const & elem : arg) {
+    strs.push_back(::jvalidate::to_string(elem));
+  }
+  return strs;
+}
 }

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

@@ -10,6 +10,7 @@
 
 #include <charconv>
 #include <cmath>
+#include <concepts>
 #include <limits>
 #include <string_view>
 #include <system_error>

+ 6 - 9
include/jvalidate/detail/relative_pointer.h

@@ -33,29 +33,26 @@ public:
     }
 
     RelativePointer rval;
-    if (auto pos = path.find('/'); pos != path.npos) {
+    if (size_t pos = path.find('/'); pos != path.npos) {
       // Handle the JSON-Pointer version
       expected ptr = Pointer::parse(path.substr(pos));
       JVALIDATE_PROPIGATE_UNEXPECTED(ptr);
       rval.pointer_ = *std::move(ptr);
       path.remove_suffix(path.size() - pos);
-    } else if (path.back() == '#') {
+    } else if (path.ends_with('#')) {
       rval.requests_key_ = true;
       path.remove_suffix(1);
     }
 
-    if (path.find_first_not_of("0123456789") != std::string_view::npos) {
-      return unexpected("RelativePointer must end in a pointer, or a '#'");
+    if (path.starts_with('0') && path != "0") {
+      return unexpected("Cannot zero-prefix a relative pointer");
     }
 
     size_t read = 0;
     expected parent_steps = parse_integer<size_t>(path, {.read = read});
-
-    JVALIDATE_PROPIGATE_UNEXPECTED(parent_steps.transform_error(to_message));
-    EXPECT_M(read == path.size(), "Extra chars in RelativePointer");
-
-    // Will throw an exception if path contains any non-numeric characters, or
+    // Will return unexpected if path contains any non-numeric characters, or
     // if path represents a number that is out of the bounds of a size_t.
+    JVALIDATE_PROPIGATE_UNEXPECTED(parent_steps.transform_error(to_message));
     rval.parent_steps_ = *parent_steps;
     return rval;
   }

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

@@ -12,6 +12,15 @@
 #include <jvalidate/status.h>
 
 namespace jvalidate::detail {
+/**
+ * @brief An ArrayAdapter implmenetation for JSON "types" which do not support
+ * arrays. This is for example caused when attempting to apply json schema
+ * validation to non-json types representation, such as a some forms of
+ * PropertyTree implementations.
+ *
+ * This is specifically provided for making StringAdapter compatible with the
+ * Adapter concept.
+ */
 template <typename CRTP> class UnsupportedArrayAdapter {
 public:
   size_t size() const { return 0; }
@@ -20,6 +29,14 @@ public:
   std::vector<CRTP>::const_iterator end() const { return {}; }
 };
 
+/**
+ * @brief An ObjectAdapter implmenetation for JSON "types" which do not support
+ * objects. This is for example caused when attempting to apply json schema
+ * validation to non-json types representation.
+ *
+ * This is specifically provided for making StringAdapter compatible with the
+ * Adapter concept.
+ */
 template <typename CRTP> class UnsupportedObjectAdapter {
 public:
   size_t size() const { return 0; }
@@ -29,6 +46,13 @@ public:
   std::map<std::string, CRTP>::const_iterator end() const { return {}; }
 };
 
+/**
+ * @brief An Adapter for strings, required for implmenting propertyNames
+ * constraints, which are applied to the keys of a JSON object.
+ *
+ * Unfortunately requires a large number of stub function implementations in
+ * order to satisfy adapter::Adapter AND Adapter concept.
+ */
 class StringAdapter final : public adapter::Adapter {
 public:
   using value_type = std::string_view;

+ 1 - 0
include/jvalidate/enum.h

@@ -29,6 +29,7 @@ enum class Version : int {
   // Formats: date-time, email, hostname, ipv4, ipv6, uri
   // https://json-schema.org/draft-04/schema
   Draft04,
+  Draft05 = Draft04, // draft-05 simply clarifies the terms of draft-04
 
   // New: "boolean as schema", propertyNames, contains, const
   // Changed: $ref, exclusiveMinimum, exclusiveMaximum, type, required, dependencies, examples

+ 1 - 1
include/jvalidate/forward.h

@@ -190,7 +190,7 @@ namespace jvalidate {
 template <Adapter A> class ConstraintFactory;
 template <Adapter A> class DocumentCache;
 
-template <RegexEngine RE, typename ExtensionVisitor> class ValidationVisitor;
+template <Adapter Root, RegexEngine RE, typename ExtensionVisitor> class ValidationVisitor;
 
 template <RegexEngine RE, typename ExtensionVisitor> class Validator;
 

+ 25 - 0
include/jvalidate/regex.h

@@ -24,6 +24,11 @@ namespace jvalidate {
  *
  * If you need to use complicated patterns in your json schema, provide a
  * RegexEngine compatible wrapper for a different library, such as re2.
+ * std::regex does not support graphemes, meaning that multi-byte characters
+ * will need to wrapped in groups if you want to repeat them.
+ *
+ * Regular expressions are compiled using the default ECMAScript flags, which
+ * is almost, but not quite, compliant with the ECMA-262 standard.
  */
 class StdRegexEngine {
 private:
@@ -48,6 +53,26 @@ public:
 
 #if JVALIDATE_HAS_ICU
 namespace jvalidate {
+/**
+ * @brief An implementation of a regular expression "engine", for use with
+ * constraints like "pattern" and "patternProperties".
+ * Uses the "International Components for Unicode" (icu4c) library for its
+ * underlying implementation.
+ *
+ * These regular expressions operate on the level of graphemes, rather than
+ * characters. This means that multi-byte characters like emojis will be
+ * treated as singular characters for the purpose of "character sets" and
+ * repetition operators.
+ *
+ * This regex engine is not ECMA-262 compliant, which means that certain cases
+ * will not be recognized. This is a notice rather than a true issue, since
+ * many other languages' regex libraries (e.g. Python) are also not ECMA-262
+ * compliant.
+ *
+ * This means that we pass test cases that ECMAScript rejects, such as:
+ * - i18n digit characters are captured by \\d
+ * - i18n characters can be matched by \\w (if they are i18nword chars)
+ */
 class ICURegexEngine {
 private:
   std::unordered_map<std::string, std::unique_ptr<icu::RegexPattern>> cache_;

+ 1 - 1
include/jvalidate/validation_result.h

@@ -13,7 +13,7 @@ namespace jvalidate {
 class ValidationResult {
 public:
   // Only allow ValidationVisitor to construct the elements of a validation result
-  template <RegexEngine, typename> friend class ValidationVisitor;
+  template <Adapter, RegexEngine, typename> friend class ValidationVisitor;
 
   using DocPointer = detail::Pointer;
   using SchemaPointer = detail::Pointer;

+ 59 - 45
include/jvalidate/validation_visitor.h

@@ -45,18 +45,29 @@
     }                                                                                              \
   } while (false)
 
+#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...));              \
+    }                                                                                              \
+  }
+
 namespace jvalidate {
-template <RegexEngine RE, typename ExtensionVisitor> class ValidationVisitor {
+template <Adapter Root, 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:
   detail::Pointer where_;
   detail::Pointer schema_path_;
 
   schema::Node const * schema_;
+  Root const * root_;
 
   ValidationResult * result_;
 
@@ -79,10 +90,12 @@ public:
    * @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) {}
+  ValidationVisitor(schema::Node const & schema, Root const & root, ValidationConfig const & cfg,
+                    RE & regex, ExtensionVisitor extension, ValidationResult * result)
+      : schema_(&schema), root_(&root), result_(result), cfg_(cfg), extension_(extension),
+        regex_(regex) {}
 
+private:
   Status visit(constraint::ExtensionConstraint const & cons, Adapter auto const & document) const {
     // Because we don't provide any contract constraint on our ExtensionVisitor,
     // we instead defer it to here where we validate that the extension can be
@@ -567,8 +580,8 @@ public:
     return rval;
   }
 
-  template <Adapter A>
-  Status visit(constraint::PropertyNamesConstraint const & cons, A const & document) const {
+  Status visit(constraint::PropertyNamesConstraint const & cons,
+               Adapter auto const & document) const {
     NOOP_UNLESS_TYPE(Object);
 
     Status rval = Status::Accept;
@@ -634,9 +647,12 @@ public:
     return rval;
   }
 
+public:
   /**
    * @brief The main entry point into the validator. Validates the provided
-   * document according to the schema.
+   * document according to the schema. This function should only be called
+   * internally (validate_subschema/validate_subschema_on) or via the Validator
+   * class.
    */
   Status validate(Adapter auto const & document) {
     // Step 1) Check if this is an always-false schema. Sometimes, this will
@@ -701,33 +717,40 @@ public:
     return rval;
   }
 
-private:
-  template <typename S>
-    requires(std::is_constructible_v<std::string, S>)
-  // Optimization to avoid running string-like objects through a
-  // std::stringstream in fmtlist.
-  static std::string fmt(S const & str) {
-    return std::string(str);
-  }
+  // Functions to grant access to some, but not all of the internals of the
+  // ValidationVisitor for the purposes of implementing ExtensionVisitor
+  // validators.
+  detail::Pointer const & where() const { return where_; }
+  Root const & root() const { return *root_; }
+  ValidationConfig const & config() const { return cfg_; }
+  RE & regex() const { return regex_; }
 
-  // Format va_args into a single string to annotate or mark an error message
-  static std::string fmt(auto const &... args) {
-    std::stringstream ss;
-    using ::jvalidate::operator<<;
-    [[maybe_unused]] int _[] = {(ss << args, 0)...};
-    return ss.str();
+  /**
+   * @brief Allow ExtensionVisitor to enter a state similar to a NotConstraint
+   * when calling sub-requests.
+   *
+   * @return A ScopedState object that will restore the tracking mode once it
+   * is destroyed.
+   */
+  [[nodiscard]] detail::ScopedState invert_tracking() const {
+    return detail::ScopedState(tracking_, !tracking_);
   }
 
-  // Format an iterable argument into a vector of strings to annotate or mark
-  // an error.
-  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;
+  /**
+   * @brief Allow ExtensionVisitor to enable tracking of all results in child
+   * constraints.
+   *
+   * @return A ScopedState object that will restore the tracking mode once it
+   * is destroyed.
+   */
+  [[nodiscard]] detail::ScopedState track_everything() const {
+    return detail::ScopedState(tracking_, StoreResults::ForAnything);
   }
 
+  ANNOTATION_HELPER(error, error, jvalidate::to_string)
+  ANNOTATION_HELPER(annotate, annotate, jvalidate::to_string)
+  ANNOTATION_HELPER(annotate_list, annotate, jvalidate::to_string_list)
+
   bool should_annotate(Status stat) const {
     if (not result_) {
       return false;
@@ -742,21 +765,6 @@ private:
     }
   }
 
-#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);
   }
@@ -854,3 +862,9 @@ private:
   }
 };
 }
+
+#undef ANNOTATION_HELPER
+#undef BREAK_EARLY_IF_NO_RESULT_TREE
+#undef NOOP_UNLESS_TYPE
+#undef VALIDATE_SUBSCHEMA_AND_MARK_LOCAL_VISIT
+#undef VISITED

+ 2 - 2
include/jvalidate/validator.h

@@ -69,7 +69,7 @@ public:
              "Cannot perform mutations on an immutable JSON Adapter");
     detail::OnBlockExit _ = [&result, this]() { post_process(result); };
     return static_cast<bool>(
-        ValidationVisitor(schema_, cfg_, regex_, extension_, result).validate(json));
+        ValidationVisitor(schema_, json, cfg_, regex_, extension_, result).validate(json));
   }
 
   /**
@@ -89,7 +89,7 @@ public:
   template <MutableAdapter A> bool validate(A const & json, ValidationResult * result = nullptr) {
     detail::OnBlockExit _ = [&result, this]() { post_process(result); };
     return static_cast<bool>(
-        ValidationVisitor(schema_, cfg_, regex_, extension_, result).validate(json));
+        ValidationVisitor(schema_, json, cfg_, regex_, extension_, result).validate(json));
   }
 
   /**

+ 3 - 9
tests/extension_test.cxx

@@ -29,24 +29,18 @@ struct IsKeyOfConstraint : jvalidate::extension::ConstraintBase<IsKeyOfConstrain
   jvalidate::detail::RelativePointer ptr;
 };
 
-template <jvalidate::Adapter A>
-class Visitor : public jvalidate::extension::Visitor<Visitor<A>, IsKeyOfConstraint> {
+class Visitor : public jvalidate::extension::Visitor<Visitor, 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();
+        std::get<1>(cons.ptr.inspect(validator.where(), validator.root())).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,
@@ -59,7 +53,7 @@ auto validate(Json::Value const & schema_doc, Json::Value const & instance_doc,
   jvalidate::Schema const schema(schema_doc, version, factory);
 
   jvalidate::ValidationResult result;
-  (void)jvalidate::Validator(schema, Visitor(A(instance_doc))).validate(instance_doc, &result);
+  (void)jvalidate::Validator(schema, Visitor()).validate(instance_doc, &result);
 
   return result;
 }