Browse Source

Merge branch 'master' into feat/format-matcher

# Conflicts:
#	include/jvalidate/detail/string.h
#	include/jvalidate/validator.h
Sam Jaffe 2 tuần trước cách đây
mục cha
commit
266137e95c

+ 21 - 0
LICENSE.md

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Samuel Jaffe
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 7 - 1
Makefile

@@ -33,6 +33,7 @@ EXCLUDED_TESTS := content ecmascript_regex zeroTerminatedFloats non_bmp_regex
 EXCLUDED_TESTS := $(shell printf ":*optional_%s" $(EXCLUDED_TESTS) | cut -c2-):$(EXCLUDED_FORMAT_TESTS)
 EXCLUDED_TEST_CASES = "*leap second*"
 
+all: .build/bin/validate
 all: run-test
 
 debug: CXX_FLAGS := $(CXX_FLAGS) -g -fsanitize=address
@@ -50,7 +51,12 @@ 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 $@
+	$(CXX) $(CXX_FLAGS) -c $< -o $@ -Wno-character-conversion
+
+
+.build/bin/validate: src/validate.cxx $(HEADERS)
+	@ mkdir -p .build/bin
+	$(CXX) $< -o $@ $(CXX_FLAGS) $(LD_FLAGS) -ljsoncpp -lcurl
 
 
 .build/bin/selfvalidate: .build/tests/selfvalidate_test.o

+ 78 - 0
include/jvalidate/adapter.h

@@ -1,7 +1,10 @@
 #pragma once
 
 #include <cstdint>
+#include <filesystem>
+#include <fstream>
 #include <optional>
+#include <ostream>
 
 #include <jvalidate/detail/array_iterator.h>
 #include <jvalidate/detail/number.h>
@@ -313,6 +316,48 @@ public:
       return std::nullopt;
     }
   }
+
+  virtual void write(std::ostream & os) const {
+    std::string_view div;
+    switch (type()) {
+    case Type::Null:
+      os << "null";
+      break;
+    case Type::Boolean:
+      os << (as_boolean() ? "true" : "false");
+      break;
+    case Type::Integer:
+      os << as_integer();
+      break;
+    case Type::Number:
+      os << as_number();
+      break;
+    case Type::String:
+      os << '"' << as_string() << '"';
+      break;
+    case Type::Array:
+      os << '[';
+      apply_array([&os, &div](Adapter const & elem) {
+        os << std::exchange(div, ", ") << elem;
+        return Status::Accept;
+      });
+      os << ']';
+      break;
+    case Type::Object:
+      os << '{';
+      apply_object([&os, &div](std::string const & key, Adapter const & elem) {
+        os << std::exchange(div, ", ") << '"' << key << '"' << ':' << elem;
+        return Status::Accept;
+      });
+      os << '}';
+      break;
+    }
+  }
+
+  friend std::ostream & operator<<(std::ostream & os, Adapter const & self) {
+    self.write(os);
+    return os;
+  }
 };
 
 /**
@@ -331,7 +376,24 @@ public:
    * @return the result of cb on the contained JSON
    */
   virtual Status apply(AdapterCallback const & cb) const = 0;
+  friend std::ostream & operator<<(std::ostream & os, Const const & self) {
+    self.apply([&os](Adapter const & adapter) {
+      adapter.write(os);
+      return Status::Accept;
+    });
+    return os;
+  }
 };
+
+template <typename JSON>
+bool load_file(std::filesystem::path const & path, JSON & out, std::string & error) noexcept {
+  std::ifstream in(path);
+  if (in.bad()) {
+    error = "file error";
+    return false;
+  }
+  return load_stream(in, out, error);
+}
 }
 
 namespace jvalidate::adapter::detail {
@@ -355,3 +417,19 @@ private:
   JSON value_;
 };
 }
+
+namespace std {
+inline ostream & operator<<(ostream & os, unique_ptr<jvalidate::adapter::Const const> const & ptr) {
+  return os << *ptr;
+}
+
+inline ostream & operator<<(ostream & os,
+                            vector<unique_ptr<jvalidate::adapter::Const const>> const & items) {
+  std::string_view div;
+  os << '[';
+  for (auto const & ptr : items) {
+    os << std::exchange(div, ", ") << *ptr;
+  }
+  return os << ']';
+}
+}

+ 8 - 0
include/jvalidate/adapters/jsoncpp.h

@@ -1,14 +1,22 @@
 #pragma once
 #include <type_traits>
 
+#include <json/reader.h>
 #include <json/value.h>
 
 #include <jvalidate/adapter.h>
+#include <jvalidate/detail/expect.h>
 #include <jvalidate/detail/number.h>
 #include <jvalidate/detail/simple_adapter.h>
 #include <jvalidate/enum.h>
 
 namespace jvalidate::adapter {
+template <>
+inline bool load_stream(std::istream & in, Json::Value & out, std::string & error) noexcept {
+  Json::CharReaderBuilder builder;
+  return Json::parseFromStream(builder, in, &out, &error);
+}
+
 template <typename JSON> class JsonCppAdapter;
 
 template <> struct AdapterTraits<Json::Value> {

+ 1 - 1
include/jvalidate/compat/compare.h

@@ -1,6 +1,6 @@
 #pragma once
 
-#include <compare>
+#include <compare> // IWYU pragma: keep
 
 // Apple Clang does not properly support <=> in the STL - so we need to force it
 #if __cpp_lib_three_way_comparison < 201907L

+ 46 - 0
include/jvalidate/compat/curl.h

@@ -0,0 +1,46 @@
+#pragma once
+#include <sstream>
+#include <string_view>
+
+#include <curl/curl.h>
+
+#include <jvalidate/adapter.h>
+#include <jvalidate/forward.h>
+#include <jvalidate/uri.h>
+
+namespace jvalidate {
+inline size_t transfer_to_buffer(char * data, size_t size, size_t nmemb, void * userdata) {
+  std::stringstream & ss = *reinterpret_cast<std::stringstream *>(userdata);
+  size_t actual_size = size * nmemb;
+  ss << std::string_view(data, actual_size);
+  return actual_size;
+}
+
+template <typename JSON>
+bool curl_get(jvalidate::URI const & uri, JSON & out, std::string & error) noexcept {
+  using jvalidate::adapter::load_file;
+  using jvalidate::adapter::load_stream;
+  if (uri.scheme().starts_with("http")) {
+    std::stringstream ss;
+    if (CURL * curl = curl_easy_init(); curl) {
+      curl_easy_setopt(curl, CURLOPT_URL, uri.c_str());
+      curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
+      curl_easy_setopt(curl, CURLOPT_WRITEDATA, &ss);
+      curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, &transfer_to_buffer);
+
+      CURLcode res = curl_easy_perform(curl);
+      curl_easy_cleanup(curl);
+
+      if (res == CURLE_OK) {
+        return load_stream(ss, out, error);
+      }
+    }
+    return false;
+  } else if (uri.scheme() == "file") {
+    return load_file(uri.resource(), out, error);
+  } else {
+    error = "unknown scheme";
+    return false;
+  }
+}
+}

+ 1 - 2
include/jvalidate/constraint.h

@@ -454,8 +454,7 @@ public:
    * @returns A constraint that checks equality against a single value.
    */
   static auto isConstant(detail::ParserContext<A> const & context) {
-    constraint::EnumConstraint rval;
-    rval.enumeration.push_back(context.schema.freeze());
+    constraint::ConstConstraint rval{.value = context.schema.freeze()};
     return ptr(rval);
   }
 

+ 56 - 0
include/jvalidate/constraint/array_constraint.h

@@ -7,32 +7,88 @@
 #include <jvalidate/forward.h>
 
 namespace jvalidate::constraint {
+/**
+ * @brief A constraint on the Array type.
+ * Given an argument array, every element after applies_after_nth is
+ * validated against subschema. applies_after_nth is made to be used in
+ * conjunction with {@see TupleConstraint} and cannot be manually specified.
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-core#section-10.3.1.2
+ */
 struct AdditionalItemsConstraint {
   schema::Node const * subschema;
   size_t applies_after_nth;
 };
 
+/**
+ * @brief A constraint on the Array type, that counts the number of elements
+ * in the input array that match subschema and compares that number against
+ * minimum and maximum. If minimum is unset, then it is equivalent to a minimum
+ * of 1. If maximum is unset, then it is equivalent to a maximum of INF. As an
+ * optimization, validation can be stopped after minimum matches if maximum is
+ * unset.
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-core#section-10.3.1.3
+ * https://json-schema.org/draft/2020-12/json-schema-validation#section-6.4.4
+ * https://json-schema.org/draft/2020-12/json-schema-validation#section-6.4.5
+ */
 struct ContainsConstraint {
   schema::Node const * subschema;
   std::optional<size_t> minimum = std::nullopt;
   std::optional<size_t> maximum = std::nullopt;
 };
 
+/**
+ * @brief A constraint on the Array type with the following characteristic(s):
+ * @code{.cpp}
+ * arg.size() <= value
+ * @endcode
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-validation#section-6.4.1
+ */
 struct MaxItemsConstraint {
   int64_t value;
 };
 
+/**
+ * @brief A constraint on the Array type with the following characteristic(s):
+ * @code{.cpp}
+ * arg.size() >= value
+ * @endcode
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-validation#section-6.4.2
+ */
 struct MinItemsConstraint {
   int64_t value;
 };
 
+/**
+ * @brief A constraint on the Array type.
+ * Given an argument array, the Nth element of that Array is validated against
+ * the Nth element of items.
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-core#section-10.3.1.1
+ */
 struct TupleConstraint {
   std::vector<schema::Node const *> items;
 };
 
+/**
+ * @brief A constraint on the Array type.
+ * Given an argument array, every element which is not evaluated by another
+ * schema {@see jvalidate::Status::Noop} will be validated against subschema.
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-core#section-11.2
+ */
 struct UnevaluatedItemsConstraint {
   schema::Node const * subschema;
 };
 
+/**
+ * @brief A constraint on the Array type that affirms that each element
+ * of the array is unique.
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-validation#section-6.4.3
+ */
 struct UniqueItemsConstraint {};
 }

+ 26 - 1
include/jvalidate/constraint/extension_constraint.h

@@ -6,7 +6,19 @@
 #include <jvalidate/status.h>
 
 namespace jvalidate::constraint {
-class ExtensionConstraint {
+/**
+ * @brief A plugin class that allows the for Domain-Specific Grammers to be
+ * implemented for a json-schema.
+ *
+ * These custom Constraints are implemented by creating two extension classes:
+ * - A constraint inheriting from {@see jvalidate::ConstraintBase}, which in
+ *   turn inherits from ExtensionConstraint::Impl. This acts as a simple data
+ *   wrapper for describing the Constraint.
+ * - A visitor inheriting from {@see jvalidate::extension::Visitor}.
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-core#section-6.5
+ */
+struct ExtensionConstraint {
 public:
   struct Impl {
     virtual ~Impl() = default;
@@ -14,6 +26,19 @@ public:
   };
 
 public:
+  /**
+   * @brief Convenience function for constructing this ExtensionConstraint from
+   * the concrete underlying extension in the format that is required for use
+   * with {@see jvalidate::ConstraintFactory}.
+   *
+   * @tparam T A concrete type subtyping ExtensionConstraint::Impl
+   * @tparam Args The argument types for initializing the object
+   *
+   * @params args... The constructor arguments for T
+   *
+   * @return A unique_ptr to a new ExtensionConstraint of underlying type T,
+   * wrapped in the Constraint variant type.
+   */
   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)...)});

+ 74 - 0
include/jvalidate/constraint/general_constraint.h

@@ -8,32 +8,106 @@
 #include <jvalidate/status.h>
 
 namespace jvalidate::constraint {
+/**
+ * @brief A constraint on any JSON document.
+ * Validates that the given document is validated by the EVERY one of the
+ * child schemas.
+ * @code{.py}
+ * all(c.validate(arg) for c in children)
+ * @endcode
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-core#section-10.2.1.1
+ */
 struct AllOfConstraint {
   std::vector<SubConstraint> children;
 };
 
+/**
+ * @brief A constraint on any JSON document.
+ * Validates that the given document is validated by the AT LEAST one of the
+ * child schemas.
+ * @code{.py}
+ * any(c.validate(arg) for c in children)
+ * @endcode
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-core#section-10.2.1.2
+ */
 struct AnyOfConstraint {
   std::vector<SubConstraint> children;
 };
 
+/**
+ * @brief A constraint on any JSON document.
+ * Validates that the input json is exactly the single stored value.
+ * Stored values can be simple scalars (bool, number, string, null), or
+ * complicated documents.
+ * @code{.py}
+ * arg == value
+ * @endcode
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-validation#section-6.1.3
+ */
+struct ConstConstraint {
+  std::unique_ptr<adapter::Const const> value;
+};
+
+/**
+ * @brief A constraint on any JSON document.
+ * Validates that the input json is one of the stored values.
+ * Stored values can be simple scalars (bool, number, string, null), or
+ * complicated documents.
+ * @code{.py}
+ * arg in enumeration
+ * @endcode
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-validation#section-6.1.2
+ */
 struct EnumConstraint {
   std::vector<std::unique_ptr<adapter::Const const>> enumeration;
 };
 
+/**
+ * @brief A constraint on any JSON document.
+ * Validates that the given document is validated by the EXACTLY one of the
+ * child schemas.
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-core#section-10.2.1.3
+ */
 struct OneOfConstraint {
   std::vector<schema::Node const *> children;
 };
 
+/**
+ * @brief A constraint on any JSON document.
+ * Validates the document against the if_constraint. If the constraint returns
+ * Success or Noop, then the document will be validated against the
+ * then_constraint. Otherwise, it will be validated against the else_constraint.
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-core#section-10.2.2
+ */
 struct ConditionalConstraint {
   schema::Node const * if_constraint;
   schema::Node const * then_constraint;
   schema::Node const * else_constraint;
 };
 
+/**
+ * @brief A constraint on any JSON document.
+ * Validates that the given document is NOT validated by the child schema.
+ * A result of {@see jvalidate::Status::Noop} is kept as a Noop.
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-core#section-10.2.1.4
+ */
 struct NotConstraint {
   SubConstraint child;
 };
 
+/**
+ * @brief A constraint on any JSON document.
+ * Validates that the type of the document is one of the given types.
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-validation#section-6.1.1
+ */
 struct TypeConstraint {
   std::set<adapter::Type> types;
 };

+ 28 - 0
include/jvalidate/constraint/number_constraint.h

@@ -7,6 +7,16 @@
 #include <jvalidate/forward.h>
 
 namespace jvalidate::constraint {
+/**
+ * @brief A constraint on the Int and Double types, asserting that the given
+ * arg is less than the stored value. Equality is accepted or rejected based
+ * on exclusive being true or not.
+ * Unlike other constraint types, numeric constraints are trivial and thus
+ * are implemented directly in the constraint object.
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-validation#section-6.2.2
+ * https://json-schema.org/draft/2020-12/json-schema-validation#section-6.2.3
+ */
 struct MaximumConstraint {
   double value;
   bool exclusive;
@@ -14,6 +24,16 @@ struct MaximumConstraint {
   bool operator()(double arg) const { return exclusive ? arg < value : arg <= value; }
 };
 
+/**
+ * @brief A constraint on the Int and Double types, asserting that the given
+ * arg is greater than the stored value. Equality is accepted or rejected based
+ * on exclusive being true or not.
+ * Unlike other constraint types, numeric constraints are trivial and thus
+ * are implemented directly in the constraint object.
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-validation#section-6.2.4
+ * https://json-schema.org/draft/2020-12/json-schema-validation#section-6.2.5
+ */
 struct MinimumConstraint {
   double value;
   bool exclusive;
@@ -21,6 +41,14 @@ struct MinimumConstraint {
   bool operator()(double arg) const { return exclusive ? arg > value : arg >= value; }
 };
 
+/**
+ * @brief A constraint on the Int and Double types, asserting that the given
+ * arg is a multiple of the stored value.
+ * Unlike other constraint types, numeric constraints are trivial and thus
+ * are implemented directly in the constraint object.
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-validation#section-6.2.1
+ */
 struct MultipleOfConstraint {
   double value;
 

+ 80 - 0
include/jvalidate/constraint/object_constraint.h

@@ -9,41 +9,121 @@
 #include <jvalidate/forward.h>
 
 namespace jvalidate::constraint {
+/**
+ * @brief A constraint on the Object type.
+ * Given an argument object, every element that is not subject to a
+ * {@see PropertiesConstraint} or {@see PatternPropertiesConstraint} in the
+ * same level of the Schema as this constraint is validated against subschema.
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-core#section-10.3.2.3
+ */
 struct AdditionalPropertiesConstraint {
   schema::Node const * subschema;
   std::unordered_set<std::string> properties;
   std::vector<std::string> patterns;
 };
 
+/**
+ * @brief A constraint on the Object type representing two different
+ * constraints:
+ * - If a given property is present in the Object, then validate the overall
+ *   object against the subschema (dependentSchemas).
+ * - If a given property is present in the Object, then require that the
+ *   overall object also contains a list of properties (dependentRequired).
+ * Because the explicit separation into different keywords is part of
+ * Draft2020-12, the behaviors are merged together for legacy reasons.
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-core#section-10.2.2.4
+ * https://json-schema.org/draft/2020-12/json-schema-validation#section-6.5.4
+ */
 struct DependenciesConstraint {
   std::map<std::string, schema::Node const *> subschemas;
   std::map<std::string, std::unordered_set<std::string>> required;
 };
 
+/**
+ * @brief A constraint on the Object type with the following characteristic(s):
+ * @code{.cpp}
+ * arg.size() <= value
+ * @endcode
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-validation#section-6.5.1
+ */
 struct MaxPropertiesConstraint {
   int64_t value;
 };
 
+/**
+ * @brief A constraint on the Object type with the following characteristic(s):
+ * @code{.cpp}
+ * arg.size() >= value
+ * @endcode
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-validation#section-6.5.2
+ */
 struct MinPropertiesConstraint {
   int64_t value;
 };
 
+/**
+ * @brief A constraint on the Object type.
+ * Validates properties whose names match a given regex against the subschema.
+ * Multiple patterns allowed to match to the same property, meaning that a
+ * property can be validated against more than one subschema.
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-core#section-10.3.2.2
+ */
 struct PatternPropertiesConstraint {
   std::vector<std::pair<std::string, schema::Node const *>> properties;
 };
 
+/**
+ * @brief A constraint on the Object type.
+ * Validates named properties against their matching subschemas. Does not
+ * intrinsically require that the property be present in the Object without
+ * use of {@see RequiredConstraint}.
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-core#section-10.3.2.1
+ */
 struct PropertiesConstraint {
   std::map<std::string, schema::Node const *> properties;
 };
 
+/**
+ * @brief A constraint on the individual keys of an Object type. Since each key
+ * is a string, key_schema should be a schema which validates against the
+ * String type.
+ * @code{.py}
+ * for key in arg.keys():
+ *     key_schema.validate(key)
+ * @endcode
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-core#section-10.3.2.4
+ */
 struct PropertyNamesConstraint {
   schema::Node const * key_schema;
 };
 
+/**
+ * @brief A constraint on the Object type.
+ * Validates that the object contains all of the stored properties.
+ * @code{.py}
+ * all(p in arg for p in properties)
+ * @endcode
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-validation#section-6.5.3
+ */
 struct RequiredConstraint {
   std::unordered_set<std::string> properties;
 };
 
+/**
+ * @brief A constraint on the Object type.
+ * Given an argument object, every property which is not evaluated by another
+ * schema {@see jvalidate::Status::Noop} will be validated against subschema.
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-core#section-11.3
+ */
 struct UnevaluatedPropertiesConstraint {
   schema::Node const * subschema;
 };

+ 33 - 0
include/jvalidate/constraint/string_constraint.h

@@ -6,18 +6,51 @@
 #include <jvalidate/forward.h>
 
 namespace jvalidate::constraint {
+/**
+ * @brief A constraint on the String type with the following characteristic(s):
+ * @code{.cpp}
+ * arg.size() >= value
+ * @endcode
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-validation#section-6.3.2
+ */
 struct MinLengthConstraint {
   int64_t value;
 };
 
+/**
+ * @brief A constraint on the String type with the following characteristic(s):
+ * @code{.cpp}
+ * arg.size() <= value
+ * @endcode
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-validation#section-6.3.1
+ */
 struct MaxLengthConstraint {
   int64_t value;
 };
 
+/**
+ * @brief A constraint on the String type with the following characteristic(s):
+ * @code{.py}
+ * re.match(regex, arg)
+ * @endcode
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-validation#section-6.3.3
+ */
 struct PatternConstraint {
   std::string regex;
 };
 
+/**
+ * @brief A constraint on the String type that describes a string format using
+ * a human friendly name. The implementation of the regular expression, ABNF
+ * grammer, or state-machine parser that checks the validity of the string
+ * (for the standard list of formats respected by the JSON schema RPC) is
+ * implemented in {@see include/jvalidate/format.h}.
+ *
+ * https://json-schema.org/draft/2020-12/json-schema-validation#section-7
+ */
 struct FormatConstraint {
   std::string format;
   bool is_assertion;

+ 2 - 2
include/jvalidate/detail/number.h

@@ -27,8 +27,8 @@ inline bool is_json_integer(double number) { return std::floor(number) == number
  * 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();
+  static constexpr double g_int_max = static_cast<double>(std::numeric_limits<int64_t>::max());
+  static constexpr double g_int_min = static_cast<double>(std::numeric_limits<int64_t>::min());
   return is_json_integer(number) && number <= g_int_max && number >= g_int_min;
 }
 

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

@@ -66,7 +66,6 @@ public:
     if (ref_) {
       *ref_ = std::forward<U>(val);
     }
-    return *this;
   }
 };
 

+ 33 - 1
include/jvalidate/detail/parser_context.h

@@ -1,6 +1,5 @@
 #pragma once
 
-#include <functional>
 #include <optional>
 
 #include <jvalidate/detail/reference.h>
@@ -22,19 +21,52 @@ template <Adapter A> struct ParserContext {
   Reference where = {};
   Reference dynamic_where = {};
 
+  /**
+   * @brief Obtain the ParserContext for an arbitrary schema location,
+   * preserving the general context members of the root Schema, Vocabulary,
+   * and ReferenceManager.
+   *
+   * @param new_schema The new schema JSON adapter
+   * @param new_loc The json pointer to this new_schema relative to its document
+   * root.
+   * @param new_dyn The json pointer to this new_schema using dynamic reference
+   * rules.
+   * @param parent The parent of this schema, if that parent is an Object.
+   */
   ParserContext rebind(A const & new_schema, Reference const & new_loc, Reference const & new_dyn,
                        std::optional<Object> parent = std::nullopt) const {
     return {root, new_schema, vocab, ref, parent, new_loc, new_dyn};
   }
 
+  /**
+   * @brief Obtain the ParserContext for the child schema of the current
+   * location. Will set the parent context, which is used by some constraints
+   * like "contains" or "if".
+   *
+   * @param child The new child schema
+   * @param key The object-key to the new schema
+   */
   ParserContext child(A const & child, std::string const & key) const {
     return rebind(child, where / key, dynamic_where / key, schema.as_object());
   }
 
+  /**
+   * @brief Obtain the ParserContext for the child schema of the current
+   * location. Will clear the parent schema context.
+   *
+   * @param child The new child schema
+   * @param key The array-index to the new schema
+   */
   ParserContext child(A const & child, size_t index) const {
     return rebind(child, where / index, dynamic_where / index);
   }
 
+  /**
+   * @brief Obtain the ParserContext for the a child schema with the same parent
+   * as this context. Called when generating {@see ConditionalConstraint}.
+   *
+   * @param key The object-key to the new schema
+   */
   ParserContext neighbor(std::string const & key) const {
     return rebind((*parent)[key], where.parent() / key, dynamic_where.parent() / key, parent);
   }

+ 13 - 6
include/jvalidate/detail/reference_manager.h

@@ -82,7 +82,7 @@ public:
    */
   ReferenceManager(DocumentCache<A> & external, A const & root, schema::Version version,
                    ConstraintFactory<A> const & constraints)
-      : external_(external), constraints_(constraints), roots_{{{}, root}} {
+      : constraints_(constraints), external_(external), roots_{{{}, root}} {
     prime(root, {}, &vocab(version));
   }
 
@@ -122,8 +122,10 @@ public:
       return it->second;
     }
 
-    std::optional<A> external = external_.try_load(schema);
-    EXPECT_M(external.has_value(), "Unable to load external meta-schema " << schema);
+    std::string error;
+    std::optional<A> external = external_.try_load(schema, error);
+    EXPECT_M(external.has_value(),
+             "Unable to load external meta-schema " << schema << ": " << error);
     EXPECT_M(external->type() == adapter::Type::Object, "meta-schema must be an object");
 
     auto metaschema = external->as_object();
@@ -185,12 +187,12 @@ public:
    * 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) {
+  std::optional<A> load(Reference const & ref, Vocabulary<A> const * vocab, std::string & error) {
     if (auto it = roots_.find(ref.root()); it != roots_.end()) {
       return ref.pointer().walk(it->second);
     }
 
-    std::optional<A> external = external_.try_load(ref.uri());
+    std::optional<A> external = external_.try_load(ref.uri(), error);
     if (not external) {
       return std::nullopt;
     }
@@ -467,7 +469,12 @@ private:
       vocab_docs.emplace(vocab.substr(n));
       vocab.replace(n, 7, "/meta/");
 
-      auto vocab_object = external_.try_load(URI(vocab));
+      std::string error;
+      auto vocab_object = external_.try_load(URI(vocab), error);
+      if (!vocab_object.has_value()) {
+        continue;
+      }
+
       auto it = vocab_object->as_object().find("properties");
       if (it == vocab_object->as_object().end()) {
         continue;

+ 31 - 2
include/jvalidate/detail/relative_pointer.h

@@ -10,14 +10,29 @@
 #include <jvalidate/forward.h>
 
 namespace jvalidate::detail {
+/**
+ * @brief A special form of json pointer that is allowed to specify moving up
+ * into the parent scope of the current location.
+ * A relative pointer has two forms:
+ * - Nth parent-key, which takes the ABNF form:
+ *   non-negative-integer "#"
+ * - Nth parent followed by a JSON-Pointer as specified by RFC6901
+ *
+ * This does not comply with array neighbor offsets as described in
+ * Section 4 Paragraph 3 (the "index-manipulation" ABNF).
+ *
+ * https://json-schema.org/draft/2020-12/relative-json-pointer
+ * https://datatracker.ietf.org/doc/html/rfc6901
+ */
 class RelativePointer {
 public:
   RelativePointer(std::string_view path) {
-    if (path == "0") {
+    if (path == "0") { // A literal RelativePointer of "0" simply means "here"
       return;
     }
 
-    if (auto pos = path.find('/'); pos != path.npos) {
+    if (size_t pos = path.find('/'); pos != path.npos) {
+      // Handle the JSON-Pointer version
       pointer_ = Pointer(path.substr(pos));
       path.remove_suffix(path.size() - pos);
     } else if (path.ends_with('#')) {
@@ -26,9 +41,23 @@ public:
     }
 
     EXPECT_M(path == "0" || not path.starts_with("0"), "Cannot zero-prefix a relative pointer");
+    // Will throw an exception if path contains any non-numeric characters, or
+    // if path represents a number that is out of the bounds of a size_t.
     parent_steps_ = from_str<size_t>(path);
   }
 
+  /**
+   * @brief Acquire either the key of the nth parent or a JSON value at some "cousin"
+   * location in the overall document.
+   *
+   * @tparam A JSON Adapter type
+   *
+   * @param where The pointer representing the current location.
+   * @param root The root document location (accessed by where == Pointer())
+   *
+   * @return The evaluation result as described by
+   * https://json-schema.org/draft/2020-12/relative-json-pointer#rfc.section.4
+   */
   template <Adapter A>
   std::variant<std::string, A> inspect(Pointer const & where, A const & root) const {
     if (requests_key_) {

+ 2 - 2
include/jvalidate/detail/simple_adapter.h

@@ -250,7 +250,7 @@ public:
 
       bool rval = true;
       auto array = this->as_array();
-      rhs.apply_array([&, this, index = 0UL](adapter::Adapter const & elem) mutable {
+      rhs.apply_array([&, index = 0UL](adapter::Adapter const & elem) mutable {
         // Short-Circuit OK
         rval = rval && array[index].equals(elem, strict);
         ++index;
@@ -269,7 +269,7 @@ public:
 
       bool rval = true;
       auto object = this->as_object();
-      rhs.apply_object([&, this](std::string const & key, adapter::Adapter const & elem) {
+      rhs.apply_object([&](std::string const & key, adapter::Adapter const & elem) {
         // Short-Circuit OK
         rval = rval && object.contains(key) && object[key].equals(elem, strict);
         return Status::Accept;

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

@@ -5,6 +5,7 @@
 #pragma once
 #include <jvalidate/_config.h>
 
+#include <cstring>
 #include <memory>
 #include <string>
 #include <string_view>

+ 2 - 2
include/jvalidate/document_cache.h

@@ -56,7 +56,7 @@ public:
 
   operator bool() const { return resolve_; }
 
-  std::optional<A> try_load(URI const & uri) {
+  std::optional<A> try_load(URI const & uri, std::string & error) {
     // 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).
@@ -65,7 +65,7 @@ public:
     }
 
     auto [it, created] = cache_.try_emplace(uri);
-    if (created && not resolve_(uri, it->second)) {
+    if (created && not resolve_(uri, it->second, error)) {
       // 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

+ 66 - 0
include/jvalidate/extension.h

@@ -7,6 +7,10 @@
 #include <jvalidate/status.h>
 
 namespace jvalidate::extension {
+/**
+ * @brief A stub visitor type for building the ExtensionConstraint processing
+ * hierarchy. Exposed for use with {@see ConstraintBase::visit}.
+ */
 struct VisitorBase {
   virtual ~VisitorBase() = default;
 };
@@ -15,10 +19,44 @@ template <typename E>
 concept Constraint = std::is_base_of_v<constraint::ExtensionConstraint::Impl, E>;
 
 namespace detail {
+/**
+ * @brief A stub visitor type for proxying the ExtensionConstraint concrete
+ * implementation type into a visitor.
+ * Exists as a base class so that {@see jvalidate::extension::ConstraintBase}
+ * can cast a VisitorBase to this.
+ *
+ * The call graph (including abstract -> virtual resolution) is as follows:
+ * ExtensionConstraint::Impl::visit(VisitorBase const &)
+ * -> ConstraintBase<E>::visit(VisitorBase const &)
+ * ---> TypedVisitor<E>::visit(E const &)
+ * -----> TypedVisitorImpl<E, V>::visit(E const &)
+ * -------> Visitor::Impl::dispatch(E const &)
+ * ---------> Visitor::visit<Adapter, ValidationVisitor>(E const &, ...)
+ *
+ * @tparam E The ExtensionConstraint type that this visitor applies to
+ */
 template <Constraint E> struct TypedVisitor : VisitorBase {
   virtual Status visit(E const & cons) const = 0;
 };
 
+/**
+ * @brief The cleverness of this virtual class hierarchy.
+ * Connects {@see jvalidate::extension::Visitor::Impl} to one of the extension
+ * constraint types provided in its type signature.
+ *
+ * The call graph (including abstract -> virtual resolution) is as follows:
+ * ExtensionConstraint::Impl::visit(VisitorBase const &)
+ * -> ConstraintBase<E>::visit(VisitorBase const &)
+ * ---> TypedVisitor<E>::visit(E const &)
+ * -----> TypedVisitorImpl<E, V>::visit(E const &)
+ * -------> Visitor::Impl::dispatch(E const &)
+ * ---------> Visitor::visit<Adapter, ValidationVisitor>(E const &, ...)
+ *
+ * @tparam E The ExtensionConstraint type that this visitor applies to
+ * @tparam CRTP A "Curiously Recurring Template Pattern" class (the actually
+ * concrete class implementation of this Visitor).
+ * Must implement a function "dispatch" which can accept at least E.
+ */
 template <Constraint E, typename CRTP> struct TypedVisitorImpl : TypedVisitor<E> {
   Status visit(E const & cons) const final {
     return static_cast<CRTP const *>(this)->dispatch(cons);
@@ -26,6 +64,22 @@ template <Constraint E, typename CRTP> struct TypedVisitorImpl : TypedVisitor<E>
 };
 }
 
+/**
+ * @brief The preferred base class of all user-defined constraints. Using the
+ * "Curiously Recurring Template Pattern", it is able to unwrap RTTI (Run Time
+ * Type Information) through {@see TypedVisitor}.
+ *
+ * The call graph (including abstract -> virtual resolution) is as follows:
+ * ExtensionConstraint::Impl::visit(VisitorBase const &)
+ * -> ConstraintBase<E>::visit(VisitorBase const &)
+ * ---> TypedVisitor<E>::visit(E const &)
+ * -----> TypedVisitorImpl<E, V>::visit(E const &)
+ * -------> Visitor::Impl::dispatch(E const &)
+ * ---------> Visitor::visit<Adapter, ValidationVisitor>(E const &, ...)
+ *
+ * @tparam CRTP A "Curiously Recurring Template Pattern" class (the actually
+ * concrete class implementation of this ExtensionConstraint).
+ */
 template <typename CRTP> struct ConstraintBase : constraint::ExtensionConstraint::Impl {
   Status visit(VisitorBase const & visitor) const final {
     return dynamic_cast<detail::TypedVisitor<CRTP> const &>(visitor).visit(
@@ -33,6 +87,18 @@ template <typename CRTP> struct ConstraintBase : constraint::ExtensionConstraint
   }
 };
 
+/**
+ * @brief The visitor class responsible for performing validation on some list
+ * of ExtensionConstraints as desired by the user.
+ *
+ * @tparam CRTP A "Curiously Recurring Template Pattern" class (the actually
+ * concrete class implementation of this Visitor).
+ * This class must implement the visit function for each extension constraint
+ * in the Es... type list, as described by Visitor::Impl::dispatch.
+ *
+ * @tparam Es... A number of ExtensionConstraint implementations. Each of these
+ * must fulfill the {@see jvalidate::extension::Constraint} contract.
+ */
 template <typename CRTP, typename... Es> class Visitor {
 private:
   template <Adapter A, typename V> class Impl : public detail::TypedVisitorImpl<Es, Impl<A, V>>... {

+ 8 - 2
include/jvalidate/forward.h

@@ -1,6 +1,7 @@
 #pragma once
 
 #include <functional>
+#include <iosfwd>
 #include <string>
 #include <type_traits>
 #include <variant>
@@ -12,7 +13,8 @@
 
 #define COMMA_NAME(X) , X
 
-namespace jvalidate::detail {}
+namespace jvalidate::detail {
+}
 namespace jvalidate::format::detail {
 using namespace jvalidate::detail;
 }
@@ -37,6 +39,8 @@ template <typename> struct AdapterTraits;
 template <typename V> struct AdapterTraits<V const> : AdapterTraits<V> {};
 
 template <typename JSON> using AdapterFor = typename AdapterTraits<JSON>::template Adapter<JSON>;
+template <typename JSON>
+bool load_stream(std::istream & in, JSON & out, std::string & error) noexcept;
 }
 
 namespace jvalidate::schema {
@@ -48,6 +52,7 @@ namespace jvalidate::constraint {
 #define CONSTRAINT_IMPLEMENTATION_LIST(X)                                                          \
   /* General Constraints - See jvalidate/constraint/general_constraint.h */                        \
   X(TypeConstraint)                                                                                \
+  X(ConstConstraint)                                                                               \
   X(EnumConstraint)                                                                                \
   X(AllOfConstraint)                                                                               \
   X(AnyOfConstraint)                                                                               \
@@ -190,7 +195,8 @@ 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 <Adapter A>
+using URIResolver = bool (*)(URI const &, typename A::value_type &, std::string &) noexcept;
 }
 
 namespace jvalidate::extension {

+ 8 - 7
include/jvalidate/schema.h

@@ -1,9 +1,7 @@
 #pragma once
 
 #include <memory>
-#include <type_traits>
 #include <unordered_map>
-#include <vector>
 
 #include <jvalidate/adapter.h>
 #include <jvalidate/constraint.h>
@@ -72,6 +70,7 @@ public:
   std::optional<std::string> const & rejects_all() const { return rejects_all_; }
   std::optional<schema::Node const *> reference_schema() const { return reference_; }
 
+  std::string const & description() const { return description_; }
   bool requires_result_context() const { return not post_constraints_.empty(); }
   auto const & constraints() const { return constraints_; }
   auto const & post_constraints() const { return post_constraints_; }
@@ -112,7 +111,7 @@ namespace jvalidate {
 class Schema : public schema::Node {
 private:
   friend class schema::Node;
-  template <Adapter A> friend class detail::ParserContext;
+  template <Adapter A> friend struct detail::ParserContext;
 
 private:
   schema::Node accept_;
@@ -273,15 +272,17 @@ private:
       return *cached;
     }
 
-    if (std::optional root = context.ref.load(lexical, context.vocab)) {
+    std::string error;
+    if (std::optional root = context.ref.load(lexical, context.vocab, error)) {
       return fetch_schema(context.rebind(*root, lexical, dynamic));
     }
 
-    std::string error = "URIResolver could not resolve " + std::string(lexical.uri());
+    constexpr char const * prelude = "URIResolver could not find ";
 #ifdef JVALIDATE_LOAD_FAILURE_AS_FALSE_SCHEMA
-    return alias(dynamic, &cache_.try_emplace(dynamic, error).first->second);
+    return alias(dynamic,
+                 &cache_.try_emplace(dynamic, prelude + std::string(lexical.uri())).first->second);
 #else
-    JVALIDATE_THROW(std::runtime_error, error);
+    JVALIDATE_THROW(std::runtime_error, prelude << lexical.uri() << ": " << error);
 #endif
   }
 

+ 6 - 0
include/jvalidate/status.h

@@ -3,5 +3,11 @@
 #include <jvalidate/detail/tribool.h>
 
 namespace jvalidate {
+/**
+ * @brief A tribool enumeration representing validation results.
+ * For the sake of running "unevaluatedProperties" and
+ * "unevaluatedItems" schemas, we need to be able to track which
+ * properties/items are not interacted with by other schemas rules.
+ */
 JVALIDATE_TRIBOOL_TYPE(Status, Accept, Reject, Noop);
 }

+ 4 - 0
include/jvalidate/validation_config.h

@@ -20,5 +20,9 @@ struct ValidationConfig {
   // When enabled, we will validate format constraints on the document, instead
   // of using them purely as annotations.
   bool validate_format = false;
+
+  // When enabled, will filter out any elements of the ValidationResult which
+  // contain only annotations.
+  bool only_return_results_with_error = false;
 };
 }

+ 34 - 4
include/jvalidate/validation_result.h

@@ -36,7 +36,7 @@ public:
    * }
    */
   struct LocalResult {
-    bool valid;
+    bool valid = true;
     std::map<std::string, Annotation> errors;
     std::map<std::string, Annotation> annotations;
   };
@@ -117,8 +117,9 @@ public:
     }
     os << ',' << '\n';
     os << indent(i) << '"' << name << '"' << ':' << ' ' << '{' << '\n';
+    char const * odiv = "";
     for (auto const & [key, anno] : named) {
-      os << indent(i + 1) << '"' << key << '"' << ':' << ' ';
+      os << std::exchange(odiv, ",\n") << 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)) {
@@ -129,9 +130,8 @@ public:
         }
         os << '\n' << indent(i + 1) << ']';
       }
-      os << '\n';
     }
-    os << indent(i) << '}';
+    os << '\n' << indent(i) << '}';
   }
 
   bool valid() const { return valid_; }
@@ -214,6 +214,21 @@ public:
     return &local.errors.at(name);
   }
 
+  ValidationResult only_errors() const {
+    ValidationResult rval;
+    rval.valid_ = valid_;
+
+    for (auto const & [doc, results] : results_) {
+      for (auto const & [schema, result] : results) {
+        if (!result.errors.empty()) {
+          rval.results_[doc][schema] = result;
+        }
+      }
+    }
+
+    return rval;
+  }
+
 private:
   /**
    * @brief Transfer the contents of another ValidationResult into this one using
@@ -224,6 +239,7 @@ private:
   void merge(ValidationResult && result) & {
     for (auto && [where, by_schema] : result.results_) {
       for (auto && [schema_path, local] : by_schema) {
+        results_[where][schema_path].valid &= local.valid;
         results_[where][schema_path].annotations.merge(local.annotations);
         results_[where][schema_path].errors.merge(local.errors);
       }
@@ -263,6 +279,7 @@ private:
     if (std::visit([](auto const & v) { return v.empty(); }, message)) {
       return;
     }
+    std::visit([](auto & msg) { sanitize(msg); }, message);
     results_[where][schema_path].errors.emplace(name, std::move(message));
   }
 
@@ -280,7 +297,20 @@ private:
     if (std::visit([](auto const & v) { return v.empty(); }, message)) {
       return;
     }
+    std::visit([](auto & msg) { sanitize(msg); }, message);
     results_[where][schema_path].annotations.emplace(name, std::move(message));
   }
+
+  static void sanitize(std::string & message) {
+    for (auto it = message.begin(); it != message.end(); ++it) {
+      if (*it == '"' || *it == '\\') {
+        it = ++message.insert(it, '\\');
+      }
+    }
+  }
+
+  static void sanitize(std::vector<std::string> & message) {
+    std::ranges::for_each(message, [](std::string & val) { sanitize(val); });
+  }
 };
 }

+ 81 - 23
include/jvalidate/validation_visitor.h

@@ -1,8 +1,8 @@
 #pragma once
 
+#include <algorithm>
 #include <tuple>
 #include <type_traits>
-#include <unordered_map>
 #include <vector>
 
 #include <jvalidate/compat/enumerate.h>
@@ -26,9 +26,10 @@
 
 #define VISITED(type) std::get<std::unordered_set<type>>(*visited_)
 
-#define VALIDATE_SUBSCHEMA_AND_MARK_LOCAL_VISIT(subschema, subinstance, path, local_visited)       \
+#define VALIDATE_SUBSCHEMA_AND_MARK_LOCAL_VISIT(subschema, subinstance, path, local_visited, ...)  \
   do {                                                                                             \
-    Status const partial = validate_subschema_on(subschema, subinstance, path);                    \
+    Status const partial =                                                                         \
+        validate_subschema_on(subschema, subinstance, path __VA_OPT__(, ) __VA_ARGS__);            \
     rval &= partial;                                                                               \
     if (result_ and partial != Status::Noop) {                                                     \
       local_visited.insert(local_visited.end(), path);                                             \
@@ -83,6 +84,11 @@ public:
       : schema_(&schema), result_(result), cfg_(cfg), extension_(extension), regex_(regex) {}
 
   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
+    // validated given the input document.
+    // This covers a case where we write the extension around a specific adapter
+    // instead of generically.
     if constexpr (std::is_invocable_r_v<Status, ExtensionVisitor, decltype(cons),
                                         decltype(document), ValidationVisitor const &>) {
       return extension_(cons, document, *this);
@@ -95,20 +101,35 @@ public:
     adapter::Type const type = document.type();
 
     for (adapter::Type const accept : cons.types) {
-      if (type == accept) {
+      if (type == accept) { // Simple case, types are equal
         return result(Status::Accept, type, " is in types [", cons.types, "]");
       }
       if (accept == adapter::Type::Number && type == adapter::Type::Integer) {
+        // Number is a super-type of Integer, therefore all Integer values are
+        // accepted by a `"type": "number"` schema.
         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())) {
+        // Since the JSON specification does not distinguish between Number
+        // and Integer, but JSON Schema does, we need to check that the number
+        // is a whole integer that is representable within the system (64-bit).
         return result(Status::Accept, type, " is in types [", cons.types, "]");
       }
     }
     return result(Status::Reject, type, " is not in types [", cons.types, "]");
   }
 
+  Status visit(constraint::ConstConstraint const & cons, Adapter auto const & document) const {
+    auto is_equal = [this, &document](auto const & frozen) {
+      return document.equals(frozen, cfg_.strict_equality);
+    };
+    if (cons.value->apply(is_equal)) {
+      return result(Status::Accept, "matches value");
+    }
+    return result(Status::Reject, cons.value, " was expected");
+  }
+
   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);
@@ -118,7 +139,7 @@ public:
         return result(Status::Accept, index);
       }
     }
-    return Status::Reject;
+    return result(Status::Reject, document, " value is not one of ", cons.enumeration);
   }
 
   Status visit(constraint::AllOfConstraint const & cons, Adapter auto const & document) const {
@@ -143,6 +164,9 @@ public:
     std::optional<size_t> first_validated;
     for (auto const & [index, subschema] : detail::enumerate(cons.children)) {
       if (validate_subschema(subschema, document, index)) {
+        // This technically will produce different results when we're tracking
+        // visited nodes, but in practice it doesn't actually matter which
+        // subschema index we record in the annotation.
         first_validated = index;
       }
       if (not visited_ && first_validated.has_value()) {
@@ -274,12 +298,15 @@ public:
   }
 
   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
+    // https://json-schema.org/draft/2020-12/json-schema-validation#name-defined-formats
     NOOP_UNLESS_TYPE(String);
 
     annotate(cons.format);
     if (not cfg_.validate_format && not cons.is_assertion) {
-      return true;
+      // Don't both validating formats if we're not in assertion mode
+      // Assertion mode is specified either by using the appropriate "$vocab"
+      // meta-schema or by requesting it in the ValidationConfig.
+      return true; // TODO: I think this can be made into Noop
     }
 
     switch (FormatValidator(&RE::is_regex)(cons.format, document.as_string())) {
@@ -373,6 +400,8 @@ public:
     NOOP_UNLESS_TYPE(Array);
 
     if constexpr (std::totally_ordered<A>) {
+      // If the adapter defines comparison operators, then it becomes possible
+      // to compute uniqueness in O(n*log(n)) checks.
       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) {
@@ -380,6 +409,9 @@ public:
         }
       }
     } else {
+      // Otherwise, we need to run an O(n^2) triangular array search comparing
+      // equality for each element. This still guarantees that each element is
+      // compared against each other element no more than once.
       auto array = document.as_array();
       for (size_t i = 0; i < array.size(); ++i) {
         for (size_t j = i + 1; j < array.size(); ++j) {
@@ -398,12 +430,9 @@ public:
     NOOP_UNLESS_TYPE(Object);
 
     auto matches_any_pattern = [this, &cons](std::string const & key) {
-      for (auto & pattern : cons.patterns) {
-        if (regex_.search(pattern, key)) {
-          return true;
-        }
-      }
-      return false;
+      return std::ranges::any_of(cons.patterns, [this, &key](auto const & pattern) {
+        return regex_.search(pattern, key);
+      });
     };
 
     Status rval = Status::Accept;
@@ -498,6 +527,15 @@ public:
     auto object = document.as_object();
 
     if constexpr (MutableAdapter<A>) {
+      // Special Rule - if the adapter is of a mutable json document (wraps a
+      // non-const reference and exposes the assign function) we will process
+      // the "default" annotation will be applied.
+      // https://json-schema.org/draft/2020-12/json-schema-validation#section-9.2
+      //
+      // Although the JSON Schema draft only says the the default value ought be
+      // valid against the schema, this implementation will assure that it is
+      // valid against this PropertiesConstraint, and any other constraints that
+      // are run after this one.
       for (auto const & [key, subschema] : cons.properties) {
         auto const * default_value = subschema->default_value();
         if (default_value && not object.contains(key)) {
@@ -509,7 +547,7 @@ public:
     std::vector<std::string> properties;
     for (auto const & [key, elem] : object) {
       if (auto it = cons.properties.find(key); it != cons.properties.end()) {
-        VALIDATE_SUBSCHEMA_AND_MARK_LOCAL_VISIT(it->second, elem, key, properties);
+        VALIDATE_SUBSCHEMA_AND_MARK_LOCAL_VISIT(it->second, elem, key, properties, key);
       }
       BREAK_EARLY_IF_NO_RESULT_TREE();
     }
@@ -524,7 +562,6 @@ public:
 
     Status rval = Status::Accept;
     for (auto const & [key, _] : document.as_object()) {
-      // TODO(samjaffe): Should we prefer a std::string adapter like valijson?
       rval &=
           validate_subschema_on(cons.key_schema, detail::StringAdapter(key), std::string("$$key"));
     }
@@ -624,14 +661,20 @@ public:
     // 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()) {
+      // TODO: Investigate why this seems to produce .../$ref/$ref pointers
       rval = validate_subschema(*ref, document, "$ref");
     }
 
+    if (result_ && !schema_->description().empty()) {
+      result_->annotate(where_, schema_path_, "description", schema_->description());
+    }
+
     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 &= std::visit([this, &document](auto & c) { return visit(c, document); }, *p_constraint);
+      rval &= std::visit([this, &document](auto & c) { return this->visit(c, document); },
+                         *p_constraint);
     }
 
     // Post Constraints represent the unevaluatedItems and unevaluatedProperties
@@ -639,7 +682,8 @@ public:
     for (auto const & [key, p_constraint] : schema_->post_constraints()) {
       BREAK_EARLY_IF_NO_RESULT_TREE();
       schema_path_ = current_schema / key;
-      rval &= std::visit([this, &document](auto & c) { return visit(c, document); }, *p_constraint);
+      rval &= std::visit([this, &document](auto & c) { return this->visit(c, document); },
+                         *p_constraint);
     }
 
     (result_ ? result_->valid(where_, current_schema, static_cast<bool>(rval)) : void());
@@ -649,10 +693,13 @@ public:
 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 str;
   }
 
+  // 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<<;
@@ -660,6 +707,8 @@ private:
     return ss.str();
   }
 
+  // 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) {
@@ -718,7 +767,7 @@ private:
     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); },
+      return std::visit([this, &document](auto & c) { return this->visit(c, document); },
                         *std::get<1>(subschema));
     }
   }
@@ -746,6 +795,8 @@ private:
 
     Status rval = next.validate(document);
 
+    // Only update the visited annotation of the current context if the
+    // subschema validates as Accepted.
     if (rval == Status::Accept and visited_) {
       std::get<0>(*visited_).merge(std::get<0>(annotate));
       std::get<1>(*visited_).merge(std::get<1>(annotate));
@@ -760,28 +811,35 @@ private:
    * @param subschema The subschema being validated.
    * @param document The child document being evaluated.
    * @param key The path to this document instance.
+   * @param schema_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_on(schema::Node const * subschema, Adapter auto const & document,
-                               K const & key) const {
+                               K const & key, auto const &... schema_keys) const {
     ValidationResult result;
 
     ValidationVisitor next = *this;
     next.where_ /= key;
+    ((next.schema_path_ /= schema_keys), ...);
     std::tie(next.schema_, next.result_, next.visited_) =
         std::forward_as_tuple(subschema, result_ ? &result : nullptr, nullptr);
 
-    auto status = next.validate(document);
-    if (status == Status::Accept and visited_) {
+    Status rval = next.validate(document);
+    // Only update the visited annotation of the current context if the
+    // subschema validates as Accepted.
+    if (rval == Status::Accept and visited_) {
       VISITED(K).insert(key);
     }
-    if (status == Status::Reject and result_) {
+    // Update the annotation/error content only if a failure is being reported,
+    // or if we are in an "if" schema.
+    if ((rval == Status::Reject or tracking_ == StoreResults::ForAnything) and result_) {
       result_->merge(std::move(result));
     }
-    return status;
+    return rval;
   }
 };
 }

+ 13 - 0
include/jvalidate/validator.h

@@ -1,5 +1,6 @@
 #pragma once
 
+#include <jvalidate/detail/on_block_exit.h>
 #include <jvalidate/forward.h>
 #include <jvalidate/regex.h>
 #include <jvalidate/status.h>
@@ -66,6 +67,7 @@ public:
   bool validate(A const & json, ValidationResult * result = nullptr) {
     EXPECT_M(not cfg_.construct_default_values,
              "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));
   }
@@ -85,6 +87,7 @@ public:
    * schema to provide a record of all of the failures.
    */
   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));
   }
@@ -107,5 +110,15 @@ public:
   bool validate(JSON & json, ValidationResult * result = nullptr) {
     return validate(adapter::AdapterFor<JSON>(json), result);
   }
+
+private:
+  void post_process(ValidationResult *& result) const {
+    if (result == nullptr) {
+      return;
+    }
+    if (cfg_.only_return_results_with_error) {
+      *result = result->only_errors();
+    }
+  }
 };
 }

+ 123 - 0
src/validate.cxx

@@ -0,0 +1,123 @@
+#include <cstdio>
+#include <cstdlib>
+#include <filesystem>
+#include <iostream>
+#include <sstream>
+#include <stdexcept>
+#include <string_view>
+
+#include <curl/curl.h>
+
+#include <jvalidate/adapter.h>
+#include <jvalidate/adapters/jsoncpp.h>
+#include <jvalidate/compat/curl.h>
+#include <jvalidate/enum.h>
+#include <jvalidate/forward.h>
+#include <jvalidate/schema.h>
+#include <jvalidate/status.h>
+#include <jvalidate/uri.h>
+#include <jvalidate/validator.h>
+
+#include <json/value.h>
+#include <json/writer.h>
+
+using jvalidate::adapter::load_file;
+using jvalidate::adapter::load_stream;
+
+struct ProgramArgs {
+  ProgramArgs(std::string_view program, std::vector<std::string_view> args) {
+    size_t nargs = 0;
+    for (size_t i = 0; i < args.size(); ++i) {
+      if (args[i] == "--verbose" || args[i] == "-v") {
+        verbose = true;
+      } else if (args[i] == "--explain") {
+        format_as_explaination = true;
+      } else {
+        switch (nargs++) {
+        case 0:
+          schema = args[i];
+          break;
+        case 1:
+          object = args[i];
+          break;
+        }
+      }
+    }
+
+    if (nargs != 2) {
+      throw std::invalid_argument("usage: " + std::string(program) +
+                                  " [--verbose|-v] [--explain] schema object");
+    }
+  }
+
+  bool verbose = false;
+  bool format_as_explaination = false;
+  std::string_view schema;
+  std::string_view object;
+};
+
+int main(int argc, char const * const * argv) {
+  ProgramArgs const args{argv[0], {argv + 1, argv + argc}};
+
+  Json::Value jschema;
+  Json::Value jobject;
+  if (std::string error; !load_file(args.schema, jschema, error)) {
+    std::cerr << "Error loading schema: " << error << std::endl;
+    return EXIT_FAILURE;
+  }
+  if (std::string error; !load_file(args.object, jobject, error)) {
+    std::cerr << "Error loading schema: " << error << std::endl;
+    return EXIT_FAILURE;
+  }
+
+  using enum jvalidate::schema::Version;
+  jvalidate::Schema schema(jschema, Draft2020_12, &jvalidate::curl_get<Json::Value>);
+
+  jvalidate::ValidationResult result;
+  bool compact_error = !args.verbose && !args.format_as_explaination;
+  if (jvalidate::Validator(schema, {.only_return_results_with_error = compact_error})
+          .validate(jobject, &result)) {
+    std::cout << result << "\n";
+    return EXIT_SUCCESS;
+  }
+
+  if (!args.format_as_explaination) {
+    std::cout << result << "\n";
+    return EXIT_FAILURE;
+  }
+
+  Json::Value json;
+  {
+    std::stringstream ss;
+    ss << result;
+    std::string _;
+    load_stream(ss, json, _);
+  }
+
+  std::map<std::string, Json::Value> because;
+  for (Json::Value const & elem : json["details"]) {
+    std::string const & path = elem["evaluationPath"].asString();
+    if (size_t pos = path.find("/if"); pos != std::string::npos && elem.isMember("errors")) {
+      because.emplace(path.substr(0, pos), elem);
+    }
+  }
+
+  Json::Value out;
+  for (Json::Value & elem : json["details"]) {
+    if (!elem.isMember("errors")) {
+      continue;
+    }
+
+    for (std::string const & path = elem["evaluationPath"].asString();
+         auto const & [key, reason] : because) {
+      if (path.starts_with(key) && path != reason["evaluationPath"].asString()) {
+        elem["because"].append(reason);
+      }
+    }
+
+    out.append(std::move(elem));
+  }
+  std::cout << out << std::endl;
+
+  return EXIT_FAILURE;
+}

+ 1 - 1
tests/annotation_test.cxx

@@ -117,7 +117,7 @@ TEST(Annotation, AttachesAlwaysFalseSensibly) {
   })"_json;
   jvalidate::ValidationResult result = validate(schema, instance);
 
-  EXPECT_THAT(result, ErrorAt("/A"_jptr, "/properties"_jptr, "", "always false"));
+  EXPECT_THAT(result, ErrorAt("/A"_jptr, "/properties/A"_jptr, "", "always false"));
 }
 
 int main(int argc, char ** argv) {

+ 8 - 42
tests/selfvalidate_test.cxx

@@ -1,15 +1,14 @@
 #include <cstdio>
 #include <cstdlib>
 #include <filesystem>
-#include <fstream>
 #include <iostream>
 
-#include <curl/curl.h>
 #include <gmock/gmock.h>
 #include <gtest/gtest.h>
 
 #include <jvalidate/adapter.h>
 #include <jvalidate/adapters/jsoncpp.h>
+#include <jvalidate/compat/curl.h>
 #include <jvalidate/enum.h>
 #include <jvalidate/schema.h>
 #include <jvalidate/status.h>
@@ -27,50 +26,16 @@ using jvalidate::schema::Version;
 
 using testing::TestWithParam;
 
-bool load_stream(std::istream & in, Json::Value & out) {
-  Json::CharReaderBuilder builder;
-  std::string error;
-  return Json::parseFromStream(builder, in, &out, &error);
-}
+using jvalidate::adapter::load_file;
 
-bool load_file(std::filesystem::path const & path, Json::Value & out) {
-  std::ifstream in(path);
-  return load_stream(in, out);
-}
-
-size_t transfer_to_buffer(char * data, size_t size, size_t nmemb, void * userdata) {
-  std::stringstream & ss = *reinterpret_cast<std::stringstream *>(userdata);
-  size_t actual_size = size * nmemb;
-  ss << std::string_view(data, actual_size);
-  return actual_size;
-}
-
-bool load_external_for_test(jvalidate::URI const & uri, Json::Value & out) {
+bool load_external_for_test(jvalidate::URI const & uri, Json::Value & out,
+                            std::string & error) noexcept {
   constexpr std::string_view g_fake_url = "localhost:1234/";
   if (uri.scheme().starts_with("http") && uri.resource().starts_with(g_fake_url)) {
     std::string_view path = uri.resource().substr(g_fake_url.size());
-    return load_file(JSONSchemaTestSuiteDir() / "remotes" / path, out);
-  } else if (uri.scheme().starts_with("http")) {
-    std::stringstream ss;
-    if (CURL * curl = curl_easy_init(); curl) {
-      curl_easy_setopt(curl, CURLOPT_URL, uri.c_str());
-      curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
-      curl_easy_setopt(curl, CURLOPT_WRITEDATA, &ss);
-      curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, &transfer_to_buffer);
-
-      CURLcode res = curl_easy_perform(curl);
-      curl_easy_cleanup(curl);
-
-      if (res == CURLE_OK) {
-        return load_stream(ss, out);
-      }
-    }
-    return false;
-  } else if (uri.scheme() == "file") {
-    return load_file(uri.resource(), out);
-  } else {
-    return false;
+    return load_file(JSONSchemaTestSuiteDir() / "remotes" / path, out, error);
   }
+  return jvalidate::curl_get(uri, out, error);
 }
 
 class JsonSchema : public TestWithParam<SchemaParams> {
@@ -133,7 +98,8 @@ TEST_P(JsonSchema, TestSuite) {
   auto const & [version, file] = GetParam();
   Json::Value spec;
 
-  EXPECT_TRUE(load_file(file, spec));
+  std::string error;
+  EXPECT_TRUE(load_file(file, spec, error)) << error;
 
   bool is_format = file.string().find("optional/format") != std::string::npos;
   for (auto const & suite : spec) {